mistykey/packages/frontend/src/utility/check-word-mute.ts

286 lines
8.8 KiB
TypeScript

/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Misskey from 'misskey-js';
import { provide, inject, reactive, computed, unref } from 'vue';
import type { Ref, ComputedRef, Reactive } from 'vue';
import { $i } from '@/i.js';
import { deepAssign } from '@/utility/merge';
export interface Mute {
hasMute: boolean;
hardMuted?: boolean;
softMutedWords?: string[];
sensitiveMuted?: boolean;
userSilenced?: boolean;
instanceSilenced?: boolean;
threadMuted?: boolean;
noteMuted?: boolean;
noteMandatoryCW?: string | null;
userMandatoryCW?: string | null;
instanceMandatoryCW?: string | null;
}
export interface MuteOverrides {
/**
* Allows directly modifying the Mute object for all mutes.
*/
all?: Partial<Omit<Mute, 'hasMute'>>;
/**
* Per instance overrides.
* Key: instance hostname.
*/
instance: Partial<Record<string, Partial<Mute>>>;
/**
* Per user overrides.
* Key: user ID.
*/
user: Partial<Record<string, Partial<Mute>>>;
/**
* Per note overrides.
* Key: note ID.
*/
note: Partial<Record<string, Partial<Mute>>>;
/**
* Per thread overrides.
* Key: thread ID.
*/
thread: Partial<Record<string, Partial<Mute>>>;
}
export const muteOverridesSymbol = Symbol('muteOverrides');
export function useMuteOverrides(): Reactive<MuteOverrides> {
// Re-use the same instance if possible
let overrides = injectMuteOverrides();
if (!overrides) {
overrides = reactive({
note: {},
user: {},
instance: {},
thread: {},
});
provideMuteOverrides(overrides);
}
return overrides;
}
function injectMuteOverrides(): Reactive<MuteOverrides> | null {
return inject(muteOverridesSymbol, null);
}
function provideMuteOverrides(overrides: Reactive<MuteOverrides> | null) {
provide(muteOverridesSymbol, overrides);
}
export function checkMute(note: Misskey.entities.Note | ComputedRef<Misskey.entities.Note>, withHardMute?: boolean | ComputedRef<boolean>, uncollapseCW?: boolean | ComputedRef<boolean>): ComputedRef<Mute> {
// inject() can only be used inside script setup, so it MUST be outside the computed block!
const overrides = injectMuteOverrides();
return computed(() => {
const _note = unref(note);
const _withHardMute = unref(withHardMute) ?? true;
const _uncollapseCW = unref(uncollapseCW) ?? false;
return getMutes(_note, _withHardMute, _uncollapseCW, overrides);
});
}
function getMutes(note: Misskey.entities.Note, withHardMute: boolean, uncollapseCW: boolean, overrides: MuteOverrides | null): Mute {
const override: Partial<Mute> = overrides ? deepAssign(
{},
note.user.host ? overrides.instance[note.user.host] : undefined,
overrides.user[note.user.id],
overrides.thread[note.threadId],
overrides.note[note.id],
overrides.all,
) : {};
const isMe = $i != null && $i.id === note.userId;
const bypassSilence = note.bypassSilence || note.user.bypassSilence;
const hardMuted = override.hardMuted ?? (!isMe && withHardMute && isHardMuted(note));
const softMutedWords = override.softMutedWords ?? (isMe ? [] : isSoftMuted(note));
const sensitiveMuted = override.sensitiveMuted ?? isSensitiveMuted(note);
const userSilenced = override.userSilenced ?? (note.user.isSilenced && !bypassSilence);
const instanceSilenced = override.instanceSilenced ?? (note.user.instance?.isSilenced && !bypassSilence) ?? false;
const threadMuted = override.threadMuted ?? (!isMe && note.isMutingThread);
const noteMuted = override.noteMuted ?? (!isMe && note.isMutingNote);
const noteMandatoryCW = getNoteMandatoryCW(note, isMe, uncollapseCW, override);
const userMandatoryCW = getUserMandatoryCW(note, bypassSilence, uncollapseCW, override);
const instanceMandatoryCW = getInstanceMandatoryCW(note, bypassSilence, uncollapseCW, override);
const hasMute = hardMuted || softMutedWords.length > 0 || sensitiveMuted || userSilenced || instanceSilenced || threadMuted || noteMuted || !!noteMandatoryCW || !!userMandatoryCW || !!instanceMandatoryCW;
return { hasMute, hardMuted, softMutedWords, sensitiveMuted, userSilenced, instanceSilenced, threadMuted, noteMuted, noteMandatoryCW, userMandatoryCW, instanceMandatoryCW };
}
function getNoteMandatoryCW(note: Misskey.entities.Note, isMe: boolean, uncollapseCW: boolean, override: Partial<Mute>): string | null {
if (override.noteMandatoryCW !== undefined) return override.noteMandatoryCW;
if (uncollapseCW) return null;
if (isMe) return null;
return note.mandatoryCW ?? null;
}
function getUserMandatoryCW(note: Misskey.entities.Note, bypassSilence: boolean, uncollapseCW: boolean, override: Partial<Mute>): string | null {
if (override.userMandatoryCW !== undefined) return override.userMandatoryCW;
if (uncollapseCW) return null;
if (bypassSilence) return null;
return note.user.mandatoryCW ?? null;
}
function getInstanceMandatoryCW(note: Misskey.entities.Note, bypassSilence: boolean, uncollapseCW: boolean, override: Partial<Mute>): string | null {
if (override.instanceMandatoryCW !== undefined) return override.instanceMandatoryCW;
if (uncollapseCW) return null;
if (bypassSilence) return null;
return note.user.instance?.mandatoryCW ?? null;
}
function isHardMuted(note: Misskey.entities.Note): boolean {
if (!$i?.hardMutedWords.length) return false;
const inputs = expandNote(note);
return containsMutedWord($i.hardMutedWords, inputs);
}
function isSoftMuted(note: Misskey.entities.Note): string[] {
if (!$i?.mutedWords.length) return [];
const inputs = expandNote(note);
return getMutedWords($i.mutedWords, inputs);
}
function isSensitiveMuted(note: Misskey.entities.Note): boolean {
// 1. At least one sensitive file
if (!note.files) return false;
if (!note.files.some((v) => v.isSensitive)) return false;
// 2. In a timeline
const inTimeline = inject<boolean>('inTimeline', false);
if (!inTimeline) return false;
// 3. With sensitive files hidden
const tl_withSensitive = inject<Ref<boolean> | null>('tl_withSensitive', null);
return tl_withSensitive?.value === false;
}
export function getMutedWords(mutedWords: (string | string[])[], inputs: Iterable<string>): string[] {
// Fixup: string is assignable to Iterable<string>, but doesn't work below.
// As a workaround, we can special-case it to "upgrade" plain strings into arrays instead.
// We also need a noinspection tag, since JetBrains IDEs don't understand this behavior either.
// noinspection SuspiciousTypeOfGuard
if (typeof(inputs) === 'string') {
inputs = [inputs];
}
// Parse mutes
const { regexMutes, patternMutes } = parseMutes(mutedWords);
// Make sure we didn't filter them all out
if (regexMutes.length < 1 && patternMutes.length < 1) {
return [];
}
const matches = new Set<string>();
// Expand notes into searchable test
for (const text of inputs) {
for (const pattern of patternMutes) {
// Case-sensitive, non-boundary search for backwards compatibility
if (pattern.every(word => text.includes(word))) {
const muteLabel = pattern.join(' ');
matches.add(muteLabel);
}
}
for (const regex of regexMutes) {
for (const match of text.matchAll(regex)) {
matches.add(match[0]);
}
}
}
return Array.from(matches);
}
export function containsMutedWord(mutedWords: (string | string[])[], inputs: Iterable<string>): boolean {
// Parse mutes
const { regexMutes, patternMutes } = parseMutes(mutedWords);
// Make sure we didn't filter them all out
if (regexMutes.length < 1 && patternMutes.length < 1) {
return false;
}
// Expand notes into searchable test
for (const text of inputs) {
for (const pattern of patternMutes) {
// Case-sensitive, non-boundary search for backwards compatibility
if (pattern.every(word => text.includes(word))) {
return true;
}
}
if (regexMutes.some(regex => text.match(regex))) {
return true;
}
}
return false;
}
export function *expandNote(note: Misskey.entities.Note): Generator<string> {
if (note.cw) yield note.cw;
if (note.text) yield note.text;
if (note.files) {
for (const file of note.files) {
if (file.comment) yield file.comment;
}
}
if (note.poll) {
for (const choice of note.poll.choices) {
if (choice.text) yield choice.text;
}
}
}
function parseMutes(mutedWords: (string | string[])[]) {
const regexMutes: RegExp[] = [];
const patternMutes: string[][] = [];
for (const mute of mutedWords) {
if (Array.isArray(mute)) {
if (mute.length > 0) {
const filtered = mute.filter(keyword => keyword !== '');
if (filtered.length > 0) {
patternMutes.push(filtered);
} else {
console.warn('Skipping invalid pattern mute:', mute);
}
}
} else {
const parsed = mute.match(/^\/(.+)\/(.*)$/);
if (parsed && parsed.length === 3) {
try {
const flags = parsed[2].includes('g') ? parsed[2] : `${parsed[2]}g`;
regexMutes.push(new RegExp(parsed[1], flags));
} catch {
console.warn('Skipping invalid regexp mute:', mute);
}
}
}
}
return { regexMutes, patternMutes };
}