diff options
| author | anatawa12 <anatawa12@icloud.com> | 2023-11-23 18:56:20 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-11-23 18:56:20 +0900 |
| commit | 864827f788cd1671a4db2ebc159c1c8ab031b7ad (patch) | |
| tree | 689fa67a1f438cc3410446cd893397d91c2d6725 /packages | |
| parent | 絵文字のオートコンプリート強化の対応 (#12365) (diff) | |
| download | sharkey-864827f788cd1671a4db2ebc159c1c8ab031b7ad.tar.gz sharkey-864827f788cd1671a4db2ebc159c1c8ab031b7ad.tar.bz2 sharkey-864827f788cd1671a4db2ebc159c1c8ab031b7ad.zip | |
Hard mute (#12376)
* feat(backend,misskey-js): hard mute storage in backend
* fix(backend,misskey-js): mute word record type
* chore(frontend): generalize XWordMute
* feat(frontend): configure hard mute
* feat(frontend): hard mute notes on the timelines
* lint(backend,frontend): fix lint failure
* chore(misskey-js): update api.md
* fix(backend): test failure
* chore(frontend): check word mute for reply
* chore: limit hard mute count
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/backend/migration/1700383825690-hard-mute.js | 11 | ||||
| -rw-r--r-- | packages/backend/src/core/entities/UserEntityService.ts | 1 | ||||
| -rw-r--r-- | packages/backend/src/models/UserProfile.ts | 7 | ||||
| -rw-r--r-- | packages/backend/src/models/json-schema/user.ts | 12 | ||||
| -rw-r--r-- | packages/backend/src/server/api/endpoints/i/update.ts | 34 | ||||
| -rw-r--r-- | packages/backend/test/e2e/users.ts | 1 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkNote.vue | 17 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkNotes.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkNotifications.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/mute-block.vue | 18 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/mute-block.word-mute.vue | 22 | ||||
| -rw-r--r-- | packages/misskey-js/etc/misskey-js.api.md | 12 | ||||
| -rw-r--r-- | packages/misskey-js/src/api.types.ts | 3 | ||||
| -rw-r--r-- | packages/misskey-js/src/entities.ts | 3 |
14 files changed, 112 insertions, 33 deletions
diff --git a/packages/backend/migration/1700383825690-hard-mute.js b/packages/backend/migration/1700383825690-hard-mute.js new file mode 100644 index 0000000000..afd3247f5c --- /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/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 17e7988176..917f4e06d0 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 d6d85c5609..8a43b60039 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 b0e18db01a..a2ec203e96 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -530,6 +530,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 b00aa87bee..8ba29c5658 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<typeof meta, typeof paramDef> { // 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) { + 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<typeof meta, typeof paramDef> { // 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 1867525cc8..2ce8fbc129 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 e300ef88a5..6349df2e30 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 <template> <div - v-if="!muted" + v-if="!hardMuted && !muted" v-show="!isDeleted" ref="el" v-hotkey="keymap" @@ -133,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </article> </div> -<div v-else :class="$style.muted" @click="muted = false"> +<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false"> <I18n :src="i18n.ts.userSaysSomething" tag="small"> <template #name> <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> @@ -183,6 +183,7 @@ const props = withDefaults(defineProps<{ note: Misskey.entities.Note; pinned?: boolean; mock?: boolean; + withHardMute?: boolean; }>(), { mock: false, }); @@ -239,13 +240,23 @@ const urls = $computed(() => parsed ? extractUrlFromMfm(parsed) : null); const isLong = shouldCollapsed(appearNote, urls ?? []); const collapsed = ref(appearNote.cw == null && isLong); const isDeleted = ref(false); -const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); +const muted = ref(checkMute(appearNote, $i?.mutedWords)); +const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords)); const translation = ref<any>(null); const translating = ref(false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i.id)); let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null))); +function checkMute(note: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null): boolean { + if (mutedWords == null) return false; + + if (checkWordMute(note, $i, mutedWords)) return true; + if (note.reply && checkWordMute(note.reply, $i, mutedWords)) return true; + if (note.renote && checkWordMute(note.renote, $i, mutedWords)) return true; + return false; +} + const keymap = { 'r': () => reply(true), 'e|a|plus': () => react(true), diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index 89fd504dcc..7af31074db 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only :ad="true" :class="$style.notes" > - <MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> + <MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note" :withHardMute="true"/> </MkDateSeparatedList> </div> </template> diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index 0c817bd64c..7b072fa492 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #default="{ items: notifications }"> <MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true"> - <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> + <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" :withHardMute="true"/> <XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel"/> </MkDateSeparatedList> </template> diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index c6cbd424ec..4883ca0df4 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -9,7 +9,14 @@ SPDX-License-Identifier: AGPL-3.0-only <template #icon><i class="ti ti-message-off"></i></template> <template #label>{{ i18n.ts.wordMute }}</template> - <XWordMute/> + <XWordMute :muted="$i!.mutedWords" @save="saveMutedWords"/> + </MkFolder> + + <MkFolder> + <template #icon><i class="ti ti-message-off"></i></template> + <template #label>{{ i18n.ts.hardWordMute }}</template> + + <XWordMute :muted="$i!.hardMutedWords" @save="saveHardMutedWords"/> </MkFolder> <MkFolder> @@ -129,6 +136,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import * as os from '@/os.js'; import { infoImageUrl } from '@/instance.js'; +import { $i } from '@/account.js'; import MkFolder from '@/components/MkFolder.vue'; const renoteMutingPagination = { @@ -207,6 +215,14 @@ async function toggleBlockItem(item) { } } +async function saveMutedWords(mutedWords: (string | string[])[]) { + await os.api('i/update', { mutedWords }); +} + +async function saveHardMutedWords(hardMutedWords: (string | string[])[]) { + await os.api('i/update', { hardMutedWords }); +} + const headerActions = $computed(() => []); const headerTabs = $computed(() => []); diff --git a/packages/frontend/src/pages/settings/mute-block.word-mute.vue b/packages/frontend/src/pages/settings/mute-block.word-mute.vue index 25a836ea55..7328967c51 100644 --- a/packages/frontend/src/pages/settings/mute-block.word-mute.vue +++ b/packages/frontend/src/pages/settings/mute-block.word-mute.vue @@ -18,16 +18,17 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, watch } from 'vue'; import MkTextarea from '@/components/MkTextarea.vue'; -import MkKeyValue from '@/components/MkKeyValue.vue'; import MkButton from '@/components/MkButton.vue'; -import MkInfo from '@/components/MkInfo.vue'; -import MkTab from '@/components/MkTab.vue'; import * as os from '@/os.js'; -import number from '@/filters/number.js'; -import { defaultStore } from '@/store.js'; -import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; + +const props = defineProps<{ + muted: (string[] | string)[]; +}>(); + +const emit = defineEmits<{ + (ev: 'save', value: (string[] | string)[]): void; +}>(); const render = (mutedWords) => mutedWords.map(x => { if (Array.isArray(x)) { @@ -37,8 +38,7 @@ const render = (mutedWords) => mutedWords.map(x => { } }).join('\n'); -const tab = ref('soft'); -const mutedWords = ref(render($i!.mutedWords)); +const mutedWords = ref(render(props.muted)); const changed = ref(false); watch(mutedWords, () => { @@ -85,9 +85,7 @@ async function save() { return; } - await os.api('i/update', { - mutedWords: parsed, - }); + emit('save', parsed); changed.value = false; } diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 63c3cb71a5..dc93c4be3b 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1565,7 +1565,8 @@ export type Endpoints = { injectFeaturedNote?: boolean; receiveAnnouncementEmail?: boolean; alwaysMarkNsfw?: boolean; - mutedWords?: string[][]; + mutedWords?: (string[] | string)[]; + hardMutedWords?: (string[] | string)[]; notificationRecieveConfig?: any; emailNotificationTypes?: string[]; alsoKnownAs?: string[]; @@ -2516,7 +2517,8 @@ type MeDetailed = UserDetailed & { integrations: Record<string, any>; isDeleted: boolean; isExplorable: boolean; - mutedWords: string[][]; + mutedWords: (string[] | string)[]; + hardMutedWords: (string[] | string)[]; notificationRecieveConfig: { [notificationType in typeof notificationTypes_2[number]]?: { type: 'all'; @@ -3053,9 +3055,9 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u // // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts // src/api.types.ts:20:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts -// src/api.types.ts:634:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts -// src/entities.ts:116:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts -// src/entities.ts:627:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts +// src/api.types.ts:635:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts +// src/entities.ts:117:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts +// src/entities.ts:628:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts index 1a75b7cf57..ba333231e5 100644 --- a/packages/misskey-js/src/api.types.ts +++ b/packages/misskey-js/src/api.types.ts @@ -432,7 +432,8 @@ export type Endpoints = { injectFeaturedNote?: boolean; receiveAnnouncementEmail?: boolean; alwaysMarkNsfw?: boolean; - mutedWords?: string[][]; + mutedWords?: (string[] | string)[]; + hardMutedWords?: (string[] | string)[]; notificationRecieveConfig?: any; emailNotificationTypes?: string[]; alsoKnownAs?: string[]; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index a51315b13b..2ddcf93f5d 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -112,7 +112,8 @@ export type MeDetailed = UserDetailed & { integrations: Record<string, any>; isDeleted: boolean; isExplorable: boolean; - mutedWords: string[][]; + mutedWords: (string[] | string)[]; + hardMutedWords: (string[] | string)[]; notificationRecieveConfig: { [notificationType in typeof notificationTypes[number]]?: { type: 'all'; |