diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 9711b68fe..c4187244c 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -804,6 +804,7 @@ makeReactionsPublicDescription: "Jeder wird die Liste deiner gesendeten Reaktion classic: "Classic" muteThread: "Thread stummschalten" unmuteThread: "Threadstummschaltung aufheben" +threadMuteNotificationsDesc: "Wähle die Benachrichtigungen, die du aus diesem Thread erhalten möchtest. Globale Benachrichtigungs-Einstellungen werden zusätzlich angewandt. Das Deaktivieren einer Benachrichtigung hat Vorrang." ffVisibility: "Sichtbarkeit von Gefolgten/Followern" ffVisibilityDescription: "Konfiguriere wer sehen kann, wem du folgst sowie wer dir folgt." continueThread: "Weiteren Threadverlauf anzeigen" diff --git a/locales/en-US.yml b/locales/en-US.yml index ff7b4b0c2..6fd711b32 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -804,6 +804,7 @@ makeReactionsPublicDescription: "This will make the list of all your past reacti classic: "Classic" muteThread: "Mute thread" unmuteThread: "Unmute thread" +threadMuteNotificationsDesc: "Select the notifications you wish to view from this thread. Global notification settings also apply. Disabling takes precedence." ffVisibility: "Follows/Followers Visibility" ffVisibilityDescription: "Allows you to configure who can see who you follow and who follows you." continueThread: "View thread continuation" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index f8ef41ea0..a5b1b3ed2 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -805,6 +805,7 @@ makeReactionsPublicDescription: "あなたがしたリアクション一覧を classic: "クラシック" muteThread: "スレッドをミュート" unmuteThread: "スレッドのミュートを解除" +threadMuteNotificationsDesc: "このスレッドから表示する通知を選択します。グローバル通知設定も適用され、禁止が優先されます。" ffVisibility: "つながりの公開範囲" ffVisibilityDescription: "自分のフォロー/フォロワー情報の公開範囲を設定できます。" continueThread: "さらにスレッドを見る" diff --git a/packages/backend/migration/1655793461890-thread-mute-notifications.js b/packages/backend/migration/1655793461890-thread-mute-notifications.js new file mode 100644 index 000000000..89f340d5f --- /dev/null +++ b/packages/backend/migration/1655793461890-thread-mute-notifications.js @@ -0,0 +1,13 @@ +export class threadMuteNotifications1655793461890 { + name = 'threadMuteNotifications1655793461890' + + async up(queryRunner) { + await queryRunner.query(`CREATE TYPE "public"."note_thread_muting_mutingnotificationtypes_enum" AS ENUM('mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded')`); + await queryRunner.query(`ALTER TABLE "note_thread_muting" ADD "mutingNotificationTypes" "public"."note_thread_muting_mutingnotificationtypes_enum" array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note_thread_muting" DROP COLUMN "mutingNotificationTypes"`); + await queryRunner.query(`DROP TYPE "public"."note_thread_muting_mutingnotificationtypes_enum"`); + } +} diff --git a/packages/backend/src/models/entities/note-thread-muting.ts b/packages/backend/src/models/entities/note-thread-muting.ts index 0f2a29171..f604054b2 100644 --- a/packages/backend/src/models/entities/note-thread-muting.ts +++ b/packages/backend/src/models/entities/note-thread-muting.ts @@ -1,4 +1,5 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { noteNotificationTypes } from 'foundkey-js'; import { id } from '../id.js'; import { User } from './user.js'; import { Note } from './note.js'; @@ -30,4 +31,11 @@ export class NoteThreadMuting { length: 256, }) public threadId: string; + + @Column('enum', { + enum: noteNotificationTypes, + array: true, + default: [], + }) + public mutingNotificationTypes: typeof notificationTypes[number][]; } diff --git a/packages/backend/src/models/repositories/notification.ts b/packages/backend/src/models/repositories/notification.ts index df783351e..c62a24959 100644 --- a/packages/backend/src/models/repositories/notification.ts +++ b/packages/backend/src/models/repositories/notification.ts @@ -1,4 +1,5 @@ import { In } from 'typeorm'; +import { noteNotificationTypes } from 'foundkey-js'; import { db } from '@/db/postgre.js'; import { aggregateNoteEmojis, prefetchEmojis } from '@/misc/populate-emojis.js'; import { Packed } from '@/misc/schema.js'; @@ -28,50 +29,18 @@ export const NotificationRepository = db.getRepository(Notification).extend({ isRead: notification.isRead, userId: notification.notifierId, user: notification.notifierId ? Users.pack(notification.notifier || notification.notifierId) : null, - ...(notification.type === 'mention' ? { - note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'reply' ? { - note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'renote' ? { - note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'quote' ? { + ...(noteNotificationTypes.includes(notification.type) ? { note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { detail: true, _hint_: options._hintForEachNotes_, }), } : {}), ...(notification.type === 'reaction' ? { - note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), reaction: notification.reaction, } : {}), ...(notification.type === 'pollVote' ? { - note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), choice: notification.choice, } : {}), - ...(notification.type === 'pollEnded' ? { - note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), ...(notification.type === 'groupInvited' ? { invitation: UserGroupInvitations.pack(notification.userGroupInvitationId!), } : {}), diff --git a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts index 6dd5ddf9e..a6b86a7eb 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts @@ -1,11 +1,11 @@ -import { Not } from 'typeorm'; +import { ArrayOverlap, Not } from 'typeorm'; import { publishNoteStream } from '@/services/stream.js'; import { createNotification } from '@/services/create-notification.js'; import { deliver } from '@/queue/index.js'; import { renderActivity } from '@/remote/activitypub/renderer/index.js'; import renderVote from '@/remote/activitypub/renderer/vote.js'; import { deliverQuestionUpdate } from '@/services/note/polls/update.js'; -import { PollVotes, NoteWatchings, Users, Polls, Blockings } from '@/models/index.js'; +import { PollVotes, NoteWatchings, Users, Polls, Blockings, NoteThreadMutings } from '@/models/index.js'; import { IRemoteUser } from '@/models/entities/user.js'; import { genId } from '@/misc/gen-id.js'; import { getNote } from '../../../common/getters.js'; @@ -136,14 +136,24 @@ export default define(meta, paramDef, async (ps, user) => { userId: user.id, }); - // Notify - createNotification(note.userId, 'pollVote', { - notifierId: user.id, - noteId: note.id, - choice: ps.choice, + // check if this thread and notification type is muted + const threadMuted = await NoteThreadMutings.findOne({ + userId: note.userId, + threadId: note.threadId || note.id, + mutingNotificationTypes: ArrayOverlap(['pollVote']), }); + // Notify + if (!threadMuted) { + createNotification(note.userId, 'pollVote', { + notifierId: user.id, + noteId: note.id, + choice: ps.choice, + }); + } // Fetch watchers + // checking for mutes is not necessary here, as note watchings will be + // deleted when a thread is muted NoteWatchings.findBy({ noteId: note.id, userId: Not(user.id), diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts index 4154b5dc5..f4bd4186d 100644 --- a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts @@ -1,4 +1,5 @@ -import { Notes, NoteThreadMutings } from '@/models/index.js'; +import { noteNotificationTypes } from 'foundkey-js'; +import { Notes, NoteThreadMutings, NoteWatchings } from '@/models/index.js'; import { genId } from '@/misc/gen-id.js'; import readNote from '@/services/note/read.js'; import define from '../../../define.js'; @@ -25,6 +26,14 @@ export const paramDef = { type: 'object', properties: { noteId: { type: 'string', format: 'misskey:id' }, + mutingNotificationTypes: { + description: 'Defines which notification types from the thread should be muted. Replies are always muted. Applies in addition to the global settings, muting takes precedence.', + type: 'array', + items: { + type: 'string', enum: noteNotificationTypes, + }, + uniqueItems: true, + }, }, required: ['noteId'], } as const; @@ -51,5 +60,19 @@ export default define(meta, paramDef, async (ps, user) => { createdAt: new Date(), threadId: note.threadId || note.id, userId: user.id, + mutingNotificationTypes: ps.mutingNotificationTypes, }); + + // remove all note watchings in the muted thread + const notesThread = Notes.createQueryBuilder("notes") + .select("note.id") + .where({ + threadId: note.threadId ?? note.id, + }); + + await NoteWatchings.createQueryBuilder() + .delete() + .where(`"note_watching"."noteId" IN (${ notesThread.getQuery() })`) + .setParameters(notesThread.getParameters()) + .execute(); }); diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts index 9761de0e5..1b987b5cc 100644 --- a/packages/backend/src/services/note/create.ts +++ b/packages/backend/src/services/note/create.ts @@ -1,4 +1,4 @@ -import { Not, In } from 'typeorm'; +import { ArrayOverlap, Not, In } from 'typeorm'; import * as mfm from 'mfm-js'; import { db } from '@/db/postgre.js'; import es from '@/db/elasticsearch.js'; @@ -80,15 +80,19 @@ class NotificationManager { public async deliver() { for (const x of this.queue) { - // ミュート情報を取得 - const mentioneeMutes = await Mutings.findBy({ + // check if the sender or thread are muted + const userMuted = await Mutings.findOneBy({ muterId: x.target, + muteeId: this.notifier.id, }); - const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId); + const threadMuted = await NoteThreadMutings.findOneBy({ + userId: x.target, + threadId: this.note.threadId || this.note.id, + mutingNotificationTypes: ArrayOverlap([x.reason]), + }); - // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する - if (!mentioneesMutedUserIds.includes(this.notifier.id)) { + if (!userMuted && !threadMuted) { createNotification(x.target, x.reason, { notifierId: this.notifier.id, noteId: this.note.id, diff --git a/packages/backend/src/services/note/polls/vote.ts b/packages/backend/src/services/note/polls/vote.ts index 2c902f3ed..057e29005 100644 --- a/packages/backend/src/services/note/polls/vote.ts +++ b/packages/backend/src/services/note/polls/vote.ts @@ -1,8 +1,8 @@ -import { Not } from 'typeorm'; +import { ArrayOverlap, Not } from 'typeorm'; import { publishNoteStream } from '@/services/stream.js'; import { CacheableUser } from '@/models/entities/user.js'; import { Note } from '@/models/entities/note.js'; -import { PollVotes, NoteWatchings, Polls, Blockings } from '@/models/index.js'; +import { PollVotes, NoteWatchings, Polls, Blockings, NoteThreadMutings } from '@/models/index.js'; import { genId } from '@/misc/gen-id.js'; import { createNotification } from '../../create-notification.js'; @@ -57,12 +57,20 @@ export default async function(user: CacheableUser, note: Note, choice: number) { userId: user.id, }); - // Notify - createNotification(note.userId, 'pollVote', { - notifierId: user.id, - noteId: note.id, - choice, + // check if this thread and notification type is muted + const muted = await NoteThreadMutings.findOne({ + userId: note.userId, + threadId: note.threadId || note.id, + mutingNotificationTypes: ArrayOverlap(['pollVote']), }); + // Notify + if (!muted) { + createNotification(note.userId, 'pollVote', { + notifierId: user.id, + noteId: note.id, + choice: choice, + }); + } // Fetch watchers NoteWatchings.findBy({ diff --git a/packages/backend/src/services/note/reaction/create.ts b/packages/backend/src/services/note/reaction/create.ts index 0fa84d346..9d3882a0c 100644 --- a/packages/backend/src/services/note/reaction/create.ts +++ b/packages/backend/src/services/note/reaction/create.ts @@ -1,4 +1,4 @@ -import { IsNull, Not } from 'typeorm'; +import { ArrayOverlap, IsNull, Not } from 'typeorm'; import { publishNoteStream } from '@/services/stream.js'; import { renderLike } from '@/remote/activitypub/renderer/like.js'; import DeliverManager from '@/remote/activitypub/deliver-manager.js'; @@ -6,7 +6,7 @@ import { renderActivity } from '@/remote/activitypub/renderer/index.js'; import { toDbReaction, decodeReaction } from '@/misc/reaction-lib.js'; import { User, IRemoteUser } from '@/models/entities/user.js'; import { Note } from '@/models/entities/note.js'; -import { NoteReactions, Users, NoteWatchings, Notes, Emojis, Blockings } from '@/models/index.js'; +import { NoteReactions, Users, NoteWatchings, Notes, Emojis, Blockings, NoteThreadMutings } from '@/models/index.js'; import { perUserReactionsChart } from '@/services/chart/index.js'; import { genId } from '@/misc/gen-id.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; @@ -98,8 +98,14 @@ export default async (user: { id: User['id']; host: User['host']; }, note: Note, userId: user.id, }); + // check if this thread is muted + const threadMuted = await NoteThreadMutings.findOne({ + userId: note.userId, + threadId: note.threadId || note.id, + mutingNotificationTypes: ArrayOverlap(['reaction']), + }); // リアクションされたユーザーがローカルユーザーなら通知を作成 - if (note.userHost === null) { + if (note.userHost === null && !threadMuted) { createNotification(note.userId, 'reaction', { notifierId: user.id, noteId: note.id, diff --git a/packages/client/src/components/notification-setting-window.vue b/packages/client/src/components/notification-setting-window.vue index c7fdc56ab..4edbda033 100644 --- a/packages/client/src/components/notification-setting-window.vue +++ b/packages/client/src/components/notification-setting-window.vue @@ -18,7 +18,7 @@