Compare commits

...

13 Commits

Author SHA1 Message Date
taichan 8704d8830b
Merge 32cf70eb9b into 4d562e7439 2025-02-13 20:09:07 +09:00
おさむのひと 4d562e7439
enhance(frontend): ノートに埋め込まれたメディアのコンテキストメニューから管理者用のファイル管理画面を開けるように (#15460)
* enhance(frontend): ノートに埋め込まれたメディアのコンテキストメニューから管理者用のファイル管理画面を開けるように

* fix icon

* fix menu

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-02-13 11:09:04 +00:00
syuilo 30df768d26 Update about-misskey.vue 2025-02-12 20:55:12 +09:00
tai-cha 32cf70eb9b
Fix: typo 2025-02-09 07:59:59 +09:00
tai-cha d639f4bd4a
formatting code 2025-02-09 07:55:32 +09:00
tai-cha 85b8af4b98
test(frontend): vitestによるワードミュートのテスト 2025-02-09 07:54:10 +09:00
tai-cha 2adf917fcf
Fix: 謎の混入を削除 2025-02-08 11:02:22 +09:00
tai-cha fe8f20779b
update modern-ahocorasick to 2.0.3 (unicode fix) 2025-02-08 10:41:56 +09:00
tai-cha ca30df4b90
refactor: 名前変更 initMuteInfo initWordMuteInfo 2025-02-04 22:54:11 +09:00
tai-cha cc1fdece65
perf: ノート毎にミュートワードを渡すのをやめる 2025-02-04 22:51:09 +09:00
tai-cha 9a71514b4c Refactor: just import type 2025-02-02 08:43:04 +00:00
tai-cha fbf7c61c07 FIx: pnpm-lock by pnpm install 2025-02-02 08:20:09 +00:00
tai-cha 1d2f2eda30 perf(frontend): ahocorasickを使ったワードミュート 2025-02-02 03:18:51 +00:00
14 changed files with 285 additions and 54 deletions

View File

@ -9,6 +9,7 @@
- Enhance: アンテナ、リスト等の名前をカラム名のデフォルト値にするように `#13992`
- Enhance: クライアントエラー画面の多言語対応
- Enhance: 開発者モードでメニューからファイルIDをコピー出来るように `#15441'
- Enhance: ノートに埋め込まれたメディアのコンテキストメニューから管理者用のファイル管理画面を開けるように ( #15440 )
- Fix: コンディショナルロールを手動で割り当てできる導線を削除 `#13529`
- Fix: 埋め込みプレイヤーから外部ページに移動できない問題を修正

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

@ -217,10 +217,9 @@ function showMenu(ev: MouseEvent) {
});
}
const details: MenuItem[] = [];
if ($i?.id === props.audio.userId) {
menu.push({
type: 'divider',
}, {
details.push({
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
@ -228,6 +227,19 @@ function showMenu(ev: MouseEvent) {
});
}
if (iAmModerator) {
details.push({
type: 'link',
text: i18n.ts.moderation,
icon: 'ti ti-photo-exclamation',
to: `/admin/file/${props.audio.id}`,
});
}
if (details.length > 0) {
menu.push({ type: 'divider' }, ...details);
}
if (defaultStore.state.devMode) {
menu.push({ type: 'divider' }, {
icon: 'ti ti-id',

View File

@ -133,10 +133,9 @@ function showMenu(ev: MouseEvent) {
});
}
const details: MenuItem[] = [];
if ($i?.id === props.image.userId) {
menuItems.push({
type: 'divider',
}, {
details.push({
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
@ -144,6 +143,19 @@ function showMenu(ev: MouseEvent) {
});
}
if (iAmModerator) {
details.push({
type: 'link',
text: i18n.ts.moderation,
icon: 'ti ti-photo-exclamation',
to: `/admin/file/${props.image.id}`,
});
}
if (details.length > 0) {
menuItems.push({ type: 'divider' }, ...details);
}
if (defaultStore.state.devMode) {
menuItems.push({ type: 'divider' }, {
icon: 'ti ti-id',

View File

@ -242,10 +242,9 @@ function showMenu(ev: MouseEvent) {
});
}
const details: MenuItem[] = [];
if ($i?.id === props.video.userId) {
menu.push({
type: 'divider',
}, {
details.push({
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
@ -253,6 +252,19 @@ function showMenu(ev: MouseEvent) {
});
}
if (iAmModerator) {
details.push({
type: 'link',
text: i18n.ts.moderation,
icon: 'ti ti-photo-exclamation',
to: `/admin/file/${props.video.id}`,
});
}
if (details.length > 0) {
menu.push({ type: 'divider' }, ...details);
}
if (defaultStore.state.devMode) {
menu.push({ type: 'divider' }, {
icon: 'ti ti-id',

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

@ -109,6 +109,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>
<a style="display: inline-block;" class="pepabo" title="GMO Pepabo" href="https://pepabo.com/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/gmo_pepabo.svg" alt="GMO Pepabo"></a>
</div>
<div>
<a style="display: inline-block;" class="purpledotdigital" title="Purple Dot Digital" href="https://purpledotdigital.com/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/purple-dot-digital.jpg" alt="Purple Dot Digital"></a>
</div>
</div>
</FormSection>
<FormSection>

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