diff --git a/packages/backend/migration/1682753227899-NoteEdit.js b/packages/backend/migration/1682753227899-NoteEdit.js new file mode 100644 index 0000000000..55a0de0206 --- /dev/null +++ b/packages/backend/migration/1682753227899-NoteEdit.js @@ -0,0 +1,53 @@ +export class NoteEdit1682753227899 { + name = "NoteEdit1682753227899"; + + async up(queryRunner) { + await queryRunner.query(` + CREATE TABLE "note_edit" ( + "id" character varying(32) NOT NULL, + "noteId" character varying(32) NOT NULL, + "text" text, + "cw" character varying(512), + "fileIds" character varying(32) array NOT NULL DEFAULT '{}', + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT "PK_736fc6e0d4e222ecc6f82058e08" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + COMMENT ON COLUMN "note_edit"."noteId" IS 'The ID of note.' + `); + await queryRunner.query(` + COMMENT ON COLUMN "note_edit"."updatedAt" IS 'The updated date of the Note.' + `); + await queryRunner.query(` + CREATE INDEX "IDX_702ad5ae993a672e4fbffbcd38" ON "note_edit" ("noteId") + `); + await queryRunner.query(` + ALTER TABLE "note" + ADD "updatedAt" TIMESTAMP WITH TIME ZONE + `); + await queryRunner.query(` + COMMENT ON COLUMN "note"."updatedAt" IS 'The updated date of the Note.' + `); + await queryRunner.query(` + ALTER TABLE "note_edit" + ADD CONSTRAINT "FK_702ad5ae993a672e4fbffbcd38c" + FOREIGN KEY ("noteId") + REFERENCES "note"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION + `); + } + + async down(queryRunner) { + await queryRunner.query(` + ALTER TABLE "note_edit" DROP CONSTRAINT "FK_702ad5ae993a672e4fbffbcd38c" + `); + await queryRunner.query(` + ALTER TABLE "note" DROP COLUMN "updatedAt" + `); + await queryRunner.query(` + DROP TABLE "note_edit" + `); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 18271ee346..232080ad7f 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -31,6 +31,7 @@ import { MetaService } from './MetaService.js'; import { MfmService } from './MfmService.js'; import { ModerationLogService } from './ModerationLogService.js'; import { NoteCreateService } from './NoteCreateService.js'; +import { NoteEditService } from './NoteEditService.js'; import { NoteDeleteService } from './NoteDeleteService.js'; import { NotePiningService } from './NotePiningService.js'; import { NoteReadService } from './NoteReadService.js'; @@ -156,6 +157,7 @@ const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaServic const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService }; const $ModerationLogService: Provider = { provide: 'ModerationLogService', useExisting: ModerationLogService }; const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService }; +const $NoteEditService: Provider = { provide: 'NoteEditService', useExisting: NoteEditService }; const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService }; const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService }; @@ -285,6 +287,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting MfmService, ModerationLogService, NoteCreateService, + NoteEditService, NoteDeleteService, NotePiningService, NoteReadService, @@ -407,6 +410,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $MfmService, $ModerationLogService, $NoteCreateService, + $NoteEditService, $NoteDeleteService, $NotePiningService, $NoteReadService, @@ -530,6 +534,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting MfmService, ModerationLogService, NoteCreateService, + NoteEditService, NoteDeleteService, NotePiningService, NoteReadService, @@ -651,6 +656,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $MfmService, $ModerationLogService, $NoteCreateService, + $NoteEditService, $NoteDeleteService, $NotePiningService, $NoteReadService, diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts new file mode 100644 index 0000000000..c8cf69f934 --- /dev/null +++ b/packages/backend/src/core/NoteEditService.ts @@ -0,0 +1,755 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { setImmediate } from 'node:timers/promises'; +import * as mfm from 'mfm-js'; +import { In } from 'typeorm'; +import * as Redis from 'ioredis'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import RE2 from 're2'; +import { extractMentions } from '@/misc/extract-mentions.js'; +import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; +import { extractHashtags } from '@/misc/extract-hashtags.js'; +import type { IMentionedRemoteUsers } from '@/models/Note.js'; +import { MiNote } from '@/models/Note.js'; +import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { MiApp } from '@/models/App.js'; +import { concat } from '@/misc/prelude/array.js'; +import { IdService } from '@/core/IdService.js'; +import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; +import type { IPoll } from '@/models/Poll.js'; +import { checkWordMute } from '@/misc/check-word-mute.js'; +import type { MiChannel } from '@/models/Channel.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { MemorySingleCache } from '@/misc/cache.js'; +import type { MiUserProfile } from '@/models/UserProfile.js'; +import { RelayService } from '@/core/RelayService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { WebhookService } from '@/core/WebhookService.js'; +import { HashtagService } from '@/core/HashtagService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; +import { NoteReadService } from '@/core/NoteReadService.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { bindThis } from '@/decorators.js'; +import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { RoleService } from '@/core/RoleService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { SearchService } from '@/core/SearchService.js'; + +const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5); + +type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; + +class NotificationManager { + private notifier: { id: MiUser['id']; }; + private note: MiNote; + private queue: { + target: MiLocalUser['id']; + reason: NotificationType; + }[]; + + constructor( + private mutingsRepository: MutingsRepository, + private notificationService: NotificationService, + notifier: { id: MiUser['id']; }, + note: MiNote, + ) { + this.notifier = notifier; + this.note = note; + this.queue = []; + } + + @bindThis + public push(notifiee: MiLocalUser['id'], reason: NotificationType) { + // 自分自身へは通知しない + if (this.notifier.id === notifiee) return; + + const exist = this.queue.find(x => x.target === notifiee); + + if (exist) { + // 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする + if (reason !== 'mention') { + exist.reason = reason; + } + } else { + this.queue.push({ + reason: reason, + target: notifiee, + }); + } + } + + @bindThis + public async deliver() { + for (const x of this.queue) { + // ミュート情報を取得 + const mentioneeMutes = await this.mutingsRepository.findBy({ + muterId: x.target, + }); + + const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId); + + // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する + if (!mentioneesMutedUserIds.includes(this.notifier.id)) { + this.notificationService.createNotification(x.target, x.reason, { + notifierId: this.notifier.id, + noteId: this.note.id, + }); + } + } + } +} + +type MinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + +type Option = { + createdAt?: Date | null; + name?: string | null; + text?: string | null; + reply?: MiNote | null; + renote?: MiNote | null; + files?: MiDriveFile[] | null; + poll?: IPoll | null; + localOnly?: boolean | null; + reactionAcceptance?: MiNote['reactionAcceptance']; + cw?: string | null; + visibility?: string; + visibleUsers?: MinimumUser[] | null; + channel?: MiChannel | null; + apMentions?: MinimumUser[] | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + uri?: string | null; + url?: string | null; + app?: MiApp | null; + updatedAt?: Date | null; + editcount?: boolean | null; +}; + +@Injectable() +export class NoteEditService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.mutedNotesRepository) + private mutedNotesRepository: MutedNotesRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.noteThreadMutingsRepository) + private noteThreadMutingsRepository: NoteThreadMutingsRepository, + + @Inject(DI.noteEditRepository) + private noteEditRepository: NoteEditRepository, + + private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + private queueService: QueueService, + private noteReadService: NoteReadService, + private notificationService: NotificationService, + private relayService: RelayService, + private federatedInstanceService: FederatedInstanceService, + private hashtagService: HashtagService, + private webhookService: WebhookService, + private remoteUserResolveService: RemoteUserResolveService, + private apDeliverManagerService: ApDeliverManagerService, + private apRendererService: ApRendererService, + private roleService: RoleService, + private metaService: MetaService, + private searchService: SearchService, + private activeUsersChart: ActiveUsersChart, + private instanceChart: InstanceChart, + ) { } + + @bindThis + public async edit(user: { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; + createdAt: MiUser['createdAt']; + isBot: MiUser['isBot']; + }, editid: MiNote['id'], data: Option, silent = false): Promise { + + if (!editid) { + throw new Error('fail'); + }; + + let oldnote = await this.notesRepository.findOneBy({ + id: editid, + }); + + if (oldnote == null) { + throw new Error('no such note'); + }; + + if (oldnote.userId !== user.id) { + throw new Error('not the author'); + }; + + // チャンネル外にリプライしたら対象のスコープに合わせる + // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) + if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { + if (data.reply.channelId) { + data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); + } else { + data.channel = null; + } + } + + // チャンネル内にリプライしたら対象のスコープに合わせる + // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) + if (data.reply && (data.channel == null) && data.reply.channelId) { + data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); + } + + if (data.visibility == null) data.visibility = 'public'; + if (data.localOnly == null) data.localOnly = false; + if (data.channel != null) data.visibility = 'public'; + if (data.channel != null) data.visibleUsers = []; + if (data.channel != null) data.localOnly = true; + if (data.updatedAt == null) data.updatedAt = new Date(); + + if (data.visibility === 'public' && data.channel == null) { + const sensitiveWords = (await this.metaService.fetch()).sensitiveWords; + if (this.isSensitive(data, sensitiveWords)) { + data.visibility = 'home'; + } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { + data.visibility = 'home'; + } + } + + // Renote対象が「ホームまたは全体」以外の公開範囲ならreject + if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) { + throw new Error('Renote target is not public or home'); + } + + // Renote対象がpublicではないならhomeにする + if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') { + data.visibility = 'home'; + } + + // Renote対象がfollowersならfollowersにする + if (data.renote && data.renote.visibility === 'followers') { + data.visibility = 'followers'; + } + + // 返信対象がpublicではないならhomeにする + if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { + data.visibility = 'home'; + } + + // ローカルのみをRenoteしたらローカルのみにする + if (data.renote && data.renote.localOnly && data.channel == null) { + data.localOnly = true; + } + + // ローカルのみにリプライしたらローカルのみにする + if (data.reply && data.reply.localOnly && data.channel == null) { + data.localOnly = true; + } + + if (data.text) { + if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) { + data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); + } + data.text = data.text.trim(); + } else { + data.text = null; + } + + let tags = data.apHashtags; + let emojis = data.apEmojis; + let mentionedUsers = data.apMentions; + + // Parse MFM if needed + if (!tags || !emojis || !mentionedUsers) { + const tokens = data.text ? mfm.parse(data.text)! : []; + const cwTokens = data.cw ? mfm.parse(data.cw)! : []; + const choiceTokens = data.poll && data.poll.choices + ? concat(data.poll.choices.map(choice => mfm.parse(choice)!)) + : []; + + const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); + + tags = data.apHashtags ?? extractHashtags(combinedTokens); + + emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens); + + mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens); + } + + tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32); + + if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) { + mentionedUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); + } + + if (data.visibility === 'specified') { + if (data.visibleUsers == null) throw new Error('invalid param'); + + for (const u of data.visibleUsers) { + if (!mentionedUsers.some(x => x.id === u.id)) { + mentionedUsers.push(u); + } + } + + if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) { + data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); + } + } + + const update: Partial = {}; + if (data.text !== oldnote.text) { + update.text = data.text; + } + if (data.cw !== oldnote.cw) { + update.cw = data.cw; + } + if (data.localOnly !== oldnote.localOnly) { + update.localOnly = data.localOnly; + } + if (oldnote.hasPoll !== !!data.poll) { + update.hasPoll = !!data.poll; + } + + await this.noteEditRepository.insert({ + id: this.idService.genId(), + noteId: oldnote.id, + text: data.text || undefined, + cw: data.cw, + fileIds: undefined, + updatedAt: new Date(), + }); + + const note = new MiNote({ + id: oldnote.id, + createdAt: new Date(oldnote.createdAt!), + updatedAt: data.updatedAt ? data.updatedAt : new Date(), + fileIds: data.files ? data.files.map(file => file.id) : [], + replyId: data.reply ? data.reply.id : null, + renoteId: data.renote ? data.renote.id : null, + channelId: data.channel ? data.channel.id : null, + threadId: data.reply + ? data.reply.threadId + ? data.reply.threadId + : data.reply.id + : null, + name: data.name, + text: data.text, + hasPoll: data.poll != null, + cw: data.cw ?? null, + tags: tags.map(tag => normalizeForSearch(tag)), + emojis, + reactions: oldnote.reactions, + userId: user.id, + localOnly: data.localOnly!, + reactionAcceptance: data.reactionAcceptance, + visibility: data.visibility as any, + visibleUserIds: data.visibility === 'specified' + ? data.visibleUsers + ? data.visibleUsers.map(u => u.id) + : [] + : [], + + attachedFileTypes: data.files ? data.files.map(file => file.type) : [], + + // 以下非正規化データ + replyUserId: data.reply ? data.reply.userId : null, + replyUserHost: data.reply ? data.reply.userHost : null, + renoteUserId: data.renote ? data.renote.userId : null, + renoteUserHost: data.renote ? data.renote.userHost : null, + userHost: user.host, + }); + + if (data.uri != null) note.uri = data.uri; + if (data.url != null) note.url = data.url; + + if (mentionedUsers.length > 0) { + note.mentions = mentionedUsers.map(u => u.id); + const profiles = await this.userProfilesRepository.findBy({ userId: In(note.mentions) }); + note.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u)).map(u => { + const profile = profiles.find(p => p.userId === u.id); + const url = profile != null ? profile.url : null; + return { + uri: u.uri, + url: url ?? undefined, + username: u.username, + host: u.host, + } as IMentionedRemoteUsers[0]; + })); + } + + await this.notesRepository.update(oldnote.id, note); + + if (data.channel) { + this.redisClient.xadd( + `channelTimeline:${data.channel.id}`, + 'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(), + '*', + 'note', note.id); + } + + setImmediate('post edited', { signal: this.#shutdownController.signal }).then( + () => this.postNoteEdited(note, user, data, silent, tags!, mentionedUsers!), + () => { /* aborted, ignore this */ }, + ); + + return note; + } + + @bindThis + private async postNoteEdited(note: MiNote, user: { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; + createdAt: MiUser['createdAt']; + isBot: MiUser['isBot']; + }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { + + // Register host + if (this.userEntityService.isRemoteUser(user)) { + this.federatedInstanceService.fetch(user.host).then(async i => { + this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, true); + } + }); + } + + // ハッシュタグ更新 + if (data.visibility === 'public' || data.visibility === 'home') { + this.hashtagService.updateHashtags(user, tags); + } + + // Word mute + mutedWordsCache.fetch(() => this.userProfilesRepository.find({ + where: { + enableWordMute: true, + }, + select: ['userId', 'mutedWords'], + })).then(us => { + for (const u of us) { + checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => { + if (shouldMute) { + this.mutedNotesRepository.insert({ + id: this.idService.genId(), + userId: u.userId, + noteId: note.id, + reason: 'word', + }); + } + }); + } + }); + + if (data.poll && data.poll.expiresAt) { + const delay = data.poll.expiresAt.getTime() - Date.now(); + this.queueService.endedPollNotificationQueue.add(note.id, { + noteId: note.id, + }, { + delay, + removeOnComplete: true, + }); + } + + if (!silent) { + if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); + + // 未読通知を作成 + if (data.visibility === 'specified') { + if (data.visibleUsers == null) throw new Error('invalid param'); + + for (const u of data.visibleUsers) { + // ローカルユーザーのみ + if (!this.userEntityService.isLocalUser(u)) continue; + + this.noteReadService.insertNoteUnread(u.id, note, { + isSpecified: true, + isMentioned: false, + }); + } + } else { + for (const u of mentionedUsers) { + // ローカルユーザーのみ + if (!this.userEntityService.isLocalUser(u)) continue; + + this.noteReadService.insertNoteUnread(u.id, note, { + isSpecified: false, + isMentioned: true, + }); + } + } + + // Pack the note + const noteObj = await this.noteEntityService.pack(note); + + this.globalEventService.publishNotesStream(noteObj); + + this.roleService.addNoteToRoleTimeline(noteObj); + + this.webhookService.getActiveWebhooks().then(webhooks => { + webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'note', { + note: noteObj, + }); + } + }); + + const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note); + + await this.createMentionedEvents(mentionedUsers, note, nm); + + // If has in reply to note + if (data.reply) { + // 通知 + if (data.reply.userHost === null) { + const isThreadMuted = await this.noteThreadMutingsRepository.exist({ + where: { + userId: data.reply.userId, + threadId: data.reply.threadId ?? data.reply.id, + }, + }); + + if (!isThreadMuted) { + nm.push(data.reply.userId, 'reply'); + this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'reply', { + note: noteObj, + }); + } + } + } + } + + // If it is renote + if (data.renote) { + const type = data.text ? 'quote' : 'renote'; + + // Notify + if (data.renote.userHost === null) { + nm.push(data.renote.userId, type); + } + + // Publish event + if ((user.id !== data.renote.userId) && data.renote.userHost === null) { + this.globalEventService.publishMainStream(data.renote.userId, 'renote', noteObj); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'renote', { + note: noteObj, + }); + } + } + } + + nm.deliver(); + + //#region AP deliver + if (this.userEntityService.isLocalUser(user)) { + (async () => { + const noteActivity = await this.renderNoteOrRenoteActivity(data, note); + const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); + + // メンションされたリモートユーザーに配送 + for (const u of mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u))) { + dm.addDirectRecipe(u as MiRemoteUser); + } + + // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 + if (data.reply && data.reply.userHost !== null) { + const u = await this.usersRepository.findOneBy({ id: data.reply.userId }); + if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u); + } + + // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 + if (data.renote && data.renote.userHost !== null) { + const u = await this.usersRepository.findOneBy({ id: data.renote.userId }); + if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u); + } + + // フォロワーに配送 + if (['public', 'home', 'followers'].includes(note.visibility)) { + dm.addFollowersRecipe(); + } + + if (['public'].includes(note.visibility)) { + this.relayService.deliverToRelays(user, noteActivity); + } + + dm.execute(); + })(); + } + //#endregion + } + + if (data.channel) { + this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1); + this.channelsRepository.update(data.channel.id, { + lastNotedAt: new Date(), + }); + + this.notesRepository.countBy({ + userId: user.id, + channelId: data.channel.id, + }).then(count => { + // この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる + // TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい + if (count === 1) { + this.channelsRepository.increment({ id: data.channel!.id }, 'usersCount', 1); + } + }); + } + + // Register to search database + this.index(note); + } + + @bindThis + private isSensitive(note: Option, sensitiveWord: string[]): boolean { + if (sensitiveWord.length > 0) { + const text = note.cw ?? note.text ?? ''; + if (text === '') return false; + const matched = sensitiveWord.some(filter => { + // represents RegExp + const regexp = filter.match(/^\/(.+)\/(.*)$/); + // This should never happen due to input sanitisation. + if (!regexp) { + const words = filter.split(' '); + return words.every(keyword => text.includes(keyword)); + } + try { + return new RE2(regexp[1], regexp[2]).test(text); + } catch (err) { + // This should never happen due to input sanitisation. + return false; + } + }); + if (matched) return true; + } + return false; + } + + @bindThis + private async createMentionedEvents(mentionedUsers: MinimumUser[], note: MiNote, nm: NotificationManager) { + for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) { + const isThreadMuted = await this.noteThreadMutingsRepository.exist({ + where: { + userId: u.id, + threadId: note.threadId ?? note.id, + }, + }); + + if (isThreadMuted) { + continue; + } + + const detailPackedNote = await this.noteEntityService.pack(note, u, { + detail: true, + }); + + this.globalEventService.publishMainStream(u.id, 'mention', detailPackedNote); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'mention', { + note: detailPackedNote, + }); + } + + // Create notification + nm.push(u.id, 'mention'); + } + } + + @bindThis + private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { + if (data.localOnly) return null; + + const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0) + ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) + : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); + + return this.apRendererService.addContext(content); + } + + @bindThis + private index(note: MiNote) { + if (note.text == null && note.cw == null) return; + + this.searchService.indexNote(note); + } + + @bindThis + private async extractMentionedUsers(user: { host: MiUser['host']; }, tokens: mfm.MfmNode[]): Promise { + if (tokens == null) return []; + + const mentions = extractMentions(tokens); + let mentionedUsers = (await Promise.all(mentions.map(m => + this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null), + ))).filter(x => x != null) as MiUser[]; + + // Drop duplicate users + mentionedUsers = mentionedUsers.filter((u, i, self) => + i === self.findIndex(u2 => u.id === u2.id), + ); + + return mentionedUsers; + } + + @bindThis + public dispose(): void { + this.#shutdownController.abort(); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts index 60868627a2..c45c27f863 100644 --- a/packages/backend/src/core/activitypub/ApMfmService.ts +++ b/packages/backend/src/core/activitypub/ApMfmService.ts @@ -27,6 +27,6 @@ export class ApMfmService { @bindThis public getNoteHtml(note: MiNote): string | null { if (!note.text) return ''; - return this.mfmService.toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers)); + return this.mfmService.toHtml(mfm.parse(note.text), note.mentionedRemoteUsers ? JSON.parse(note.mentionedRemoteUsers) : []); } } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 7a9d2e21d8..b772f3efd5 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -353,7 +353,7 @@ export class ApRendererService { const attributedTo = this.userEntityService.genLocalUserUri(note.userId); - const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); + const mentions = note.mentionedRemoteUsers ? (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri) : []; let to: string[] = []; let cc: string[] = []; @@ -371,7 +371,7 @@ export class ApRendererService { to = mentions; } - const mentionedUsers = note.mentions.length > 0 ? await this.usersRepository.findBy({ + const mentionedUsers = note.mentions && note.mentions.length > 0 ? await this.usersRepository.findBy({ id: In(note.mentions), }) : []; diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index bf42e98ce0..7b54ce5c6d 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -335,9 +335,10 @@ export class NoteEntityService implements OnModuleInit { color: channel.color, isSensitive: channel.isSensitive, } : undefined, - mentions: note.mentions.length > 0 ? note.mentions : undefined, + mentions: note.mentions && note.mentions.length > 0 ? note.mentions : undefined, uri: note.uri ?? undefined, url: note.url ?? undefined, + updatedAt: note.updatedAt != null ? note.updatedAt.toISOString() : undefined, ...(opts.detail ? { clippedCount: note.clippedCount, diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 72ec98cebe..7034fff058 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -77,5 +77,6 @@ export const DI = { flashsRepository: Symbol('flashsRepository'), flashLikesRepository: Symbol('flashLikesRepository'), userMemosRepository: Symbol('userMemosRepository'), + noteEditRepository: Symbol('noteEditRepository'), //#endregion }; diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index ed86d4549e..6855f83b61 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -239,6 +239,12 @@ export class MiNote { comment: '[Denormalized]', }) public renoteUserHost: string | null; + + @Index() + @Column('timestamp with time zone', { + comment: 'The update time of the Note.', + }) + public updatedAt: Date | null; //#endregion constructor(data: Partial) { diff --git a/packages/backend/src/models/NoteEdit.ts b/packages/backend/src/models/NoteEdit.ts new file mode 100644 index 0000000000..547b135e56 --- /dev/null +++ b/packages/backend/src/models/NoteEdit.ts @@ -0,0 +1,46 @@ +import { Entity, JoinColumn, Column, ManyToOne, PrimaryColumn, Index } from "typeorm"; +import { id } from './util/id.js'; +import { MiNote } from './Note.js'; +import type { MiDriveFile } from './DriveFile.js'; + +@Entity() +export class NoteEdit { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + comment: "The ID of note.", + }) + public noteId: MiNote["id"]; + + @ManyToOne((type) => MiNote, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note: MiNote | null; + + @Column("text", { + nullable: true, + }) + public text: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public cw: string | null; + + @Column({ + ...id(), + array: true, + default: "{}", + }) + public fileIds: MiDriveFile["id"][]; + + @Column("timestamp with time zone", { + comment: "The updated date of the Note.", + }) + public updatedAt: Date; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 766e7ce21c..7e2bee8c44 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -5,7 +5,7 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMutedNote, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListJoining, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './_.js'; +import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMutedNote, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListJoining, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, NoteEdit } from './_.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -399,6 +399,12 @@ const $userMemosRepository: Provider = { inject: [DI.db], }; +const $noteEditRepository: Provider = { + provide: DI.noteEditRepository, + useFactory: (db: DataSource) => db.getRepository(NoteEdit), + inject: [DI.db], +}; + @Module({ imports: [ ], @@ -468,6 +474,7 @@ const $userMemosRepository: Provider = { $flashsRepository, $flashLikesRepository, $userMemosRepository, + $noteEditRepository, ], exports: [ $usersRepository, @@ -535,6 +542,7 @@ const $userMemosRepository: Provider = { $flashsRepository, $flashLikesRepository, $userMemosRepository, + $noteEditRepository, ], }) export class RepositoryModule {} diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 6be7bd0df6..e6eafa4184 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -68,6 +68,7 @@ import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiFlash } from '@/models/Flash.js'; import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserListFavorite } from '@/models/UserListFavorite.js'; +import { NoteEdit } from '@/models/NoteEdit.js'; import type { Repository } from 'typeorm'; export { @@ -136,6 +137,7 @@ export { MiFlash, MiFlashLike, MiUserMemo, + NoteEdit, }; export type AbuseUserReportsRepository = Repository; @@ -203,3 +205,4 @@ export type RoleAssignmentsRepository = Repository; export type FlashsRepository = Repository; export type FlashLikesRepository = Repository; export type UserMemoRepository = Repository; +export type NoteEditRepository = Repository; \ No newline at end of file diff --git a/packages/backend/src/models/json-schema/note-edit.ts b/packages/backend/src/models/json-schema/note-edit.ts new file mode 100644 index 0000000000..e877f3f946 --- /dev/null +++ b/packages/backend/src/models/json-schema/note-edit.ts @@ -0,0 +1,49 @@ +export const packedNoteEdit = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + updatedAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + note: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + noteId: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + text: { + type: "string", + optional: true, + nullable: true, + }, + cw: { + type: "string", + optional: true, + nullable: true, + }, + fileIds: { + type: "array", + optional: true, + nullable: true, + items: { + type: "string", + format: "id", + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index eb744aa109..f3c436d0b3 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -22,6 +22,11 @@ export const packedNoteSchema = { optional: true, nullable: true, format: 'date-time', }, + updatedAt: { + type: 'string', + optional: true, nullable: true, + format: 'date-time', + }, text: { type: 'string', optional: false, nullable: true, diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 10126eab2b..b12a84ac96 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -76,6 +76,7 @@ import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiFlash } from '@/models/Flash.js'; import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserMemo } from '@/models/UserMemo.js'; +import { NoteEdit } from '@/models/NoteEdit.js'; import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; @@ -190,6 +191,7 @@ export const entities = [ MiFlash, MiFlashLike, MiUserMemo, + NoteEdit, ...charts, ]; diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 41a11bfb19..fef8214bc1 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -272,6 +272,7 @@ import * as ep___notes_reactions_create from './endpoints/notes/reactions/create import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js'; import * as ep___notes_renotes from './endpoints/notes/renotes.js'; import * as ep___notes_replies from './endpoints/notes/replies.js'; +import * as ep___notes_edit from './endpoints/notes/edit.js'; import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js'; import * as ep___notes_search from './endpoints/notes/search.js'; import * as ep___notes_show from './endpoints/notes/show.js'; @@ -630,6 +631,7 @@ const $notes_timeline: Provider = { provide: 'ep:notes/timeline', useClass: ep__ const $notes_translate: Provider = { provide: 'ep:notes/translate', useClass: ep___notes_translate.default }; const $notes_unrenote: Provider = { provide: 'ep:notes/unrenote', useClass: ep___notes_unrenote.default }; const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default }; +const $notes_edit: Provider = { provide: 'ep:notes/edit', useClass: ep___notes_edit.default }; const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default }; const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default }; const $notifications_testNotification: Provider = { provide: 'ep:notifications/test-notification', useClass: ep___notifications_testNotification.default }; @@ -982,6 +984,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_translate, $notes_unrenote, $notes_userListTimeline, + $notes_edit, $notifications_create, $notifications_markAllAsRead, $notifications_testNotification, @@ -1328,6 +1331,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_translate, $notes_unrenote, $notes_userListTimeline, + $notes_edit, $notifications_create, $notifications_markAllAsRead, $pagePush, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index ab20a708ef..208bffeb6b 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -282,6 +282,7 @@ import * as ep___notes_timeline from './endpoints/notes/timeline.js'; import * as ep___notes_translate from './endpoints/notes/translate.js'; import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; +import * as ep___notes_edit from './endpoints/notes/edit.js'; import * as ep___notifications_create from './endpoints/notifications/create.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js'; @@ -628,6 +629,7 @@ const eps = [ ['notes/translate', ep___notes_translate], ['notes/unrenote', ep___notes_unrenote], ['notes/user-list-timeline', ep___notes_userListTimeline], + ['notes/edit', ep___notes_edit], ['notifications/create', ep___notifications_create], ['notifications/mark-all-as-read', ep___notifications_markAllAsRead], ['notifications/test-notification', ep___notifications_testNotification], diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts new file mode 100644 index 0000000000..0d621c40ba --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/edit.ts @@ -0,0 +1,357 @@ +import ms from 'ms'; +import { In } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import type { MiUser } from '@/models/User.js'; +import type { UsersRepository, NotesRepository, BlockingsRepository, DriveFilesRepository, ChannelsRepository } from '@/models/_.js'; +import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { MiNote } from '@/models/Note.js'; +import type { MiChannel } from '@/models/Channel.js'; +import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { NoteEditService } from '@/core/NoteEditService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + limit: { + duration: ms('1hour'), + max: 300, + }, + + kind: "write:notes", + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + createdNote: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, + }, + + errors: { + noSuchRenoteTarget: { + message: "No such renote target.", + code: "NO_SUCH_RENOTE_TARGET", + id: "b5c90186-4ab0-49c8-9bba-a1f76c282ba4", + }, + + cannotReRenote: { + message: "You can not Renote a pure Renote.", + code: "CANNOT_RENOTE_TO_A_PURE_RENOTE", + id: "fd4cc33e-2a37-48dd-99cc-9b806eb2031a", + }, + + noSuchReplyTarget: { + message: "No such reply target.", + code: "NO_SUCH_REPLY_TARGET", + id: "749ee0f6-d3da-459a-bf02-282e2da4292c", + }, + + cannotReplyToPureRenote: { + message: "You can not reply to a pure Renote.", + code: "CANNOT_REPLY_TO_A_PURE_RENOTE", + id: "3ac74a84-8fd5-4bb0-870f-01804f82ce15", + }, + + cannotCreateAlreadyExpiredPoll: { + message: "Poll is already expired.", + code: "CANNOT_CREATE_ALREADY_EXPIRED_POLL", + id: "04da457d-b083-4055-9082-955525eda5a5", + }, + + noSuchChannel: { + message: "No such channel.", + code: "NO_SUCH_CHANNEL", + id: "b1653923-5453-4edc-b786-7c4f39bb0bbb", + }, + + youHaveBeenBlocked: { + message: "You have been blocked by this user.", + code: "YOU_HAVE_BEEN_BLOCKED", + id: "b390d7e1-8a5e-46ed-b625-06271cafd3d3", + }, + + accountLocked: { + message: "You migrated. Your account is now locked.", + code: "ACCOUNT_LOCKED", + id: "d390d7e1-8a5e-46ed-b625-06271cafd3d3", + }, + + needsEditId: { + message: "You need to specify `editId`.", + code: "NEEDS_EDIT_ID", + id: "d697edc8-8c73-4de8-bded-35fd198b79e5", + }, + + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "eef6c173-3010-4a23-8674-7c4fcaeba719", + }, + + youAreNotTheAuthor: { + message: "You are not the author of this note.", + code: "YOU_ARE_NOT_THE_AUTHOR", + id: "c6e61685-411d-43d0-b90a-a448d2539001", + }, + + cannotPrivateRenote: { + message: "You can not perform a private renote.", + code: "CANNOT_PRIVATE_RENOTE", + id: "19a50f1c-84fa-4e33-81d3-17834ccc0ad8", + }, + + notLocalUser: { + message: "You are not a local user.", + code: "NOT_LOCAL_USER", + id: "b907f407-2aa0-4283-800b-a2c56290b822", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + editId: { type: "string", format: "misskey:id" }, + visibility: { type: "string", enum: ['public', 'home', 'followers', 'specified'], default: "public" }, + visibleUserIds: { + type: "array", + uniqueItems: true, + items: { + type: "string", + format: "misskey:id", + }, + }, + text: { type: "string", maxLength: MAX_NOTE_TEXT_LENGTH, nullable: true }, + cw: { type: "string", nullable: true, maxLength: 250 }, + localOnly: { type: "boolean", default: false }, + noExtractMentions: { type: "boolean", default: false }, + noExtractHashtags: { type: "boolean", default: false }, + noExtractEmojis: { type: "boolean", default: false }, + fileIds: { + type: "array", + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: "string", format: "misskey:id" }, + }, + mediaIds: { + deprecated: true, + description: + "Use `fileIds` instead. If both are specified, this property is discarded.", + type: "array", + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: "string", format: "misskey:id" }, + }, + replyId: { type: "string", format: "misskey:id", nullable: true }, + renoteId: { type: "string", format: "misskey:id", nullable: true }, + channelId: { type: "string", format: "misskey:id", nullable: true }, + poll: { + type: "object", + nullable: true, + properties: { + choices: { + type: "array", + uniqueItems: true, + minItems: 2, + maxItems: 10, + items: { type: "string", minLength: 1, maxLength: 50 }, + }, + multiple: { type: "boolean", default: false }, + expiresAt: { type: "integer", nullable: true }, + expiredAfter: { type: "integer", nullable: true, minimum: 1 }, + }, + required: ["choices"], + }, + }, + anyOf: [ + { + // (re)note with text, files and poll are optional + properties: { + text: { + type: "string", + minLength: 1, + maxLength: MAX_NOTE_TEXT_LENGTH, + nullable: false, + }, + }, + required: ["text"], + }, + { + // (re)note with files, text and poll are optional + required: ["fileIds"], + }, + { + // (re)note with files, text and poll are optional + required: ["mediaIds"], + }, + { + // (re)note with poll, text and files are optional + properties: { + poll: { type: "object", nullable: false }, + }, + required: ["poll"], + }, + { + // pure renote + required: ["renoteId"], + }, + ], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + private noteEntityService: NoteEntityService, + private noteEditService: NoteEditService, + ) { + super(meta, paramDef, async (ps, me) => { + let visibleUsers: MiUser[] = []; + if (ps.visibleUserIds) { + visibleUsers = await this.usersRepository.findBy({ + id: In(ps.visibleUserIds), + }); + } + + let files: MiDriveFile[] = []; + const fileIds = ps.fileIds ?? ps.mediaIds ?? null; + if (fileIds != null) { + files = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId AND file.id IN (:...fileIds)', { + userId: me.id, + fileIds, + }) + .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') + .setParameters({ fileIds }) + .getMany(); + + if (files.length !== fileIds.length) { + throw new ApiError(meta.errors.noSuchNote); + } + } + + let renote: MiNote | null = null; + if (ps.renoteId != null) { + // Fetch renote to note + renote = await this.notesRepository.findOneBy({ id: ps.renoteId }); + + if (renote == null) { + throw new ApiError(meta.errors.noSuchRenoteTarget); + } else if (renote.renoteId && !renote.text && !renote.fileIds && !renote.hasPoll) { + throw new ApiError(meta.errors.cannotReRenote); + } + + // Check blocking + if (renote.userId !== me.id) { + const blockExist = await this.blockingsRepository.exist({ + where: { + blockerId: renote.userId, + blockeeId: me.id, + }, + }); + if (blockExist) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + } + + let reply: MiNote | null = null; + if (ps.replyId != null) { + // Fetch reply + reply = await this.notesRepository.findOneBy({ id: ps.replyId }); + + if (reply == null) { + throw new ApiError(meta.errors.noSuchReplyTarget); + } else if (reply.renoteId && !reply.text && !reply.fileIds && !reply.hasPoll) { + throw new ApiError(meta.errors.cannotReplyToPureRenote); + } + + // Check blocking + if (reply.userId !== me.id) { + const blockExist = await this.blockingsRepository.exist({ + where: { + blockerId: reply.userId, + blockeeId: me.id, + }, + }); + if (blockExist) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + } + + if (ps.poll) { + if (typeof ps.poll.expiresAt === 'number') { + if (ps.poll.expiresAt < Date.now()) { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + } + } else if (typeof ps.poll.expiredAfter === 'number') { + ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter; + } + } + + let channel: MiChannel | null = null; + if (ps.channelId != null) { + channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + } + + // 投稿を作成 + const note = await this.noteEditService.edit(me, ps.editId!, { + files: files, + poll: ps.poll ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple ?? false, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + } : undefined, + text: ps.text ?? undefined, + reply, + renote, + cw: ps.cw, + localOnly: ps.localOnly, + reactionAcceptance: ps.reactionAcceptance, + visibility: ps.visibility, + visibleUsers, + channel, + apMentions: ps.noExtractMentions ? [] : undefined, + apHashtags: ps.noExtractHashtags ? [] : undefined, + apEmojis: ps.noExtractEmojis ? [] : undefined, + }); + + return { + createdNote: await this.noteEntityService.pack(note, me), + }; + }); + } +} \ No newline at end of file diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 0bc94f5bd2..f085b30b03 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -39,6 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only +
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index e8e52e00a4..76d2a2e594 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -58,6 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only +
diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index dda7238d27..63392fef71 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only + diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 2b4dcc8ed4..9a7327eaf6 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -143,6 +143,7 @@ const props = withDefaults(defineProps<{ fixed?: boolean; autofocus?: boolean; freezeAfterPosted?: boolean; + editId?: Misskey.entities.Note["id"]; }>(), { initialVisibleUsers: () => [], autofocus: true, @@ -709,6 +710,7 @@ async function post(ev?: MouseEvent) { visibility: visibility, visibleUserIds: visibility === 'specified' ? visibleUsers.map(u => u.id) : undefined, reactionAcceptance, + editId: props.editId ? props.editId : undefined, }; if (withHashtags && hashtags && hashtags.trim() !== '') { @@ -731,7 +733,7 @@ async function post(ev?: MouseEvent) { } posting = true; - os.api('notes/create', postData, token).then(() => { + os.api(postData.editId ? "notes/edit" : "notes/create", postData, token).then(() => { if (props.freezeAfterPosted) { posted = true; } else { @@ -755,7 +757,7 @@ async function post(ev?: MouseEvent) { const text = postData.text ?? ''; const lowerCase = text.toLowerCase(); - if ((lowerCase.includes('love') || lowerCase.includes('❤')) && lowerCase.includes('misskey')) { + if ((lowerCase.includes('love') || lowerCase.includes('❤')) && lowerCase.includes('sharkey')) { claimAchievement('iLoveMisskey'); } if ([ diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index c07a166a83..25a8788a38 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -30,6 +30,7 @@ const props = defineProps<{ instant?: boolean; fixed?: boolean; autofocus?: boolean; + editId?: Misskey.entities.Note["id"]; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index 734a632039..476db3e9a6 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -171,6 +171,15 @@ export function getNoteMenu(props: { } }); } + function edit(): void { + os.post({ + initialNote: appearNote, + renote: appearNote.renote, + reply: appearNote.reply, + channel: appearNote.channel, + editId: appearNote.id, + }); +} function toggleFavorite(favorite: boolean): void { claimAchievement('noteFavorited1'); @@ -353,10 +362,17 @@ export function getNoteMenu(props: { ...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [ null, appearNote.userId === $i.id ? { + icon: 'ti ti-pencil', + text: i18n.ts.edit, + danger: true, + action: edit, + }: undefined, + { icon: 'ti ti-edit', text: i18n.ts.deleteAndEdit, + danger: true, action: delEdit, - } : undefined, + }, { icon: 'ti ti-trash', text: i18n.ts.delete, diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts index 46d790fe31..3275f0c8e6 100644 --- a/packages/misskey-js/src/api.types.ts +++ b/packages/misskey-js/src/api.types.ts @@ -508,6 +508,24 @@ export type Endpoints = { }; }; res: { createdNote: Note }; }; 'notes/delete': { req: { noteId: Note['id']; }; res: null; }; + 'notes/edit': { req: { + visibility?: 'public' | 'home' | 'followers' | 'specified', + visibleUserIds?: User['id'][]; + text?: null | string; + cw?: null | string; + viaMobile?: boolean; + localOnly?: boolean; + fileIds?: DriveFile['id'][]; + replyId?: null | Note['id']; + renoteId?: null | Note['id']; + channelId?: null | Channel['id']; + poll?: null | { + choices: string[]; + multiple?: boolean; + expiresAt?: null | number; + expiredAfter?: null | number; + }; + }; res: { createdNote: Note }; }; 'notes/favorites/create': { req: { noteId: Note['id']; }; res: null; }; 'notes/favorites/delete': { req: { noteId: Note['id']; }; res: null; }; 'notes/featured': { req: TODO; res: Note[]; }; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 9a0114d71c..c7cb81e29e 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -194,6 +194,7 @@ export type Note = { uri?: string; url?: string; isHidden?: boolean; + updatedAt?: DateString; }; export type NoteReaction = { @@ -203,6 +204,15 @@ export type NoteReaction = { type: string; }; +export type NoteEdit = { + noteId: Note['id']; + note: Note; + text: string; + cw: string; + fileIds: DriveFile['id'][]; + updatedAt?: DateString; +} + export type Notification = { id: ID; createdAt: DateString;