From 6c9dcb84abc1a11e21d03b6e82b46d1743fe0c03 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 4 May 2025 10:08:41 -0400 Subject: [PATCH 1/8] resolve mentioned user handles on the backend --- .../src/core/entities/NoteEntityService.ts | 58 ++++++++++++++++++- .../backend/src/models/json-schema/note.ts | 10 ++++ packages/misskey-js/src/autogen/types.ts | 3 + 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index eceb972a91..94fe9e97ef 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -402,7 +402,8 @@ export class NoteEntityService implements OnModuleInit { bufferedReactions: Map; pairs: ([MiUser['id'], string])[] }> | null; myReactions: Map; packedFiles: Map | null>; - packedUsers: Map> + packedUsers: Map>; + mentionHandles: Record; }; }, ): Promise> { @@ -443,6 +444,9 @@ export class NoteEntityService implements OnModuleInit { const packedFiles = options?._hint_?.packedFiles; const packedUsers = options?._hint_?.packedUsers; + // Do not await - defer until the awaitAll below + const mentionHandles = this.getUserHandles(note.mentions, options?._hint_?.mentionHandles); + const packed: Packed<'Note'> = await awaitAll({ id: note.id, createdAt: this.idService.parse(note.id).date.toISOString(), @@ -476,7 +480,8 @@ export class NoteEntityService implements OnModuleInit { allowRenoteToExternal: channel.allowRenoteToExternal, userId: channel.userId, } : undefined, - mentions: note.mentions && note.mentions.length > 0 ? note.mentions : undefined, + mentions: note.mentions.length > 0 ? note.mentions : undefined, + mentionHandles: note.mentions.length > 0 ? mentionHandles : undefined, uri: note.uri ?? undefined, url: note.url ?? undefined, poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, @@ -594,6 +599,22 @@ export class NoteEntityService implements OnModuleInit { const packedUsers = await this.userEntityService.packMany(users, me) .then(users => new Map(users.map(u => [u.id, u]))); + // Recursively add all mentioned users from all notes + replies + renotes + const allMentionedUsers = notes.reduce((users, note) => { + function add(n: MiNote) { + for (const user of n.mentions) { + users.add(user); + } + + if (n.reply) add(n.reply); + if (n.renote) add(n.renote); + } + + add(note); + return users; + }, new Set()); + const mentionHandles = await this.getUserHandles(Array.from(allMentionedUsers)); + return await Promise.all(notes.map(n => this.pack(n, me, { ...options, _hint_: { @@ -601,6 +622,7 @@ export class NoteEntityService implements OnModuleInit { myReactions: myReactionsMap, packedFiles, packedUsers, + mentionHandles, }, }))); } @@ -636,4 +658,36 @@ export class NoteEntityService implements OnModuleInit { relations: ['user'], }); } + + private async getUserHandles(userIds: string[], hint?: Record): Promise> { + if (userIds.length < 1) return {}; + + // Hint is provided by packMany to avoid N+1 queries. + // It should already include all existing mentioned users. + if (hint) { + const handles = {} as Record; + for (const id of userIds) { + handles[id] = hint[id]; + } + return handles; + } + + const users = await this.usersRepository.find({ + select: { + id: true, + username: true, + host: true, + }, + where: { + id: In(userIds), + }, + }); + + return users.reduce((map, user) => { + map[user.id] = user.host + ? `@${user.username}@${user.host}` + : `@${user.username}`; + return map; + }, {} as Record); + } } diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index 16e240ab11..b19c8f7c06 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -85,6 +85,16 @@ export const packedNoteSchema = { format: 'id', }, }, + mentionHandles: { + type: 'object', + optional: true, nullable: false, + additionalProperties: { + anyOf: [{ + type: 'string', + }], + optional: true, nullable: false, + }, + }, visibleUserIds: { type: 'array', optional: true, nullable: false, diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 5ac3fb26f5..2b4c39c280 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4668,6 +4668,9 @@ export type components = { /** @enum {string} */ visibility: 'public' | 'home' | 'followers' | 'specified'; mentions?: string[]; + mentionHandles?: { + [key: string]: string; + }; visibleUserIds?: string[]; fileIds?: string[]; files?: components['schemas']['DriveFile'][]; From a4c7f3affdd05bd994223d7d10bd3d45edcbc08e Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 4 May 2025 10:19:48 -0400 Subject: [PATCH 2/8] when replying to a note, auto-fill mentions based on the backend data instead of parsing the OP text --- packages/frontend/src/components/MkPostForm.vue | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 5f4e40d513..59c23b090e 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -316,20 +316,8 @@ if (props.reply && (props.reply.user.username !== $i.username || (props.reply.us text.value = `@${props.reply.user.username}${props.reply.user.host != null ? '@' + toASCII(props.reply.user.host) : ''} `; } -if (props.reply && props.reply.text != null) { - const ast = mfm.parse(props.reply.text); - const otherHost = props.reply.user.host; - - for (const x of extractMentions(ast)) { - const mention = x.host ? - `@${x.username}@${toASCII(x.host)}` : - (otherHost == null || otherHost === host) ? - `@${x.username}` : - `@${x.username}@${toASCII(otherHost)}`; - - // 自分は除外 - if ($i.username === x.username && (x.host == null || x.host === host)) continue; - +if (props.reply && props.reply.mentionHandles) { + for (const mention of Object.values(props.reply.mentionHandles)) { // 重複は除外 if (text.value.includes(`${mention} `)) continue; From df0d8045d5dc289102455315b9930cf82edaf3ad Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 4 May 2025 10:21:16 -0400 Subject: [PATCH 3/8] fix duplicate mentions and spurious "user is not mentioned" warnings when replying to a DM thread including a user with a capitalized username --- packages/frontend/src/components/MkPostForm.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 59c23b090e..78e94d48b0 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -412,7 +412,7 @@ function checkMissingMention() { const ast = mfm.parse(text.value); for (const x of extractMentions(ast)) { - if (!visibleUsers.value.some(u => (u.username === x.username) && (u.host === x.host))) { + if (!visibleUsers.value.some(u => (u.username.toLowerCase() === x.username.toLowerCase()) && (u.host === x.host))) { hasNotSpecifiedMentions.value = true; return; } @@ -425,7 +425,7 @@ function addMissingMention() { const ast = mfm.parse(text.value); for (const x of extractMentions(ast)) { - if (!visibleUsers.value.some(u => (u.username === x.username) && (u.host === x.host))) { + if (!visibleUsers.value.some(u => (u.username.toLowerCase() === x.username.toLowerCase()) && (u.host === x.host))) { misskeyApi('users/show', { username: x.username, host: x.host }).then(user => { pushVisibleUser(user); }); @@ -641,7 +641,7 @@ function showOtherSettings() { //#endregion function pushVisibleUser(user: Misskey.entities.UserDetailed) { - if (!visibleUsers.value.some(u => u.username === user.username && u.host === user.host)) { + if (!visibleUsers.value.some(u => u.username.toLowerCase() === user.username.toLowerCase() && u.host === user.host)) { visibleUsers.value.push(user); } } From d06e1e308004a53f5b842e25433717c5dbc3c8c5 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 4 May 2025 11:17:20 -0400 Subject: [PATCH 4/8] don't insert mentions for the current user --- packages/frontend/src/components/MkPostForm.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 78e94d48b0..052ac7cf6e 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -317,7 +317,10 @@ if (props.reply && (props.reply.user.username !== $i.username || (props.reply.us } if (props.reply && props.reply.mentionHandles) { - for (const mention of Object.values(props.reply.mentionHandles)) { + for (const [user, mention] of Object.entries(props.reply.mentionHandles)) { + // Don't mention ourself + if (user === $i.id) continue; + // 重複は除外 if (text.value.includes(`${mention} `)) continue; From 5e2cc8eb855157f01d67d45e751837aaebffe65c Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 5 May 2025 10:50:58 -0400 Subject: [PATCH 5/8] avoid error when editing notes without any mentions --- packages/backend/src/core/entities/NoteEntityService.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 94fe9e97ef..45d9491e36 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -444,9 +444,6 @@ export class NoteEntityService implements OnModuleInit { const packedFiles = options?._hint_?.packedFiles; const packedUsers = options?._hint_?.packedUsers; - // Do not await - defer until the awaitAll below - const mentionHandles = this.getUserHandles(note.mentions, options?._hint_?.mentionHandles); - const packed: Packed<'Note'> = await awaitAll({ id: note.id, createdAt: this.idService.parse(note.id).date.toISOString(), @@ -481,7 +478,7 @@ export class NoteEntityService implements OnModuleInit { userId: channel.userId, } : undefined, mentions: note.mentions.length > 0 ? note.mentions : undefined, - mentionHandles: note.mentions.length > 0 ? mentionHandles : undefined, + mentionHandles: note.mentions.length > 0 ? this.getUserHandles(note.mentions, options?._hint_?.mentionHandles) : undefined, uri: note.uri ?? undefined, url: note.url ?? undefined, poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, From 58d2c4af6b110458c6912b2ca07d4c2e31f41c64 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 8 May 2025 11:29:42 -0400 Subject: [PATCH 6/8] use targetNotes to reduce duplicate code --- .../src/core/entities/NoteEntityService.ts | 51 ++++++++----------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 45d9491e36..6ada5463a3 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -531,6 +531,25 @@ export class NoteEntityService implements OnModuleInit { ) { if (notes.length === 0) return []; + const targetNotes: MiNote[] = []; + for (const note of notes) { + if (isPureRenote(note)) { + // we may need to fetch 'my reaction' for renote target. + targetNotes.push(note.renote); + if (note.renote.reply) { + // idem if the renote is also a reply. + targetNotes.push(note.renote.reply); + } + } else { + if (note.reply) { + // idem for OP of a regular reply. + targetNotes.push(note.reply); + } + + targetNotes.push(note); + } + } + const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null; const meId = me ? me.id : null; @@ -538,25 +557,6 @@ export class NoteEntityService implements OnModuleInit { if (meId) { const idsNeedFetchMyReaction = new Set(); - const targetNotes: MiNote[] = []; - for (const note of notes) { - if (isPureRenote(note)) { - // we may need to fetch 'my reaction' for renote target. - targetNotes.push(note.renote); - if (note.renote.reply) { - // idem if the renote is also a reply. - targetNotes.push(note.renote.reply); - } - } else { - if (note.reply) { - // idem for OP of a regular reply. - targetNotes.push(note.reply); - } - - targetNotes.push(note); - } - } - for (const note of targetNotes) { const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); if (reactionsCount === 0) { @@ -597,17 +597,10 @@ export class NoteEntityService implements OnModuleInit { .then(users => new Map(users.map(u => [u.id, u]))); // Recursively add all mentioned users from all notes + replies + renotes - const allMentionedUsers = notes.reduce((users, note) => { - function add(n: MiNote) { - for (const user of n.mentions) { - users.add(user); - } - - if (n.reply) add(n.reply); - if (n.renote) add(n.renote); + const allMentionedUsers = targetNotes.reduce((users, note) => { + for (const user of note.mentions) { + users.add(user); } - - add(note); return users; }, new Set()); const mentionHandles = await this.getUserHandles(Array.from(allMentionedUsers)); From 1fe39ed4327b64e8aff4bee7c9ffe507f04d23a7 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 8 May 2025 16:34:40 -0400 Subject: [PATCH 7/8] re-fetch notes after create/edit to ensure they have all fields populated --- packages/backend/src/core/NoteCreateService.ts | 5 +++-- packages/backend/src/core/NoteEditService.ts | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index ed97908f66..a342bc1912 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -227,7 +227,7 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - public async create(user: MiUser & { + public async create(user: MiUser & { id: MiUser['id']; username: MiUser['username']; host: MiUser['host']; @@ -539,7 +539,8 @@ export class NoteCreateService implements OnApplicationShutdown { await this.notesRepository.insert(insert); } - return insert; + // Re-fetch note to get the default values of null / unset fields. + return await this.notesRepository.findOneByOrFail({ id: insert.id }); } catch (e) { // duplicate key error if (isDuplicateKeyValueError(e)) { diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 332560154d..e9637c56c7 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -574,12 +574,15 @@ export class NoteEditService implements OnApplicationShutdown { await this.notesRepository.update(oldnote.id, note); } + // Re-fetch note to get the default values of null / unset fields. + const edited = await this.notesRepository.findOneByOrFail({ id: note.id }); + setImmediate('post edited', { signal: this.#shutdownController.signal }).then( - () => this.postNoteEdited(note, oldnote, user, data, silent, tags!, mentionedUsers!), + () => this.postNoteEdited(edited, oldnote, user, data, silent, tags!, mentionedUsers!), () => { /* aborted, ignore this */ }, ); - return note; + return edited; } else { return oldnote; } From 164c85067fa5664498affadbac48466a1c9ecc6e Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 8 May 2025 22:43:02 -0400 Subject: [PATCH 8/8] remove extra space in NoteCreateService.ts --- packages/backend/src/core/NoteCreateService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index a342bc1912..e961d4236c 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -227,7 +227,7 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - public async create(user: MiUser & { + public async create(user: MiUser & { id: MiUser['id']; username: MiUser['username']; host: MiUser['host'];