From 27db3b99d23aafae9fca08d1ed653d8dd467fdcd Mon Sep 17 00:00:00 2001 From: romaboo <80708836+puff-fan-420@users.noreply.github.com> Date: Thu, 9 Dec 2021 12:38:56 +0000 Subject: [PATCH] feat: user-level instance mute (#7712) * Update ja-JP.yml * Added settable config for muted instances * added psql query for removal of muted notes * Added filtering and trimming for instance mutes * cleaned up filtering of bad instance mutes and added a refresh at the end for the list on the client * Added notification & streaming timeline muting * Updated changelog * Added missing semicolon * Apply japanese string suggestions from robflop Co-authored-by: Robin B. * Changed Ja-JP instance mute title string to one suggested by sousuke Co-authored-by: sousuke0422 * Update ja-JP instanceMuteDescription based on sousuke's suggestion Co-authored-by: sousuke0422 * added notification mute * added notification and note children muting * Fixed a bug where local notifications were getting filtered on cold start * Fixed instance mute imports * Fixed not saving/loading instance mutes * removed en-US translations for instance mute * moved instance mute migration to js * changed settings index back to spaces * removed destructuring assignment from notification stream in instance mute check call Co-authored-by: tamaina * added .note accessor for checking note data instead of notification data * changed note to use Packed<'Note'> instead of any and removed usage of snake case Co-authored-by: tamaina * changed notification mute check to check specifically for notification host * changed to using single quotes * moved @click to the end for the linter * revert unnecessary changes * restored newlines * whitespace removal Co-authored-by: syuilo Co-authored-by: Robin B. Co-authored-by: sousuke0422 Co-authored-by: puffaboo Co-authored-by: tamaina --- CHANGELOG.md | 3 + locales/ja-JP.yml | 7 ++ .../1629968054000_userInstanceBlocks.js | 15 ++++ .../backend/src/misc/is-instance-muted.ts | 15 ++++ .../src/models/entities/user-profile.ts | 5 ++ .../backend/src/models/repositories/user.ts | 5 ++ .../common/generate-muted-instance-query.ts | 40 +++++++++ .../server/api/endpoints/i/notifications.ts | 3 + .../src/server/api/endpoints/i/update.ts | 5 ++ .../server/api/endpoints/notes/children.ts | 2 + .../api/endpoints/notes/global-timeline.ts | 2 + .../api/endpoints/notes/hybrid-timeline.ts | 2 + .../server/api/endpoints/notes/timeline.ts | 2 + .../src/server/api/endpoints/users/notes.ts | 2 + .../api/stream/channels/global-timeline.ts | 4 + .../api/stream/channels/home-timeline.ts | 4 + .../api/stream/channels/hybrid-timeline.ts | 4 + .../src/server/api/stream/channels/main.ts | 5 ++ packages/client/src/pages/settings/index.vue | 6 ++ .../src/pages/settings/instance-mute.vue | 83 +++++++++++++++++++ 20 files changed, 214 insertions(+) create mode 100644 packages/backend/migration/1629968054000_userInstanceBlocks.js create mode 100644 packages/backend/src/misc/is-instance-muted.ts create mode 100644 packages/backend/src/server/api/common/generate-muted-instance-query.ts create mode 100644 packages/client/src/pages/settings/instance-mute.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index a46f9c7c8..12a73a4c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -234,6 +234,9 @@ ## 12.89.1 (2021/08/24) +### Features +- Added a user-level instance mute in user settings + ### Improvements - クライアントのデザインの調整 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index d5c009bbc..2a8d0bd9e 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -592,6 +592,7 @@ smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" smtpSecureInfo: "STARTTLS使用時はオフにします。" testEmail: "配信テスト" wordMute: "ワードミュート" +instanceMute: "インスタンスミュート" userSaysSomething: "{name}が何かを言いました" makeActive: "アクティブにする" display: "表示" @@ -1021,6 +1022,12 @@ _wordMute: hard: "ハード" mutedNotes: "ミュートされたノート" +_instanceMute: + instanceMuteDescription: "ミュートしたインスタンスのユーザーからの返信を含めて、設定したインスタンスの全てのノートとRenoteをミュートします。" + instanceMuteDescription2: "改行で区切って設定します" + title: "設定したインスタンスのノートを隠します。" + heading: "ミュートするインスタンス" + _theme: explore: "テーマを探す" install: "テーマのインストール" diff --git a/packages/backend/migration/1629968054000_userInstanceBlocks.js b/packages/backend/migration/1629968054000_userInstanceBlocks.js new file mode 100644 index 000000000..5703ff0b0 --- /dev/null +++ b/packages/backend/migration/1629968054000_userInstanceBlocks.js @@ -0,0 +1,15 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class userInstanceBlocks1629968054000 { + constructor() { + this.name = 'userInstanceBlocks1629968054000'; + } + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "mutedInstances" jsonb NOT NULL DEFAULT '[]'`); + await queryRunner.query(`COMMENT ON COLUMN "user_profile"."mutedInstances" IS 'List of instances muted by the user.'`); + } + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "mutedInstances"`); + } +} +exports.userInstanceBlocks1629968054000 = userInstanceBlocks1629968054000; diff --git a/packages/backend/src/misc/is-instance-muted.ts b/packages/backend/src/misc/is-instance-muted.ts new file mode 100644 index 000000000..2e1785b51 --- /dev/null +++ b/packages/backend/src/misc/is-instance-muted.ts @@ -0,0 +1,15 @@ +import { Packed } from "./schema"; + +export function isInstanceMuted(note: Packed<'Note'>, mutedInstances: Set): boolean { + if (mutedInstances.has(note?.user?.host ?? '')) return true; + if (mutedInstances.has(note?.reply?.user?.host ?? '')) return true; + if (mutedInstances.has(note?.renote?.user?.host ?? '')) return true; + + return false; +} + +export function isUserFromMutedInstance(notif: Packed<'Notification'>, mutedInstances: Set): boolean { + if (mutedInstances.has(notif?.user?.host ?? '')) return true; + + return false; +} diff --git a/packages/backend/src/models/entities/user-profile.ts b/packages/backend/src/models/entities/user-profile.ts index 8a8cacfd5..60e16820f 100644 --- a/packages/backend/src/models/entities/user-profile.ts +++ b/packages/backend/src/models/entities/user-profile.ts @@ -189,6 +189,11 @@ export class UserProfile { }) public mutedWords: string[][]; + @Column('jsonb', { + default: [] + }) + public mutedInstances: string[]; + @Column('enum', { enum: notificationTypes, array: true, diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts index 81468d6de..2f6c150d3 100644 --- a/packages/backend/src/models/repositories/user.ts +++ b/packages/backend/src/models/repositories/user.ts @@ -288,6 +288,7 @@ export class UserRepository extends Repository { hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), integrations: profile!.integrations, mutedWords: profile!.mutedWords, + mutedInstances: profile!.mutedInstances, mutingNotificationTypes: profile!.mutingNotificationTypes, emailNotificationTypes: profile!.emailNotificationTypes, } : {}), @@ -623,6 +624,10 @@ export const packedUserSchema = { type: 'array' as const, nullable: false as const, optional: true as const }, + mutedInstances: { + type: 'array' as const, + nullable: false as const, optional: true as const + }, mutingNotificationTypes: { type: 'array' as const, nullable: false as const, optional: true as const diff --git a/packages/backend/src/server/api/common/generate-muted-instance-query.ts b/packages/backend/src/server/api/common/generate-muted-instance-query.ts new file mode 100644 index 000000000..dbc9fc98f --- /dev/null +++ b/packages/backend/src/server/api/common/generate-muted-instance-query.ts @@ -0,0 +1,40 @@ +import { User } from '@/models/entities/user'; +import { id } from '@/models/id'; +import { UserProfiles } from '@/models/index'; +import { SelectQueryBuilder, Brackets } from 'typeorm'; + +function createMutesQuery(id: string) { + return UserProfiles.createQueryBuilder('user_profile') + .select('user_profile.mutedInstances') + .where('user_profile.userId = :muterId', { muterId: id }); +} + +export function generateMutedInstanceQuery(q: SelectQueryBuilder, me: { id: User['id'] }) { + const mutingQuery = createMutesQuery(me.id); + + q + .andWhere(new Brackets(qb => { qb + .andWhere('note.userHost IS NULL') + .orWhere(`NOT((${ mutingQuery.getQuery() })::jsonb ? note.userHost)`); + })) + .andWhere(new Brackets(qb => { qb + .where(`note.replyUserHost IS NULL`) + .orWhere(`NOT ((${ mutingQuery.getQuery() })::jsonb ? note.replyUserHost)`); + })) + .andWhere(new Brackets(qb => { qb + .where(`note.renoteUserHost IS NULL`) + .orWhere(`NOT ((${ mutingQuery.getQuery() })::jsonb ? note.renoteUserHost)`); + })); + q.setParameters(mutingQuery.getParameters()); +} + +export function generateMutedInstanceNotificationQuery(q: SelectQueryBuilder, me: { id: User['id'] }) { + const mutingQuery = createMutesQuery(me.id); + + q.andWhere(new Brackets(qb => { qb + .andWhere('notifier.host IS NULL') + .orWhere(`NOT (( ${mutingQuery.getQuery()} )::jsonb ? notifier.host)`); + })); + + q.setParameters(mutingQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index 56668d03b..a85637d8a 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -3,6 +3,7 @@ import { ID } from '@/misc/cafy-id'; import { readNotification } from '../../common/read-notification'; import define from '../../define'; import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateMutedInstanceNotificationQuery } from '../../common/generate-muted-instance-query'; import { Notifications, Followings, Mutings, Users } from '@/models/index'; import { notificationTypes } from '@/types'; import read from '@/services/note/read'; @@ -101,6 +102,8 @@ export default define(meta, async (ps, user) => { })); query.setParameters(mutingQuery.getParameters()); + generateMutedInstanceNotificationQuery(query, user); + query.andWhere(new Brackets(qb => { qb .where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`) .orWhere('notification.notifierId IS NULL'); diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index d0f201ab6..4c5a4da62 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -116,6 +116,10 @@ export const meta = { validator: $.optional.arr($.arr($.str)) }, + mutedInstances: { + validator: $.optional.arr($.str) + }, + mutingNotificationTypes: { validator: $.optional.arr($.str.or(notificationTypes as unknown as string[])) }, @@ -185,6 +189,7 @@ export default define(meta, async (ps, _user, token) => { profileUpdates.mutedWords = ps.mutedWords; profileUpdates.enableWordMute = ps.mutedWords.length > 0; } + if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; if (ps.mutingNotificationTypes !== undefined) profileUpdates.mutingNotificationTypes = ps.mutingNotificationTypes as typeof notificationTypes[number][]; if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable; diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index 68881fda9..49e5a2f84 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -7,6 +7,7 @@ import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; import { Brackets } from 'typeorm'; import { Notes } from '@/models/index'; import { generateBlockedUserQuery } from '../../common/generate-block-query'; +import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query'; export const meta = { tags: ['notes'], @@ -65,6 +66,7 @@ export default define(meta, async (ps, user) => { generateVisibilityQuery(query, user); if (user) generateMutedUserQuery(query, user); if (user) generateBlockedUserQuery(query, user); + if (user) generateMutedInstanceQuery(query, user); const notes = await query.take(ps.limit!).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 5902c0415..7c80153b4 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -6,6 +6,7 @@ import { ApiError } from '../../error'; import { makePaginationQuery } from '../../common/make-pagination-query'; import { Notes } from '@/models/index'; import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query'; import { activeUsersChart } from '@/services/chart/index'; import { generateRepliesQuery } from '../../common/generate-replies-query'; import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; @@ -83,6 +84,7 @@ export default define(meta, async (ps, user) => { if (user) generateMutedUserQuery(query, user); if (user) generateMutedNoteQuery(query, user); if (user) generateBlockedUserQuery(query, user); + if (user) generateMutedInstanceQuery(query, user); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 47f08f208..22babb5d0 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -8,6 +8,7 @@ import { Followings, Notes } from '@/models/index'; import { Brackets } from 'typeorm'; import { generateVisibilityQuery } from '../../common/generate-visibility-query'; import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query'; import { activeUsersChart } from '@/services/chart/index'; import { generateRepliesQuery } from '../../common/generate-replies-query'; import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; @@ -108,6 +109,7 @@ export default define(meta, async (ps, user) => { generateRepliesQuery(query, user); generateVisibilityQuery(query, user); generateMutedUserQuery(query, user); + generateMutedInstanceQuery(query, user); generateMutedNoteQuery(query, user); generateBlockedUserQuery(query, user); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 1bd0e57d3..7a69b1590 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -5,6 +5,7 @@ import { makePaginationQuery } from '../../common/make-pagination-query'; import { Notes, Followings } from '@/models/index'; import { generateVisibilityQuery } from '../../common/generate-visibility-query'; import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query'; import { activeUsersChart } from '@/services/chart/index'; import { Brackets } from 'typeorm'; import { generateRepliesQuery } from '../../common/generate-replies-query'; @@ -100,6 +101,7 @@ export default define(meta, async (ps, user) => { generateRepliesQuery(query, user); generateVisibilityQuery(query, user); generateMutedUserQuery(query, user); + generateMutedInstanceQuery(query, user); generateMutedNoteQuery(query, user); generateBlockedUserQuery(query, user); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 0afbad9d0..e46167253 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -9,6 +9,7 @@ import { Notes } from '@/models/index'; import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; import { Brackets } from 'typeorm'; import { generateBlockedUserQuery } from '../../common/generate-block-query'; +import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query'; export const meta = { tags: ['users', 'notes'], @@ -102,6 +103,7 @@ export default define(meta, async (ps, me) => { generateVisibilityQuery(query, me); if (me) generateMutedUserQuery(query, me, user); if (me) generateBlockedUserQuery(query, me); + if (me) generateMutedInstanceQuery(query, me); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index f5983ab47..3c37b16dd 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -5,6 +5,7 @@ import { fetchMeta } from '@/misc/fetch-meta'; import { Notes } from '@/models/index'; import { checkWordMute } from '@/misc/check-word-mute'; import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { isInstanceMuted } from '@/misc/is-instance-muted'; import { Packed } from '@/misc/schema'; export default class extends Channel { @@ -48,6 +49,9 @@ export default class extends Channel { if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; } + // Ignore notes from instances the user has muted + if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する if (isMutedUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 52e9aec25..24fb3bd40 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -4,6 +4,7 @@ import Channel from '../channel'; import { Notes } from '@/models/index'; import { checkWordMute } from '@/misc/check-word-mute'; import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { isInstanceMuted } from '@/misc/is-instance-muted'; import { Packed } from '@/misc/schema'; export default class extends Channel { @@ -26,6 +27,9 @@ export default class extends Channel { if ((this.user!.id !== note.userId) && !this.following.has(note.userId)) return; } + // Ignore notes from instances the user has muted + if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; + if (['followers', 'specified'].includes(note.visibility)) { note = await Notes.pack(note.id, this.user!, { detail: true diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 51f95fc0c..615cc4540 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -5,6 +5,7 @@ import { fetchMeta } from '@/misc/fetch-meta'; import { Notes } from '@/models/index'; import { checkWordMute } from '@/misc/check-word-mute'; import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { isInstanceMuted } from '@/misc/is-instance-muted'; import { Packed } from '@/misc/schema'; export default class extends Channel { @@ -57,6 +58,9 @@ export default class extends Channel { } } + // Ignore notes from instances the user has muted + if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; + // 関係ない返信は除外 if (note.reply) { const reply = note.reply; diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts index 131ac3047..925263aef 100644 --- a/packages/backend/src/server/api/stream/channels/main.ts +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -1,6 +1,7 @@ import autobind from 'autobind-decorator'; import Channel from '../channel'; import { Notes } from '@/models/index'; +import { isInstanceMuted, isUserFromMutedInstance } from '@/misc/is-instance-muted'; export default class extends Channel { public readonly chName = 'main'; @@ -13,6 +14,8 @@ export default class extends Channel { this.subscriber.on(`mainStream:${this.user!.id}`, async data => { switch (data.type) { case 'notification': { + // Ignore notifications from instances the user has muted + if (isUserFromMutedInstance(data.body, new Set(this.userProfile?.mutedInstances ?? []))) return; if (data.body.userId && this.muting.has(data.body.userId)) return; if (data.body.note && data.body.note.isHidden) { @@ -25,6 +28,8 @@ export default class extends Channel { break; } case 'mention': { + if (isInstanceMuted(data.body, new Set(this.userProfile?.mutedInstances ?? []))) return; + if (this.muting.has(data.body.userId)) return; if (data.body.isHidden) { const note = await Notes.pack(data.body.id, this.user, { diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue index bfac1be77..2e26870d8 100644 --- a/packages/client/src/pages/settings/index.vue +++ b/packages/client/src/pages/settings/index.vue @@ -136,6 +136,11 @@ export default defineComponent({ text: i18n.locale.importAndExport, to: '/settings/import-export', active: page.value === 'import-export', + }, { + icon: 'fas fa-volume-mute', + text: i18n.locale.instanceMute, + to: '/settings/instance-mute', + active: page.value === 'instance-mute', }, { icon: 'fas fa-ban', text: i18n.locale.muteAndBlock, @@ -190,6 +195,7 @@ export default defineComponent({ case 'notifications': return defineAsyncComponent(() => import('./notifications.vue')); case 'mute-block': return defineAsyncComponent(() => import('./mute-block.vue')); case 'word-mute': return defineAsyncComponent(() => import('./word-mute.vue')); + case 'instance-mute': return defineAsyncComponent(() => import('./instance-mute.vue')); case 'integration': return defineAsyncComponent(() => import('./integration.vue')); case 'security': return defineAsyncComponent(() => import('./security.vue')); case '2fa': return defineAsyncComponent(() => import('./2fa.vue')); diff --git a/packages/client/src/pages/settings/instance-mute.vue b/packages/client/src/pages/settings/instance-mute.vue new file mode 100644 index 000000000..813d2a044 --- /dev/null +++ b/packages/client/src/pages/settings/instance-mute.vue @@ -0,0 +1,83 @@ + + +