From 35fd970c4a6b40e86cb084849ab9fa47ccf89ad2 Mon Sep 17 00:00:00 2001 From: Johann150 Date: Mon, 20 Jun 2022 21:02:11 +0200 Subject: [PATCH 1/7] add column: muted types in thread --- .../1655793461890-thread-mute-notifications.js | 13 +++++++++++++ .../src/models/entities/note-thread-muting.ts | 8 ++++++++ packages/foundkey-js/src/consts.ts | 2 ++ packages/foundkey-js/src/index.ts | 1 + 4 files changed, 24 insertions(+) create mode 100644 packages/backend/migration/1655793461890-thread-mute-notifications.js 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/foundkey-js/src/consts.ts b/packages/foundkey-js/src/consts.ts index df73e829d..645bdf223 100644 --- a/packages/foundkey-js/src/consts.ts +++ b/packages/foundkey-js/src/consts.ts @@ -1,5 +1,7 @@ export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const; +export const noteNotificationTypes = ['mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded'] as const; + export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; export const ffVisibility = ['public', 'followers', 'private'] as const; diff --git a/packages/foundkey-js/src/index.ts b/packages/foundkey-js/src/index.ts index 61d34310b..5acac2748 100644 --- a/packages/foundkey-js/src/index.ts +++ b/packages/foundkey-js/src/index.ts @@ -10,6 +10,7 @@ export { export { permissions, notificationTypes, + noteNotificationTypes, mutedNoteReasons, ffVisibility, } from './consts.js'; From 58aa7d36aa6dab57b1b41b090e1d80621ad7826d Mon Sep 17 00:00:00 2001 From: Johann150 Date: Tue, 21 Jun 2022 09:52:49 +0200 Subject: [PATCH 2/7] refactor: use noteNotificationTypes --- .../src/models/repositories/notification.ts | 35 ++----------------- 1 file changed, 2 insertions(+), 33 deletions(-) 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!), } : {}), From 321bd24b98c73ededc9713001a64db0dc55c4e68 Mon Sep 17 00:00:00 2001 From: Johann150 Date: Mon, 20 Jun 2022 21:03:26 +0200 Subject: [PATCH 3/7] api: handle muting notification types --- .../server/api/endpoints/notes/thread-muting/create.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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..3181041a8 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,3 +1,4 @@ +import { noteNotificationTypes } from 'foundkey-js'; import { Notes, NoteThreadMutings } from '@/models/index.js'; import { genId } from '@/misc/gen-id.js'; import readNote from '@/services/note/read.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,6 @@ export default define(meta, paramDef, async (ps, user) => { createdAt: new Date(), threadId: note.threadId || note.id, userId: user.id, + mutingNotificationTypes: ps.mutingNotificationTypes, }); }); From ab84457c0ed448584ef1de3d0d655b5517017b58 Mon Sep 17 00:00:00 2001 From: Johann150 Date: Mon, 20 Jun 2022 22:05:45 +0200 Subject: [PATCH 4/7] client: use new API --- .../notification-setting-window.vue | 16 +++++---- packages/client/src/scripts/get-note-menu.ts | 34 ++++++++++++++++--- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/client/src/components/notification-setting-window.vue b/packages/client/src/components/notification-setting-window.vue index c7fdc56ab..7bcf2768f 100644 --- a/packages/client/src/components/notification-setting-window.vue +++ b/packages/client/src/components/notification-setting-window.vue @@ -28,7 +28,7 @@ diff --git a/packages/client/src/scripts/get-note-menu.ts b/packages/client/src/scripts/get-note-menu.ts index a46cdb038..465fe1a40 100644 --- a/packages/client/src/scripts/get-note-menu.ts +++ b/packages/client/src/scripts/get-note-menu.ts @@ -65,9 +65,33 @@ export function getNoteMenu(props: { }); } - function toggleThreadMute(mute: boolean): void { - os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { - noteId: appearNote.id, + function muteThread(): void { + // show global settings by default + const includingTypes = foundkey.notificationTypes.filter(x => !$i.mutingNotificationTypes.includes(x)); + os.popup(defineAsyncComponent(() => import('@/components/notification-setting-window.vue')), { + includingTypes, + showGlobalToggle: false, + message: i18n.ts.threadMuteNotificationsDesc, + notificationTypes: foundkey.noteNotificationTypes, + }, { + done: async (res) => { + const { includingTypes: value } = res; + let mutingNotificationTypes: string[] | undefined; + if (value != null) { + mutingNotificationTypes = foundkey.noteNotificationTypes.filter(x => !value.includes(x)) + } + + await os.apiWithDialog('notes/thread-muting/create', { + noteId: appearNote.id, + mutingNotificationTypes, + }); + } + }, 'closed'); + } + + function unmuteThread(): void { + os.apiWithDialog('notes/thread-muting/delete', { + noteId: appearNote.id }); } @@ -251,11 +275,11 @@ export function getNoteMenu(props: { statePromise.then(state => state.isMutedThread ? { icon: 'fas fa-comment-slash', text: i18n.ts.unmuteThread, - action: () => toggleThreadMute(false), + action: () => unmuteThread(), } : { icon: 'fas fa-comment-slash', text: i18n.ts.muteThread, - action: () => toggleThreadMute(true), + action: () => muteThread(), }), appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? { icon: 'fas fa-thumbtack', From 87411a6ed831cdc00cce9bcbc99c770e5bd7b950 Mon Sep 17 00:00:00 2001 From: Johann150 Date: Mon, 20 Jun 2022 22:37:17 +0200 Subject: [PATCH 5/7] enhance: more descriptive info message --- locales/ja-JP.yml | 1 + .../api/endpoints/notes/thread-muting/create.ts | 15 ++++++++++++++- .../components/notification-setting-window.vue | 4 +++- 3 files changed, 18 insertions(+), 2 deletions(-) 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/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts index 3181041a8..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,5 +1,5 @@ import { noteNotificationTypes } from 'foundkey-js'; -import { Notes, NoteThreadMutings } from '@/models/index.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'; @@ -62,4 +62,17 @@ export default define(meta, paramDef, async (ps, user) => { 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/client/src/components/notification-setting-window.vue b/packages/client/src/components/notification-setting-window.vue index 7bcf2768f..4edbda033 100644 --- a/packages/client/src/components/notification-setting-window.vue +++ b/packages/client/src/components/notification-setting-window.vue @@ -18,7 +18,7 @@
- {{ i18n.ts.notificationSettingDesc }} + {{ message }} {{ i18n.ts.disableAll }} {{ i18n.ts.enableAll }} {{ i18n.t(`_notification._types.${ntype}`) }} @@ -44,10 +44,12 @@ const props = withDefaults(defineProps<{ includingTypes?: typeof foundkey.notificationTypes[number][] | null; notificationTypes?: typeof foundkey.notificationTypes[number][] | null; showGlobalToggle?: boolean; + message?: string, }>(), { includingTypes: () => [], notificationTypes: () => [], showGlobalToggle: true, + message: i18n.ts.notificationSettingDesc, }); let includingTypes = $computed(() => props.includingTypes || []); From cc5a1977857e05797786bd2d78aa87606394e184 Mon Sep 17 00:00:00 2001 From: Johann150 Date: Thu, 30 Dec 2021 21:22:48 +0100 Subject: [PATCH 6/7] do not create muted notification types in respective threads --- .../server/api/endpoints/notes/polls/vote.ts | 24 +++++++++++++------ packages/backend/src/services/note/create.ts | 16 ++++++++----- .../backend/src/services/note/polls/vote.ts | 22 +++++++++++------ .../src/services/note/reaction/create.ts | 12 +++++++--- 4 files changed, 51 insertions(+), 23 deletions(-) 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/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, From 48fda127ca320e0f2021a5fd3c6914accab5ad28 Mon Sep 17 00:00:00 2001 From: Johann150 Date: Fri, 9 Sep 2022 00:19:20 +0200 Subject: [PATCH 7/7] add more locale strings --- locales/de-DE.yml | 1 + locales/en-US.yml | 1 + 2 files changed, 2 insertions(+) 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"