diff --git a/locales/en-US.yml b/locales/en-US.yml index ef1bef8ebee8..a80c54e2827c 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -217,6 +217,8 @@ blockedInstances: "Blocked Instances" blockedInstancesDescription: "List the hostnames of the instances you want to block separated by linebreaks. Listed instances will no longer be able to communicate with this instance." silencedInstances: "Silenced instances" silencedInstancesDescription: "List the hostnames of the instances that you want to silence. All accounts of the listed instances will be treated as silenced, can only make follow requests, and cannot mention local accounts if not followed. This will not affect blocked instances." +gtlMutedInstances: "GTL-muted instances" +gtlMutedInstancesDescription: "List the hostnames of the instances that you want to mute in the global timeline. All notes related to the accounts of the listed instances will not be shown up in the global timeline. This will not affect displays in other timelines." muteAndBlock: "Mutes and Blocks" mutedUsers: "Muted users" blockedUsers: "Blocked users" @@ -402,6 +404,7 @@ silence: "Silence" silenceConfirm: "Are you sure that you want to silence this user?" unsilence: "Undo silencing" unsilenceConfirm: "Are you sure that you want to undo the silencing of this user?" +gtlMuted: "GTL mutes" popularUsers: "Popular users" recentlyUpdatedUsers: "Recently active users" recentlyRegisteredUsers: "Newly joined users" @@ -1750,6 +1753,11 @@ _instanceMute: instanceMuteDescription2: "Separate with newlines" title: "Hides notes from listed instances." heading: "List of instances to be muted" +_instanceGtlMute: + instanceMuteDescription: "This will mute any notes/renotes from the listed instances in the global timeline. In other timelines, these mutings will not take effect." + instanceMuteDescription2: "Separate with newlines" + title: "Hides notes from listed instances." + heading: "List of instances to be muted in the GTL" _theme: explore: "Explore Themes" install: "Install a theme" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 2e4cc232d8dd..fe42f545f2de 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -217,6 +217,8 @@ blockedInstances: "ブロックしたサーバー" blockedInstancesDescription: "ブロックしたいサーバーのホストを改行で区切って設定します。ブロックされたサーバーは、このインスタンスとやり取りできなくなります。" silencedInstances: "サイレンスしたサーバー" silencedInstancesDescription: "サイレンスしたいサーバーのホストを改行で区切って設定します。サイレンスされたサーバーに所属するアカウントはすべて「サイレンス」として扱われ、フォローがすべてリクエストになり、フォロワーでないローカルアカウントにはメンションできなくなります。ブロックしたインスタンスには影響しません。" +gtlMutedInstances: "GTLミュートしたサーバー" +gtlMutedInstancesDescription: "GTLミュートしたいサーバーのホストを改行で区切って設定します。GTLミュートされたサーバーに所属するアカウントに関連する投稿がグローバルタイムラインに表示されなくなります。" muteAndBlock: "ミュートとブロック" mutedUsers: "ミュートしたユーザー" blockedUsers: "ブロックしたユーザー" @@ -403,6 +405,7 @@ silence: "サイレンス" silenceConfirm: "サイレンスしますか?" unsilence: "サイレンス解除" unsilenceConfirm: "サイレンス解除しますか?" +gtlMuted: "GTLミュート" popularUsers: "人気のユーザー" recentlyUpdatedUsers: "最近投稿したユーザー" recentlyRegisteredUsers: "最近登録したユーザー" @@ -1794,6 +1797,12 @@ _instanceMute: title: "設定したサーバーのノートを隠します。" heading: "ミュートするサーバー" +_instanceGtlMute: + instanceMuteDescription: "ミュートしたサーバーからの投稿をグローバルタイムラインに表示しません。その他のタイムラインでは表示されます。" + instanceMuteDescription2: "改行で区切って設定します" + title: "設定したサーバーのノートをグローバルタイムラインから隠します。" + heading: "GTLミュートするサーバー" + _theme: explore: "テーマを探す" install: "テーマのインストール" diff --git a/packages/backend/migration/1701920984504-UserInstanceGtlMutings.js b/packages/backend/migration/1701920984504-UserInstanceGtlMutings.js new file mode 100644 index 000000000000..90b050803cf0 --- /dev/null +++ b/packages/backend/migration/1701920984504-UserInstanceGtlMutings.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class UserInstanceGtlMutings1701920984504 { + name = 'UserInstanceGtlMutings1701920984504' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "gtlMutedInstances" jsonb NOT NULL DEFAULT '[]'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "gtlMutedInstances"`); + } +} diff --git a/packages/backend/migration/1701920984505-ServerInstanceGtlMutings.js b/packages/backend/migration/1701920984505-ServerInstanceGtlMutings.js new file mode 100644 index 000000000000..49468fa104be --- /dev/null +++ b/packages/backend/migration/1701920984505-ServerInstanceGtlMutings.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ServerInstanceGtlMutings1701920984505 { + name = 'ServerInstanceGtlMutings1701920984505' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "gtlMutedHosts" character varying(1024) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "gtlMutedHosts"`); + } +} diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index f006ed494420..d9d2e942c097 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -127,7 +127,7 @@ export class QueryService { } @bindThis - public generateMutedUserQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void { + public generateMutedUserQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }, gtl?: boolean, serverGtlMutedHosts?: string): void { const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') .select('muting.muteeId') .where('muting.muterId = :muterId', { muterId: me.id }); @@ -140,6 +140,13 @@ export class QueryService { .select('user_profile.mutedInstances') .where('user_profile.userId = :muterId', { muterId: me.id }); + let gtlMutingInstanceQuery; + if (gtl) { + gtlMutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') + .select('user_profile.gtlMutedInstances') + .where('user_profile.userId = :muterId', { muterId: me.id }); + } + // 投稿の作者をミュートしていない かつ // 投稿の返信先の作者をミュートしていない かつ // 投稿の引用元の作者をミュートしていない @@ -158,22 +165,49 @@ export class QueryService { // mute instances .andWhere(new Brackets(qb => { qb - .andWhere('note.userHost IS NULL') - .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); + .where('note.userHost IS NULL') + .orWhere(new Brackets(qbb => { + qbb.where(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); + if (gtl) { + qbb.andWhere(`NOT ((${ gtlMutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); + if (serverGtlMutedHosts.length > 0) { + qbb.andWhere(`NOT (note.userHost IN (:...serverGtlMutedHosts))`, { serverGtlMutedHosts }); + } + } + })); })) .andWhere(new Brackets(qb => { qb .where('note.replyUserHost IS NULL') - .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); + .orWhere(new Brackets(qbb => { + qbb.where(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); + if (gtl) { + qbb.andWhere(`NOT ((${ gtlMutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); + if (serverGtlMutedHosts.length > 0) { + qbb.andWhere(`NOT (note.replyUserHost IN (:...serverGtlMutedHosts))`, { serverGtlMutedHosts }); + } + } + })); })) .andWhere(new Brackets(qb => { qb .where('note.renoteUserHost IS NULL') - .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`); + .orWhere(new Brackets(qbb => { + qbb.where(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`); + if (gtl) { + qbb.andWhere(`NOT ((${ gtlMutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`); + if (serverGtlMutedHosts.length > 0) { + qbb.andWhere(`NOT (note.renoteUserHost IN (:...serverGtlMutedHosts))`, { serverGtlMutedHosts }); + } + } + })); })); q.setParameters(mutingQuery.getParameters()); q.setParameters(mutingInstanceQuery.getParameters()); + if (gtl) { + q.setParameters(gtlMutingInstanceQuery.getParameters()); + } } @bindThis diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 917f4e06d060..343a399a9a5d 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -475,6 +475,7 @@ export class UserEntityService implements OnModuleInit { mutedWords: profile!.mutedWords, hardMutedWords: profile!.hardMutedWords, mutedInstances: profile!.mutedInstances, + gtlMutedInstances: profile!.gtlMutedInstances, mutingNotificationTypes: [], // 後方互換性のため notificationRecieveConfig: profile!.notificationRecieveConfig, emailNotificationTypes: profile!.emailNotificationTypes, diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 83e8962f5db2..b7cb03003d9e 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -81,6 +81,11 @@ export class MiMeta { }) public silencedHosts: string[]; + @Column('varchar', { + length: 1024, array: true, default: '{}', + }) + public gtlMutedHosts: string[]; + @Column('varchar', { length: 1024, nullable: true, diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 6659a014128e..4ca5bb86ea8a 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -229,6 +229,12 @@ export class MiUserProfile { }) public mutedInstances: string[]; + @Column('jsonb', { + default: [], + comment: 'List of instances GTL muted by the user.', + }) + public gtlMutedInstances: string[]; + @Column('jsonb', { default: {}, }) diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 2621e7e6c0e5..26e04eeb0e4e 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -563,6 +563,14 @@ export const packedMeDetailedOnlySchema = { nullable: false, optional: false, }, }, + gtlMutedInstances: { + type: 'array', + nullable: true, optional: false, + items: { + type: 'string', + nullable: false, optional: false, + }, + }, notificationRecieveConfig: { type: 'object', nullable: false, optional: false, diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 8774bcbb67d3..47b3a49282fa 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -115,6 +115,16 @@ export const meta = { nullable: false, }, }, + gtlMutedHosts: { + type: 'array', + optional: true, + nullable: false, + items: { + type: 'string', + optional: false, + nullable: false, + }, + }, pinnedUsers: { type: 'array', optional: false, nullable: false, @@ -470,6 +480,7 @@ export default class extends Endpoint { // eslint- hiddenTags: instance.hiddenTags, blockedHosts: instance.blockedHosts, silencedHosts: instance.silencedHosts, + gtlMutedHosts: instance.gtlMutedHosts, sensitiveWords: instance.sensitiveWords, preservedUsernames: instance.preservedUsernames, hcaptchaSecretKey: instance.hcaptchaSecretKey, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index d6f9b2cd941e..d48d6263ab03 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -136,6 +136,13 @@ export const paramDef = { type: 'string', }, }, + gtlMutedHosts: { + type: 'array', + nullable: true, + items: { + type: 'string', + }, + }, }, required: [], } as const; @@ -176,6 +183,14 @@ export default class extends Endpoint { // eslint- return h !== '' && h !== lv && !set.blockedHosts?.includes(h); }); } + if (Array.isArray(ps.gtlMutedHosts)) { + let lastValue = ''; + set.gtlMutedHosts = ps.gtlMutedHosts.sort().filter((h) => { + const lv = lastValue; + lastValue = h; + return h !== '' && h !== lv && !set.blockedHosts?.includes(h); + }); + } if (ps.themeColor !== undefined) { set.themeColor = ps.themeColor; } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 8f9f091868e3..37b6e8e6ae56 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -181,6 +181,9 @@ export const paramDef = { mutedInstances: { type: 'array', items: { type: 'string', } }, + gtlMutedInstances: { type: 'array', items: { + type: 'string', + } }, notificationRecieveConfig: { type: 'object' }, emailNotificationTypes: { type: 'array', items: { type: 'string', @@ -277,6 +280,7 @@ export default class extends Endpoint { // eslint- profileUpdates.hardMutedWords = ps.hardMutedWords; } if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; + if (ps.gtlMutedInstances !== undefined) profileUpdates.gtlMutedInstances = ps.gtlMutedInstances; if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig; 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/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 019b3dac9ae2..74a21d8454d4 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -12,6 +12,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; +import { MetaService } from '@/core/MetaService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -61,6 +62,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, + private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const policies = await this.roleService.getUserPolicies(me ? me.id : null); @@ -80,7 +82,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); if (me) { - this.queryService.generateMutedUserQuery(query, me); + const metaInfo = await metaService.fetch(); + this.queryService.generateMutedUserQuery(query, me, undefined, true, metaInfo.gtlMutedHosts); this.queryService.generateBlockedUserQuery(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } 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 bb7eded4ed8e..5d85bdf1b5ad 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -8,6 +8,7 @@ import { checkWordMute } from '@/misc/check-word-mute.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import type { Packed } from '@/misc/json-schema.js'; +import type { Meta } from '@/models/Meta.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -21,6 +22,7 @@ class GlobalTimelineChannel extends Channel { private withRenotes: boolean; private withHashtags: boolean; private withFiles: boolean; + private meta: Meta; constructor( private metaService: MetaService, @@ -42,6 +44,7 @@ class GlobalTimelineChannel extends Channel { this.withRenotes = params.withRenotes ?? true; this.withHashtags = params.withHashtags ?? true; this.withFiles = params.withFiles ?? false; + this.meta = await this.metaService.fetch(); // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -67,6 +70,9 @@ class GlobalTimelineChannel extends Channel { // Ignore notes from instances the user has muted if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; + if (isInstanceMuted(note, new Set(this.userProfile?.gtlMutedInstances ?? []))) return; + // Ignore notes from instances this server has muted + if (isInstanceMuted(note, new Set(this.meta.gtlMutedHosts ?? []))) return; // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する if (isUserRelated(note, this.userIdsWhoMeMuting)) return; diff --git a/packages/frontend/src/pages/admin/instance-block.vue b/packages/frontend/src/pages/admin/instance-block.vue index 259354b3d0ca..897b1f28c7ce 100644 --- a/packages/frontend/src/pages/admin/instance-block.vue +++ b/packages/frontend/src/pages/admin/instance-block.vue @@ -16,6 +16,10 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.silencedInstances }} + + {{ i18n.ts.gtlMutedInstances }} + + {{ i18n.ts.save }} @@ -34,18 +38,21 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; let blockedHosts: string = $ref(''); let silencedHosts: string = $ref(''); +let gtlMutedHosts: string = $ref(''); let tab = $ref('block'); async function init() { const meta = await os.api('admin/meta'); blockedHosts = meta.blockedHosts.join('\n'); silencedHosts = meta.silencedHosts.join('\n'); + gtlMutedHosts = meta.gtlMutedHosts.join('\n'); } function save() { os.apiWithDialog('admin/update-meta', { blockedHosts: blockedHosts.split('\n') || [], silencedHosts: silencedHosts.split('\n') || [], + gtlMutedHosts: gtlMutedHosts.split('\n') || [], }).then(() => { fetchInstance(); @@ -62,6 +69,10 @@ const headerTabs = $computed(() => [{ key: 'silence', title: i18n.ts.silence, icon: 'ti ti-eye-off', +}, { + key: 'gtlMuted', + title: i18n.ts.gtlMuted, + icon: 'ti ti-eye-off', }]); definePageMetadata({ diff --git a/packages/frontend/src/pages/settings/mute-block.gtl-instance-mute.vue b/packages/frontend/src/pages/settings/mute-block.gtl-instance-mute.vue new file mode 100644 index 000000000000..48d8b7d7c9af --- /dev/null +++ b/packages/frontend/src/pages/settings/mute-block.gtl-instance-mute.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/mute-block.instance-mute.vue b/packages/frontend/src/pages/settings/mute-block.instance-mute.vue index 4b5080ea8fa0..cf44adfd5b5d 100644 --- a/packages/frontend/src/pages/settings/mute-block.instance-mute.vue +++ b/packages/frontend/src/pages/settings/mute-block.instance-mute.vue @@ -27,7 +27,7 @@ const instanceMutes = ref($i!.mutedInstances.join('\n')); const changed = ref(false); async function save() { - let mutes = instanceMutes.value + const mutes = instanceMutes.value .trim().split('\n') .map(el => el.trim()) .filter(el => el); diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index 4883ca0df4c4..41b95388dfb6 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -26,6 +26,13 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + @@ -128,6 +135,7 @@ SPDX-License-Identifier: AGPL-3.0-only