This commit is contained in:
taichan 2025-02-13 20:09:07 +09:00 committed by GitHub
commit 8704d8830b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 236 additions and 45 deletions

View File

@ -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",

View File

@ -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', () => {

View File

@ -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<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false);
@ -302,20 +302,18 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
}));
/* Overload FunctionLint
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): Array<string | string[]> | 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<string | string[]> | false | 'sensitiveMute';
*/
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | 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<string | string[]> | 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;

View File

@ -298,7 +298,7 @@ const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
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<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false);
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;

View File

@ -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<Misskey.entities.Note[]>([]);

View File

@ -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<string | string[]>): Array<string | string[]> | 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<string | string[]>) : 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<string | string[]>, hardMutedWords: Array<string | string[]>): 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<string | string[]> | 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<string | string[]> = normalMatches.map(match => match[1]).concat(andMatches, regexMatches.map(({ original }) => original));
return matched.length > 0 ? matched : false;
}

View File

@ -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;
});

View File

@ -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<string | string[]>,
result: false | Array<string | string[]>,
}
describe('check-word-mute', () => {
const cases:Array<TestCases> = [
{
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);
});
});
});

View File

@ -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: {}