diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 0dbd94362b..cc326a9751 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -58,6 +58,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.42.0", diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index ae4e0445db..1d6751ab48 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -30,6 +30,7 @@ import { launchPlugins } from '@/plugin.js'; import { updateCurrentAccountPartial } from '@/accounts.js'; import { signout } from '@/signout.js'; import { migrateOldSettings } from '@/pref-migrate.js'; +import { initWordMuteInfo } from '@/utility/check-word-mute.js'; export async function mainBoot() { const { isClientUpdated, lastVersion } = await common(async () => { @@ -313,6 +314,8 @@ export async function mainBoot() { } } + initWordMuteInfo(); + if (store.s.realtimeMode) { const stream = useStream(); @@ -354,6 +357,7 @@ export async function mainBoot() { // 自分の情報が更新されたとき main.on('meUpdated', i => { updateCurrentAccountPartial(i); + initWordMuteInfo(); }); main.on('readAllNotifications', () => { diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 040c2acdc2..93d3821e4e 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -300,8 +300,8 @@ const parsed = computed(() => appearNote.text ? mfm.parse(appearNote.text) : nul const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null); const isLong = shouldCollapsed(appearNote, urls.value ?? []); const collapsed = ref(appearNote.cw == null && isLong); -const muted = ref(checkMute(appearNote, $i?.mutedWords)); -const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords, true)); +const muted = ref(checkMute(appearNote, 'soft')); +const hardMuted = ref(props.withHardMute && checkMute(appearNote, 'hard', true)); const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord); const translation = ref(null); const translating = ref(false); @@ -320,20 +320,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 7a2090d171..7efa463d0e 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -317,7 +317,7 @@ const galleryEl = useTemplateRef('galleryEl'); const isMyRenote = $i && ($i.id === note.userId); const showContent = ref(false); const isDeleted = ref(false); -const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); +const muted = ref($i ? checkWordMute(appearNote, $i, 'soft') : false); const translation = ref(null); const translating = ref(false); const parsed = appearNote.text ? mfm.parse(appearNote.text) : null; diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index 4fd1c210cb..a00a395db7 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/pages/settings/mute-block.word-mute.vue b/packages/frontend/src/pages/settings/mute-block.word-mute.vue index f5837abe98..4cdd8712ca 100644 --- a/packages/frontend/src/pages/settings/mute-block.word-mute.vue +++ b/packages/frontend/src/pages/settings/mute-block.word-mute.vue @@ -21,6 +21,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; +import { reloadAsk } from '@/utility/reload-ask'; const props = defineProps<{ muted: (string[] | string)[]; @@ -88,5 +89,7 @@ async function save() { emit('save', parsed); changed.value = false; + + reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); } diff --git a/packages/frontend/src/utility/check-word-mute.ts b/packages/frontend/src/utility/check-word-mute.ts index 98fea1bced..6cddc2a0b3 100644 --- a/packages/frontend/src/utility/check-word-mute.ts +++ b/packages/frontend/src/utility/check-word-mute.ts @@ -2,42 +2,103 @@ * 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 { shallowRef } from 'vue'; +import type * as Misskey from 'misskey-js'; +import { $i } from '@/i.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 WordMuteGroup = { + soft: WordMuteInfo; + hard: WordMuteInfo; +}; - if (text === '') return false; +const builtWordMutes = shallowRef(undefined); - const matched = mutedWords.filter(filter => { - if (Array.isArray(filter)) { - // Clean up - const filteredFilter = filter.filter(keyword => keyword !== ''); - if (filteredFilter.length === 0) 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 }> = []; - 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, + }; +} + +export function setWordMuteInfo(mutedWords: Array, hardMutedWords: Array): void { + const soft = createWordMuteInfo(mutedWords); + const hard = createWordMuteInfo(hardMutedWords); + + builtWordMutes.value = { soft, hard }; +} + +function getWordMuteInfo(): WordMuteGroup | undefined { + return builtWordMutes.value; +} + +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..1f56ce1f74 --- /dev/null +++ b/packages/frontend/test/word-mute.test.ts @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, test, assert } from 'vitest'; +import { checkWordMute, setWordMuteInfo } from '@/utility/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が実行されないので代わりにここで初期化 + setWordMuteInfo(c.mutedWords, []); + + 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 086bff99b4..ffceede525 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -824,6 +824,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 @@ -8398,6 +8401,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==} @@ -19987,6 +19993,8 @@ snapshots: mock-socket@9.3.1: {} + modern-ahocorasick@2.0.3: {} + module-details-from-path@1.0.3: {} ms@2.0.0: {}