diff --git a/CHANGELOG.md b/CHANGELOG.md index 00cff6d862ed..8f0555a9592a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ## 2023.11.1-kinel.1 ### General +- Feat: TL上からノートが見えなくなるワードミュートであるハードミュートを追加 ### Client diff --git a/locales/index.d.ts b/locales/index.d.ts index b197c3d0b127..422f371fe609 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -639,6 +639,7 @@ export interface Locale { "smtpSecureInfo": string; "testEmail": string; "wordMute": string; + "hardWordMute": string; "regexpError": string; "regexpErrorDescription": string; "instanceMute": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 44830e32f1c6..50d07773f5df 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -636,6 +636,7 @@ smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" smtpSecureInfo: "STARTTLS使用時はオフにします。" testEmail: "配信テスト" wordMute: "ワードミュート" +hardWordMute: "ハードワードミュート" regexpError: "正規表現エラー" regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが発生しました:" instanceMute: "サーバーミュート" diff --git a/packages/backend/migration/1700383825690-hard-mute.js b/packages/backend/migration/1700383825690-hard-mute.js new file mode 100644 index 000000000000..afd3247f5cae --- /dev/null +++ b/packages/backend/migration/1700383825690-hard-mute.js @@ -0,0 +1,11 @@ +export class HardMute1700383825690 { + name = 'HardMute1700383825690' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "hardMutedWords" jsonb NOT NULL DEFAULT '[]'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "hardMutedWords"`); + } +} diff --git a/packages/backend/migration/1700906353915-HardMuteAndSoftMuteFromRegistory.js b/packages/backend/migration/1700906353915-HardMuteAndSoftMuteFromRegistory.js new file mode 100644 index 000000000000..63d9fa7eafbe --- /dev/null +++ b/packages/backend/migration/1700906353915-HardMuteAndSoftMuteFromRegistory.js @@ -0,0 +1,56 @@ +export class HardMuteAndSoftMuteFromRegistory1700906353915 { + async up(queryRunner) { + // until 2023.9.3-kinel.4, `mutedWords` means hard muted words + // since 2023.11.1-kinel.1, `mutedWords` means soft muted words and `hardMutedWords` means hard muted words + // so migrate hard muted words to `hardMutedWords` + await queryRunner.query(`UPDATE "user_profile" SET "hardMutedWords" = "mutedWords";`); + + // then, migrate soft muted words from registry + let entries = await queryRunner.query( + `SELECT "userId", "value" FROM "registry_item" + WHERE "registry_item"."domain" IS NULL + AND "registry_item"."key" = $1 + AND "registry_item"."scope" = $2;`, + ['mutedWords', ['client', 'base']]); + + for (let entry of entries) { + await queryRunner.query(`UPDATE "user_profile" SET "mutedWords" = $1 WHERE "user_profile"."userId" = $2;`, + [JSON.stringify(entry.value), entry.userId]); + } + } + + async down(queryRunner) { + const entries = await queryRunner.query(`SELECT "userId", "mutedWords" FROM "user_profile";`); + for (let entry of entries) { + let existingEntry = await queryRunner.query( + `SELECT "id", "userId", "value" FROM "registry_item" + WHERE "registry_item"."domain" IS NULL + AND "registry_item"."key" = $1 + AND "registry_item"."scope" = $2 + AND "registry_item"."userId" = $3;`, + ['mutedWords', ['client', 'base'], entry.userId]); + + if (existingEntry.length > 0) { + await queryRunner.connection.createQueryBuilder() + .update('registry_item') + .set({ value: entry.mutedWords }) + .where('id = :id', { id: existingEntry[0].id }) + .execute(); + } else { + await queryRunner.connection.createQueryBuilder() + .insert() + .into('registry_item') + .values({ + key: 'mutedWords', + scope: ['client', 'base'], + userId: entry.userId, + domain: null, + value: entry.mutedWords + }) + .execute(); + } + } + + await queryRunner.query(`UPDATE "user_profile" SET "mutedWords" = "hardMutedWords";`); + } +} diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 17e79881765a..917f4e06d060 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -473,6 +473,7 @@ export class UserEntityService implements OnModuleInit { hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), unreadNotificationsCount: notificationsInfo?.unreadCount, mutedWords: profile!.mutedWords, + hardMutedWords: profile!.hardMutedWords, mutedInstances: profile!.mutedInstances, mutingNotificationTypes: [], // 後方互換性のため notificationRecieveConfig: profile!.notificationRecieveConfig, diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index d6d85c560930..8a43b60039a6 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -215,7 +215,12 @@ export class MiUserProfile { @Column('jsonb', { default: [], }) - public mutedWords: string[][]; + public mutedWords: (string[] | string)[]; + + @Column('jsonb', { + default: [], + }) + public hardMutedWords: (string[] | 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 37bdcbe281f2..99da1ef569ca 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -415,6 +415,18 @@ export const packedMeDetailedOnlySchema = { }, }, }, + hardMutedWords: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'string', + nullable: false, optional: false, + }, + }, + }, mutedInstances: { type: 'array', nullable: true, optional: false, diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index b00aa87bee29..b045c011891e 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -123,6 +123,11 @@ export const meta = { }, } as const; +const muteWords = { type: 'array', items: { oneOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'string' } +] } } as const; + export const paramDef = { type: 'object', properties: { @@ -171,7 +176,8 @@ export const paramDef = { autoSensitive: { type: 'boolean' }, ffVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true }, - mutedWords: { type: 'array' }, + mutedWords: muteWords, + hardMutedWords: muteWords, mutedInstances: { type: 'array', items: { type: 'string', } }, @@ -234,16 +240,20 @@ export default class extends Endpoint { // eslint- if (ps.location !== undefined) profileUpdates.location = ps.location; if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility; - if (ps.mutedWords !== undefined) { + + function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) { // TODO: ちゃんと数える - const length = JSON.stringify(ps.mutedWords).length; - if (length > (await this.roleService.getUserPolicies(user.id)).wordMuteLimit) { + const length = JSON.stringify(mutedWords).length; + if (length > limit) { throw new ApiError(meta.errors.tooManyMutedWords); } + } + + function validateMuteWordRegex(mutedWords: (string[] | string)[]) { + for (const mutedWord of mutedWords) { + if (typeof mutedWord !== "string") continue; - // validate regular expression syntax - ps.mutedWords.filter(x => !Array.isArray(x)).forEach(x => { - const regexp = x.match(/^\/(.+)\/(.*)$/); + const regexp = mutedWord.match(/^\/(.+)\/(.*)$/); if (!regexp) throw new ApiError(meta.errors.invalidRegexp); try { @@ -251,11 +261,21 @@ export default class extends Endpoint { // eslint- } catch (err) { throw new ApiError(meta.errors.invalidRegexp); } - }); + } + } + + if (ps.mutedWords !== undefined) { + checkMuteWordCount(ps.mutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); + validateMuteWordRegex(ps.mutedWords); profileUpdates.mutedWords = ps.mutedWords; profileUpdates.enableWordMute = ps.mutedWords.length > 0; } + if (ps.hardMutedWords !== undefined) { + checkMuteWordCount(ps.hardMutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); + validateMuteWordRegex(ps.hardMutedWords); + profileUpdates.hardMutedWords = ps.hardMutedWords; + } if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig; if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 1867525cc8da..2ce8fbc12942 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -168,6 +168,7 @@ describe('ユーザー', () => { hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest, unreadAnnouncements: user.unreadAnnouncements, mutedWords: user.mutedWords, + hardMutedWords: user.hardMutedWords, mutedInstances: user.mutedInstances, mutingNotificationTypes: user.mutingNotificationTypes, notificationRecieveConfig: user.notificationRecieveConfig, diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index e0c6369c2f8f..653fb19c6334 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only