diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a0fbdada..45610704df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -141,6 +141,9 @@ ### Server - Enhance: sinceId/untilIdが指定可能なエンドポイントにおいて、sinceDate/untilDateも指定可能に - Enhance: メールの送信者としてサーバー名を表示するように (サーバー名が設定されている場合) +- Enhance: スレッドミュートにおいて、リノート、引用、リアクションの通知もミュートするように + - なお、以下のケースでは引き続き通知がミュートされません。(ミュートを行っているユーザーをAとします) + - ミュート対象ノートを、当該スレッドの外にあるAへの返信/メンション付きノートにおいて引用する - Fix: ジョブキューのProgressの値を正しく計算する diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 1eefcfa054..11b7f4af39 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -608,6 +608,7 @@ export class NoteCreateService implements OnApplicationShutdown { await this.createMentionedEvents(mentionedUsers, note, nm); // If has in reply to note + let isOnThreadMutedTree = false; if (data.reply) { // 通知 if (data.reply.userHost === null) { @@ -617,6 +618,7 @@ export class NoteCreateService implements OnApplicationShutdown { threadId: data.reply.threadId ?? data.reply.id, }, }); + isOnThreadMutedTree = isThreadMuted; if (!isThreadMuted) { nm.push(data.reply.userId, 'reply'); @@ -632,13 +634,23 @@ export class NoteCreateService implements OnApplicationShutdown { // Notify if (data.renote.userHost === null) { - nm.push(data.renote.userId, type); - } + const isThreadMuted = await this.noteThreadMutingsRepository.exists({ + where: { + userId: data.renote.userId, + threadId: data.renote.threadId ?? data.renote.id, + }, + }); - // Publish event - if ((user.id !== data.renote.userId) && data.renote.userHost === null) { - this.globalEventService.publishMainStream(data.renote.userId, 'renote', noteObj); - this.webhookService.enqueueUserWebhook(data.renote.userId, 'renote', { note: noteObj }); + // If the quoted note is not thread muted but the quoting note is on thread muted tree, need to mute it. + if (!isThreadMuted && !isOnThreadMutedTree) { + nm.push(data.renote.userId, type); + + // Publish event + if (user.id !== data.renote.userId) { + this.globalEventService.publishMainStream(data.renote.userId, 'renote', noteObj); + this.webhookService.enqueueUserWebhook(data.renote.userId, 'renote', { note: noteObj }); + } + } } } diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 6f9fe53937..17e08722be 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository, MiMeta } from '@/models/_.js'; +import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository, MiMeta, NoteThreadMutingsRepository } from '@/models/_.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { MiRemoteUser, MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; @@ -82,6 +82,9 @@ export class ReactionService { @Inject(DI.noteReactionsRepository) private noteReactionsRepository: NoteReactionsRepository, + @Inject(DI.noteThreadMutingsRepository) + private noteThreadMutingsRepository: NoteThreadMutingsRepository, + @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, @@ -256,10 +259,19 @@ export class ReactionService { // リアクションされたユーザーがローカルユーザーなら通知を作成 if (note.userHost === null) { - this.notificationService.createNotification(note.userId, 'reaction', { - noteId: note.id, - reaction: reaction, - }, user.id); + const isThreadMuted = await this.noteThreadMutingsRepository.exists({ + where: { + userId: note.userId, + threadId: note.threadId ?? note.id, + }, + }); + + if (!isThreadMuted) { + this.notificationService.createNotification(note.userId, 'reaction', { + noteId: note.id, + reaction: reaction, + }, user.id); + } } //#region 配信 diff --git a/packages/backend/test/e2e/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts index 1edc178fc2..b8be7dc144 100644 --- a/packages/backend/test/e2e/thread-mute.ts +++ b/packages/backend/test/e2e/thread-mute.ts @@ -6,9 +6,14 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { api, connectStream, post, signup } from '../utils.js'; +import { setTimeout } from 'node:timers/promises'; +import { api, connectStream, post, signup, react } from '../utils.js'; import type * as misskey from 'misskey-js'; +function waitForPushToNotification() { + return setTimeout(500); +} + describe('Note thread mute', () => { let alice: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse; @@ -29,6 +34,8 @@ describe('Note thread mute', () => { const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' }); + await waitForPushToNotification(); + const res = await api('notes/mentions', {}, alice); assert.strictEqual(res.status, 200); @@ -38,7 +45,7 @@ describe('Note thread mute', () => { assert.strictEqual(res.body.some(note => note.id === carolReplyWithoutMention.id), false); }); - test('i/notifications にミュートしているスレッドの通知が含まれない', async () => { + test('i/notifications にミュートしているスレッドの通知(メンション, リプライ, リノート, 引用リノート, リアクション)が含まれない', async () => { const bobNote = await post(bob, { text: '@alice @carol root note' }); const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); @@ -46,6 +53,11 @@ describe('Note thread mute', () => { const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' }); + const carolRenote = await post(carol, { renoteId: aliceReply.id }); + const carolQuote = await post(carol, { renoteId: aliceReply.id, text: 'quote note' }); + await react(carol, aliceReply, 'like'); // react method returns nothing. + + await waitForPushToNotification(); const res = await api('i/notifications', {}, alice); @@ -53,7 +65,48 @@ describe('Note thread mute', () => { assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(res.body.some(notification => 'note' in notification && notification.note.id === carolReply.id), false); assert.strictEqual(res.body.some(notification => 'note' in notification && notification.note.id === carolReplyWithoutMention.id), false); + assert.strictEqual(res.body.some(notification => 'note' in notification && notification.note.id === carolRenote.id), false); + assert.strictEqual(res.body.some(notification => 'note' in notification && notification.note.id === carolQuote.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); // NOTE: bobの投稿はスレッドミュート前に行われたため通知に含まれていてもよい }); + + test('ミュートしているスレッドへのリプライで、ミュートしていないスレッドのノートが引用されても i/notifications に通知が含まれない', async () => { + const bobNote = await post(bob, { text: '@alice @carol root note' }); + const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); + const aliceNote = await post(alice, { text: 'another root note' }); + + await api('notes/thread-muting/create', { noteId: bobNote.id }, alice); + + const carolReplyWithQuotingAnother = await post(carol, { replyId: aliceReply.id, renoteId: aliceNote.id, text: 'child note with quoting another note' }); + + await waitForPushToNotification(); + + const res = await api('i/notifications', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some(notification => 'note' in notification && notification.note.id === carolReplyWithQuotingAnother.id), false); + }); + + test('ミュートしていないスレッドでのメンション付きノートまたはリプライで、ミュートしているスレッドのノートが引用された場合は i/notifications に通知が含まれる', async () => { + const bobNote = await post(bob, { text: '@alice @carol root note' }); + const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); + const aliceNote = await post(alice, { text: 'another root note' }); + + await api('notes/thread-muting/create', { noteId: bobNote.id }, alice); + + const carolMentionWithQuotingMuted = await post(carol, { renoteId: aliceReply.id, text: '@alice another root note with quoting muted note' }); + const carolReplyWithQuotingMuted = await post(carol, { replyId: aliceNote.id, renoteId: aliceReply.id, text: 'another child note with quoting muted note' }); + + await waitForPushToNotification(); + + const res = await api('i/notifications', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some(notification => 'note' in notification && notification.note.id === carolMentionWithQuotingMuted.id), true); + assert.strictEqual(res.body.some(notification => 'note' in notification && notification.note.id === carolReplyWithQuotingMuted.id), true); + }); });