diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 804160baad..693eb76863 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -54,6 +54,7 @@ "misskey-bubble-game": "workspace:*", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", + "modern-ahocorasick": "2.0.3", "photoswipe": "5.4.4", "punycode.js": "2.3.1", "rollup": "4.26.0", diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 3a43c6794b..02b5cf9ebb 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -25,6 +25,7 @@ import { mainRouter } from '@/router/main.js'; import { makeHotkey } from '@/scripts/hotkey.js'; import type { Keymap } from '@/scripts/hotkey.js'; import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js'; +import { initWordMuteInfo } from '@/scripts/check-word-mute.js'; export async function mainBoot() { const { isClientUpdated } = await common(() => { @@ -344,11 +345,14 @@ export async function mainBoot() { } } + initWordMuteInfo(); + const main = markRaw(stream.useChannel('main', null, 'System')); // 自分の情報が更新されたとき main.on('meUpdated', i => { updateAccountPartial(i); + initWordMuteInfo(); }); main.on('readAllNotifications', () => { diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 919734f763..609b3a443c 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -282,8 +282,8 @@ const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filte const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); const collapsed = ref(appearNote.value.cw == null && isLong); const isDeleted = ref(false); -const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); -const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true)); +const muted = ref(checkMute(appearNote.value, 'soft')); +const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, 'hard', true)); const showSoftWordMutedWord = computed(() => defaultStore.state.showSoftWordMutedWord); const translation = ref(null); const translating = ref(false); @@ -302,20 +302,18 @@ const pleaseLoginContext = computed(() => ({ })); /* Overload FunctionにLintが対応していないのでコメントアウト -function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array | undefined | null, checkOnly: true): boolean; -function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array | undefined | null, checkOnly: false): Array | false | 'sensitiveMute'; +function checkMute(noteToCheck: Misskey.entities.Note, type: 'soft' | 'hard', checkOnly: true): boolean; +function checkMute(noteToCheck: Misskey.entities.Note, type: 'soft' | 'hard', checkOnly: false): Array | false | 'sensitiveMute'; */ -function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array | undefined | null, checkOnly = false): Array | false | 'sensitiveMute' { - if (mutedWords != null) { - const result = checkWordMute(noteToCheck, $i, mutedWords); - if (Array.isArray(result)) return result; +function checkMute(noteToCheck: Misskey.entities.Note, type: 'soft' | 'hard', checkOnly = false): Array | false | 'sensitiveMute' { + const result = checkWordMute(noteToCheck, $i, type); + if (Array.isArray(result)) return result; - const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords); - if (Array.isArray(replyResult)) return replyResult; + const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, type); + if (Array.isArray(replyResult)) return replyResult; - const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords); - if (Array.isArray(renoteResult)) return renoteResult; - } + const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, type); + if (Array.isArray(renoteResult)) return renoteResult; if (checkOnly) return false; diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index fa1358da3d..8e9f0de587 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -298,7 +298,7 @@ const galleryEl = shallowRef>(); const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(false); const isDeleted = ref(false); -const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false); +const muted = ref($i ? checkWordMute(appearNote.value, $i, 'soft') : false); const translation = ref(null); const translating = ref(false); const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index e4bade309b..2070a44e5a 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -62,7 +62,7 @@ const props = withDefaults(defineProps<{ depth: 1, }); -const muted = ref($i ? checkWordMute(props.note, $i, $i.mutedWords) : false); +const muted = ref($i ? checkWordMute(props.note, $i, 'soft') : false); const showContent = ref(false); const replies = ref([]); diff --git a/packages/frontend/src/scripts/check-word-mute.ts b/packages/frontend/src/scripts/check-word-mute.ts index 98fea1bced..9ddbc5362e 100644 --- a/packages/frontend/src/scripts/check-word-mute.ts +++ b/packages/frontend/src/scripts/check-word-mute.ts @@ -2,42 +2,101 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -import * as Misskey from 'misskey-js'; +import * as AhoCorasick from 'modern-ahocorasick'; +import type * as Misskey from 'misskey-js'; +import { $i } from '@/account.js'; -export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array): Array | false { - // 自分自身 - if (me && (note.userId === me.id)) return false; +type WordMuteInfo = false | { + normals: string[]; + and: string[][]; + regex: Array<{ original: string; regex: RegExp }>; + ahoCorasick: AhoCorasick.default; +} - if (mutedWords.length > 0) { - const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim(); +type GlobalMisskeyWordMute = { + soft: WordMuteInfo; + hard: WordMuteInfo; +}; - if (text === '') return false; +export function createWordMuteInfo(mutedWords: Array) : WordMuteInfo { + if (mutedWords.length <= 0) return false; + const normalTexts: string[] = []; + const andTexts: string[][] = []; + const regexTexts: Array<{ original: string; regex: RegExp }> = []; - const matched = mutedWords.filter(filter => { - if (Array.isArray(filter)) { - // Clean up - const filteredFilter = filter.filter(keyword => keyword !== ''); - if (filteredFilter.length === 0) return false; - - return filteredFilter.every(keyword => text.includes(keyword)); + for (const filter of mutedWords) { + if (Array.isArray(filter)) { + if (filter.length === 1) { + normalTexts.push(filter[0]); } else { - // represents RegExp - const regexp = filter.match(/^\/(.+)\/(.*)$/); - - // This should never happen due to input sanitisation. - if (!regexp) return false; - - try { - return new RegExp(regexp[1], regexp[2]).test(text); - } catch (err) { - // This should never happen due to input sanitisation. - return false; - } + andTexts.push(filter); } - }); - - if (matched.length > 0) return matched; + } else if (filter.startsWith('/') && filter.endsWith('/')) { + const regExp = filter.match(/^\/(.+)\/(.*)$/); + if (!regExp) continue; + try { + regexTexts.push({ original: filter, regex: new RegExp(filter.slice(1, -1)) }); + } catch { + // 無効な正規表現はスキップ + } + } else { + normalTexts.push(filter); + } } - return false; + const ac = new AhoCorasick.default(normalTexts); + + return { + normals: normalTexts, + and: andTexts, + regex: regexTexts, + ahoCorasick: ac, + }; +} + +function setWordMuteInfo(mutedWords: Array, hardMutedWords: Array): void { + const soft = createWordMuteInfo(mutedWords); + const hard = createWordMuteInfo(hardMutedWords); + + globalThis._misskeyWordMute = { soft, hard }; +} + +function getWordMuteInfo(): GlobalMisskeyWordMute | undefined { + if (!globalThis._misskeyWordMute) return undefined; + return globalThis._misskeyWordMute as unknown as GlobalMisskeyWordMute; +} + +export function initWordMuteInfo(): void { + const mutedWords = $i?.mutedWords ?? []; + const hardMutedWords = $i?.hardMutedWords ?? []; + + setWordMuteInfo(mutedWords, hardMutedWords); +} + +export function checkWordMute( + note: Misskey.entities.Note, + me: Misskey.entities.UserLite | null | undefined, + type: 'soft' | 'hard', +): Array | false { + // 自分自身の投稿は対象外 + if (me && (note.userId === me.id)) return false; + + const wordMuteInfo = getWordMuteInfo()?.[type]; + + if (wordMuteInfo == null || wordMuteInfo === false) return false; + + const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim(); + if (text === '') return false; + + const normalMatches = wordMuteInfo.ahoCorasick.search(text); + + // andTexts + const andMatches = wordMuteInfo.and.filter(texts => texts.filter(keyword => keyword !== '').every(keyword => text.includes(keyword))); + + // RegExp + const regexMatches = wordMuteInfo.regex.filter(({ regex }) => regex.test(text)); + + const matched: Array = normalMatches.map(match => match[1]).concat(andMatches, regexMatches.map(({ original }) => original)); + + return matched.length > 0 ? matched : false; } diff --git a/packages/frontend/test/mocks.ts b/packages/frontend/test/mocks.ts new file mode 100644 index 0000000000..a4f02bda1c --- /dev/null +++ b/packages/frontend/test/mocks.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type * as Misskey from 'misskey-js'; +import { vi } from 'vitest'; + +export const UserLiteMock = vi.fn(() => { + return { + id: 'xxxxxxxx', + username: 'ai', + host: null, + name: '藍', + avatarUrl: null, + avatarBlurhash: null, + avatarDecorations: [], + emojis: {}, + onlineStatus: 'online', + } as Misskey.entities.UserLite; +}); + +export const NoteMock = vi.fn((options?: { + text?: string, + cw?: string, +}) => { + const user = new UserLiteMock(); + return { + id: 'xxxxxxxx', + // 2025/01/01 00:00:00 UTC on Unix time + createdAt: '1767225600000', + text: options?.text ?? 'Hello, Misskey!', + cw: options?.cw, + userId: user.id, + user: user, + visibility: 'public', + reactionAcceptance: null, + reactionEmojis: {}, + reactions: {}, + reactionCount: 0, + renoteCount: 0, + repliesCount: 0, + } as Misskey.entities.Note; +}); diff --git a/packages/frontend/test/word-mute.test.ts b/packages/frontend/test/word-mute.test.ts new file mode 100644 index 0000000000..a4397dfbd4 --- /dev/null +++ b/packages/frontend/test/word-mute.test.ts @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, test, assert } from 'vitest'; +import { createWordMuteInfo, checkWordMute } from '@/scripts/check-word-mute.js'; +import { NoteMock } from './mocks.js'; + +type TestCases = { + text: string, + cw?: string, + mutedWords: Array, + result: false | Array, +} + +describe('check-word-mute', () => { + const cases:Array = [ + { + text: 'Hello, Misskey!', + mutedWords: ['Misskey'], + result: [['Misskey']], + }, + // cw + { + text: 'Hello, Misskey!', + cw: 'ai', + mutedWords: ['ai'], + result: [['ai']], + }, + // nothing + { + text: 'Hello, Misskey!', + mutedWords: [], + result: false, + }, + // surrogate pair + { + text: '𠮟る', + mutedWords: ['𠮟'], + result: [['𠮟']], + }, + // grapheme cluster + { + text: '👩‍❤️‍👨', + mutedWords: ['👩'], + result: false, + }, + // regex + { + text: 'Hello, Misskey!', + mutedWords: ['/M[isk]*ey/'], + result: ['/M[isk]*ey/'], + }, + // multi words + { + text: 'Hello, Misskey!', + mutedWords: [['Hello', 'Misskey'], ['Mi']], + result: [['Mi'],['Hello', 'Misskey']], + }, + ] + + cases.forEach((c) => { + test(`text: ${c.text}, cw: ${c.cw}, mutedWords: ${c.mutedWords}` , async () => { + // initWordMuteInfoが実行されないので代わりにここで初期化 + (globalThis as any)._misskeyWordMute = { + soft: createWordMuteInfo(c.mutedWords), + hard: createWordMuteInfo([]), + } + + const note = NoteMock({ text: c.text, cw: c.cw }); + const result = checkWordMute(note, null, 'soft'); + assert.deepEqual(result, c.result); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27dfcfccc6..733fb12b28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -799,6 +799,9 @@ importers: misskey-reversi: specifier: workspace:* version: link:../misskey-reversi + modern-ahocorasick: + specifier: 2.0.3 + version: 2.0.3 photoswipe: specifier: 5.4.4 version: 5.4.4 @@ -7854,6 +7857,7 @@ packages: lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} @@ -8311,6 +8315,9 @@ packages: resolution: {integrity: sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==} engines: {node: '>= 8'} + modern-ahocorasick@2.0.3: + resolution: {integrity: sha512-3vsbnf5DmpsaE8Ye892HecJU7kaT2svsIBXNhne1J080WlU9RKjTtE5PgX+OCc2huqGqGYO+rVEsJlJJuQj+Qw==} + module-details-from-path@1.0.3: resolution: {integrity: sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==} @@ -19896,6 +19903,8 @@ snapshots: mock-socket@9.3.1: {} + modern-ahocorasick@2.0.3: {} + module-details-from-path@1.0.3: {} ms@2.0.0: {}