Merge 681548038d into bc5a33d87f
This commit is contained in:
commit
e7b8d0b655
|
|
@ -58,6 +58,7 @@
|
||||||
"misskey-bubble-game": "workspace:*",
|
"misskey-bubble-game": "workspace:*",
|
||||||
"misskey-js": "workspace:*",
|
"misskey-js": "workspace:*",
|
||||||
"misskey-reversi": "workspace:*",
|
"misskey-reversi": "workspace:*",
|
||||||
|
"modern-ahocorasick": "2.0.3",
|
||||||
"photoswipe": "5.4.4",
|
"photoswipe": "5.4.4",
|
||||||
"punycode.js": "2.3.1",
|
"punycode.js": "2.3.1",
|
||||||
"rollup": "4.42.0",
|
"rollup": "4.42.0",
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { launchPlugins } from '@/plugin.js';
|
||||||
import { updateCurrentAccountPartial } from '@/accounts.js';
|
import { updateCurrentAccountPartial } from '@/accounts.js';
|
||||||
import { signout } from '@/signout.js';
|
import { signout } from '@/signout.js';
|
||||||
import { migrateOldSettings } from '@/pref-migrate.js';
|
import { migrateOldSettings } from '@/pref-migrate.js';
|
||||||
|
import { initWordMuteInfo } from '@/utility/check-word-mute.js';
|
||||||
|
|
||||||
export async function mainBoot() {
|
export async function mainBoot() {
|
||||||
const { isClientUpdated, lastVersion } = await common(async () => {
|
const { isClientUpdated, lastVersion } = await common(async () => {
|
||||||
|
|
@ -313,6 +314,8 @@ export async function mainBoot() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initWordMuteInfo();
|
||||||
|
|
||||||
if (store.s.realtimeMode) {
|
if (store.s.realtimeMode) {
|
||||||
const stream = useStream();
|
const stream = useStream();
|
||||||
|
|
||||||
|
|
@ -354,6 +357,7 @@ export async function mainBoot() {
|
||||||
// 自分の情報が更新されたとき
|
// 自分の情報が更新されたとき
|
||||||
main.on('meUpdated', i => {
|
main.on('meUpdated', i => {
|
||||||
updateCurrentAccountPartial(i);
|
updateCurrentAccountPartial(i);
|
||||||
|
initWordMuteInfo();
|
||||||
});
|
});
|
||||||
|
|
||||||
main.on('readAllNotifications', () => {
|
main.on('readAllNotifications', () => {
|
||||||
|
|
|
||||||
|
|
@ -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 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 isLong = shouldCollapsed(appearNote, urls.value ?? []);
|
||||||
const collapsed = ref(appearNote.cw == null && isLong);
|
const collapsed = ref(appearNote.cw == null && isLong);
|
||||||
const muted = ref(checkMute(appearNote, $i?.mutedWords));
|
const muted = ref(checkMute(appearNote, 'soft'));
|
||||||
const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords, true));
|
const hardMuted = ref(props.withHardMute && checkMute(appearNote, 'hard', true));
|
||||||
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
|
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
|
||||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||||
const translating = ref(false);
|
const translating = ref(false);
|
||||||
|
|
@ -320,20 +320,18 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/* Overload FunctionにLintが対応していないのでコメントアウト
|
/* Overload FunctionにLintが対応していないのでコメントアウト
|
||||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
|
function checkMute(noteToCheck: Misskey.entities.Note, type: 'soft' | 'hard', 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: 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' {
|
function checkMute(noteToCheck: Misskey.entities.Note, type: 'soft' | 'hard', checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' {
|
||||||
if (mutedWords != null) {
|
const result = checkWordMute(noteToCheck, $i, type);
|
||||||
const result = checkWordMute(noteToCheck, $i, mutedWords);
|
if (Array.isArray(result)) return result;
|
||||||
if (Array.isArray(result)) return result;
|
|
||||||
|
|
||||||
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords);
|
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, type);
|
||||||
if (Array.isArray(replyResult)) return replyResult;
|
if (Array.isArray(replyResult)) return replyResult;
|
||||||
|
|
||||||
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords);
|
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, type);
|
||||||
if (Array.isArray(renoteResult)) return renoteResult;
|
if (Array.isArray(renoteResult)) return renoteResult;
|
||||||
}
|
|
||||||
|
|
||||||
if (checkOnly) return false;
|
if (checkOnly) return false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -317,7 +317,7 @@ const galleryEl = useTemplateRef('galleryEl');
|
||||||
const isMyRenote = $i && ($i.id === note.userId);
|
const isMyRenote = $i && ($i.id === note.userId);
|
||||||
const showContent = ref(false);
|
const showContent = ref(false);
|
||||||
const isDeleted = 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<Misskey.entities.NotesTranslateResponse | null>(null);
|
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||||
const translating = ref(false);
|
const translating = ref(false);
|
||||||
const parsed = appearNote.text ? mfm.parse(appearNote.text) : null;
|
const parsed = appearNote.text ? mfm.parse(appearNote.text) : null;
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ const props = withDefaults(defineProps<{
|
||||||
depth: 1,
|
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 showContent = ref(false);
|
||||||
const replies = ref<Misskey.entities.Note[]>([]);
|
const replies = ref<Misskey.entities.Note[]>([]);
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
|
import { reloadAsk } from '@/utility/reload-ask';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
muted: (string[] | string)[];
|
muted: (string[] | string)[];
|
||||||
|
|
@ -88,5 +89,7 @@ async function save() {
|
||||||
emit('save', parsed);
|
emit('save', parsed);
|
||||||
|
|
||||||
changed.value = false;
|
changed.value = false;
|
||||||
|
|
||||||
|
reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -2,42 +2,103 @@
|
||||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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<string | string[]>): Array<string | string[]> | false {
|
type WordMuteInfo = false | {
|
||||||
// 自分自身
|
normals: string[];
|
||||||
if (me && (note.userId === me.id)) return false;
|
and: string[][];
|
||||||
|
regex: Array<{ original: string; regex: RegExp }>;
|
||||||
|
ahoCorasick: AhoCorasick.default;
|
||||||
|
};
|
||||||
|
|
||||||
if (mutedWords.length > 0) {
|
type WordMuteGroup = {
|
||||||
const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim();
|
soft: WordMuteInfo;
|
||||||
|
hard: WordMuteInfo;
|
||||||
|
};
|
||||||
|
|
||||||
if (text === '') return false;
|
const builtWordMutes = shallowRef<WordMuteGroup | undefined>(undefined);
|
||||||
|
|
||||||
const matched = mutedWords.filter(filter => {
|
export function createWordMuteInfo(mutedWords: Array<string | string[]>) : WordMuteInfo {
|
||||||
if (Array.isArray(filter)) {
|
if (mutedWords.length <= 0) return false;
|
||||||
// Clean up
|
const normalTexts: string[] = [];
|
||||||
const filteredFilter = filter.filter(keyword => keyword !== '');
|
const andTexts: string[][] = [];
|
||||||
if (filteredFilter.length === 0) return false;
|
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 {
|
} else {
|
||||||
// represents RegExp
|
andTexts.push(filter);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
} else if (filter.startsWith('/') && filter.endsWith('/')) {
|
||||||
|
const regExp = filter.match(/^\/(.+)\/(.*)$/);
|
||||||
if (matched.length > 0) return matched;
|
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<string | string[]>, hardMutedWords: Array<string | string[]>): 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<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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
|
@ -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<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が実行されないので代わりにここで初期化
|
||||||
|
setWordMuteInfo(c.mutedWords, []);
|
||||||
|
|
||||||
|
const note = NoteMock({ text: c.text, cw: c.cw });
|
||||||
|
const result = checkWordMute(note, null, 'soft');
|
||||||
|
assert.deepEqual(result, c.result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -824,6 +824,9 @@ importers:
|
||||||
misskey-reversi:
|
misskey-reversi:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../misskey-reversi
|
version: link:../misskey-reversi
|
||||||
|
modern-ahocorasick:
|
||||||
|
specifier: 2.0.3
|
||||||
|
version: 2.0.3
|
||||||
photoswipe:
|
photoswipe:
|
||||||
specifier: 5.4.4
|
specifier: 5.4.4
|
||||||
version: 5.4.4
|
version: 5.4.4
|
||||||
|
|
@ -8398,6 +8401,9 @@ packages:
|
||||||
resolution: {integrity: sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==}
|
resolution: {integrity: sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
modern-ahocorasick@2.0.3:
|
||||||
|
resolution: {integrity: sha512-3vsbnf5DmpsaE8Ye892HecJU7kaT2svsIBXNhne1J080WlU9RKjTtE5PgX+OCc2huqGqGYO+rVEsJlJJuQj+Qw==}
|
||||||
|
|
||||||
module-details-from-path@1.0.3:
|
module-details-from-path@1.0.3:
|
||||||
resolution: {integrity: sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==}
|
resolution: {integrity: sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==}
|
||||||
|
|
||||||
|
|
@ -19987,6 +19993,8 @@ snapshots:
|
||||||
|
|
||||||
mock-socket@9.3.1: {}
|
mock-socket@9.3.1: {}
|
||||||
|
|
||||||
|
modern-ahocorasick@2.0.3: {}
|
||||||
|
|
||||||
module-details-from-path@1.0.3: {}
|
module-details-from-path@1.0.3: {}
|
||||||
|
|
||||||
ms@2.0.0: {}
|
ms@2.0.0: {}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue