Merge pull request 'fix: word mutes' (#10034) from naskya/calckey:fix/word-mutes into develop

Reviewed-on: https://codeberg.org/calckey/calckey/pulls/10034
This commit is contained in:
Kainoa Kanter 2023-05-05 20:03:03 +00:00
commit 4496507af2
14 changed files with 199 additions and 114 deletions

View File

@ -668,6 +668,9 @@ regexpErrorDescription: "An error occurred in the regular expression on line {li
instanceMute: "Instance Mutes" instanceMute: "Instance Mutes"
userSaysSomething: "{name} said something" userSaysSomething: "{name} said something"
userSaysSomethingReason: "{name} said {reason}" userSaysSomethingReason: "{name} said {reason}"
userSaysSomethingReasonReply: "{name} replied to a post containing {reason}"
userSaysSomethingReasonRenote: "{name} boosted a post containing {reason}"
userSaysSomethingReasonQuote: "{name} quoted a post containing {reason}"
makeActive: "Activate" makeActive: "Activate"
display: "Display" display: "Display"
copy: "Copy" copy: "Copy"

View File

@ -619,6 +619,9 @@ regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表
instanceMute: "インスタンスミュート" instanceMute: "インスタンスミュート"
userSaysSomething: "{name}が何かを言いました" userSaysSomething: "{name}が何かを言いました"
userSaysSomethingReason: "{name}が{reason}と言いました" userSaysSomethingReason: "{name}が{reason}と言いました"
userSaysSomethingReasonReply: "{name}が{reason}を含む投稿に返信しました"
userSaysSomethingReasonRenote: "{name}が{reason}を含む投稿をブーストしました"
userSaysSomethingReasonQuote: "{name}が{reason}を含む投稿を引用しました"
makeActive: "アクティブにする" makeActive: "アクティブにする"
display: "表示" display: "表示"
copy: "コピー" copy: "コピー"

View File

@ -12,67 +12,63 @@ type UserLike = {
id: User["id"]; id: User["id"];
}; };
export type Muted = { function checkWordMute(
muted: boolean;
matched: string[];
};
const NotMuted = { muted: false, matched: [] };
function escapeRegExp(x: string) {
return x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
}
export async function getWordMute(
note: NoteLike, note: NoteLike,
me: UserLike | null | undefined,
mutedWords: Array<string | string[]>, mutedWords: Array<string | string[]>,
): Promise<Muted> { ): boolean {
// 自分自身 if (note == null) return false;
if (me && note.userId === me.id) {
return NotMuted;
}
if (mutedWords.length > 0) { const text = ((note.cw ?? "") + " " + (note.text ?? "")).trim();
const text = ((note.cw ?? "") + "\n" + (note.text ?? "")).trim(); if (text === "") return false;
if (text === "") { for (const mutePattern of mutedWords) {
return NotMuted; if (Array.isArray(mutePattern)) {
} // Clean up
const keywords = mutePattern.filter((keyword) => keyword !== "");
for (const mutePattern of mutedWords) { if (
let mute: RE2; keywords.length > 0 &&
let matched: string[]; keywords.every((keyword) => text.includes(keyword))
if (Array.isArray(mutePattern)) { )
matched = mutePattern.filter((keyword) => keyword !== ""); return true;
} else {
// represents RegExp
const regexp = mutePattern.match(/^\/(.+)\/(.*)$/);
if (matched.length === 0) { // This should never happen due to input sanitisation.
continue; if (!regexp) {
} console.warn(`Found invalid regex in word mutes: ${mutePattern}`);
mute = new RE2( continue;
`\\b${matched.map(escapeRegExp).join("\\b.*\\b")}\\b`,
"g",
);
} else {
const regexp = mutePattern.match(/^\/(.+)\/(.*)$/);
// This should never happen due to input sanitisation.
if (!regexp) {
console.warn(`Found invalid regex in word mutes: ${mutePattern}`);
continue;
}
mute = new RE2(regexp[1], regexp[2]);
matched = [mutePattern];
} }
try { try {
if (mute.test(text)) { if (new RE2(regexp[1], regexp[2]).test(text)) return true;
return { muted: true, matched };
}
} catch (err) { } catch (err) {
// This should never happen due to input sanitisation. // This should never happen due to input sanitisation.
} }
} }
} }
return NotMuted; return false;
}
export async function getWordHardMute(
note: NoteLike,
me: UserLike | null | undefined,
mutedWords: Array<string | string[]>,
): Promise<boolean> {
// 自分自身
if (me && note.userId === me.id) {
return false;
}
if (mutedWords.length > 0) {
return (
checkWordMute(note, mutedWords) ||
checkWordMute(note.reply, mutedWords) ||
checkWordMute(note.renote, mutedWords)
);
}
return false;
} }

View File

@ -15,7 +15,7 @@ export default class extends Channel {
constructor(id: string, connection: Channel["connection"]) { constructor(id: string, connection: Channel["connection"]) {
super(id, connection); super(id, connection);
this.onNote = this.onNote.bind(this); this.onNote = this.withPackedNote(this.onNote.bind(this));
this.emitTypers = this.emitTypers.bind(this); this.emitTypers = this.emitTypers.bind(this);
} }

View File

@ -1,6 +1,6 @@
import Channel from "../channel.js"; import Channel from "../channel.js";
import { fetchMeta } from "@/misc/fetch-meta.js"; import { fetchMeta } from "@/misc/fetch-meta.js";
import { getWordMute } from "@/misc/check-word-mute.js"; import { getWordHardMute } from "@/misc/check-word-mute.js";
import { isInstanceMuted } from "@/misc/is-instance-muted.js"; import { isInstanceMuted } from "@/misc/is-instance-muted.js";
import { isUserRelated } from "@/misc/is-user-related.js"; import { isUserRelated } from "@/misc/is-user-related.js";
import type { Packed } from "@/misc/schema.js"; import type { Packed } from "@/misc/schema.js";
@ -63,10 +63,10 @@ export default class extends Channel {
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordMuteを呼んでいる // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる
if ( if (
this.userProfile && this.userProfile &&
(await getWordMute(note, this.user, this.userProfile.mutedWords)).muted (await getWordHardMute(note, this.user, this.userProfile.mutedWords))
) )
return; return;

View File

@ -1,5 +1,5 @@
import Channel from "../channel.js"; import Channel from "../channel.js";
import { getWordMute } from "@/misc/check-word-mute.js"; import { getWordHardMute } from "@/misc/check-word-mute.js";
import { isUserRelated } from "@/misc/is-user-related.js"; import { isUserRelated } from "@/misc/is-user-related.js";
import { isInstanceMuted } from "@/misc/is-instance-muted.js"; import { isInstanceMuted } from "@/misc/is-instance-muted.js";
import type { Packed } from "@/misc/schema.js"; import type { Packed } from "@/misc/schema.js";
@ -62,10 +62,10 @@ export default class extends Channel {
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordMuteを呼んでいる // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる
if ( if (
this.userProfile && this.userProfile &&
(await getWordMute(note, this.user, this.userProfile.mutedWords)).muted (await getWordHardMute(note, this.user, this.userProfile.mutedWords))
) )
return; return;

View File

@ -1,6 +1,6 @@
import Channel from "../channel.js"; import Channel from "../channel.js";
import { fetchMeta } from "@/misc/fetch-meta.js"; import { fetchMeta } from "@/misc/fetch-meta.js";
import { getWordMute } from "@/misc/check-word-mute.js"; import { getWordHardMute } from "@/misc/check-word-mute.js";
import { isUserRelated } from "@/misc/is-user-related.js"; import { isUserRelated } from "@/misc/is-user-related.js";
import { isInstanceMuted } from "@/misc/is-instance-muted.js"; import { isInstanceMuted } from "@/misc/is-instance-muted.js";
import type { Packed } from "@/misc/schema.js"; import type { Packed } from "@/misc/schema.js";
@ -79,10 +79,10 @@ export default class extends Channel {
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordMuteを呼んでいる // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる
if ( if (
this.userProfile && this.userProfile &&
(await getWordMute(note, this.user, this.userProfile.mutedWords)).muted (await getWordHardMute(note, this.user, this.userProfile.mutedWords))
) )
return; return;

View File

@ -1,6 +1,6 @@
import Channel from "../channel.js"; import Channel from "../channel.js";
import { fetchMeta } from "@/misc/fetch-meta.js"; import { fetchMeta } from "@/misc/fetch-meta.js";
import { getWordMute } from "@/misc/check-word-mute.js"; import { getWordHardMute } from "@/misc/check-word-mute.js";
import { isUserRelated } from "@/misc/is-user-related.js"; import { isUserRelated } from "@/misc/is-user-related.js";
import type { Packed } from "@/misc/schema.js"; import type { Packed } from "@/misc/schema.js";
@ -55,10 +55,10 @@ export default class extends Channel {
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordMuteを呼んでいる // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる
if ( if (
this.userProfile && this.userProfile &&
(await getWordMute(note, this.user, this.userProfile.mutedWords)).muted (await getWordHardMute(note, this.user, this.userProfile.mutedWords))
) )
return; return;

View File

@ -1,6 +1,6 @@
import Channel from "../channel.js"; import Channel from "../channel.js";
import { fetchMeta } from "@/misc/fetch-meta.js"; import { fetchMeta } from "@/misc/fetch-meta.js";
import { getWordMute } from "@/misc/check-word-mute.js"; import { getWordHardMute } from "@/misc/check-word-mute.js";
import { isUserRelated } from "@/misc/is-user-related.js"; import { isUserRelated } from "@/misc/is-user-related.js";
import { isInstanceMuted } from "@/misc/is-instance-muted.js"; import { isInstanceMuted } from "@/misc/is-instance-muted.js";
import type { Packed } from "@/misc/schema.js"; import type { Packed } from "@/misc/schema.js";
@ -77,10 +77,10 @@ export default class extends Channel {
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordMuteを呼んでいる // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる
if ( if (
this.userProfile && this.userProfile &&
(await getWordMute(note, this.user, this.userProfile.mutedWords)).muted (await getWordHardMute(note, this.user, this.userProfile.mutedWords))
) )
return; return;

View File

@ -53,7 +53,7 @@ import { Poll } from "@/models/entities/poll.js";
import { createNotification } from "../create-notification.js"; import { createNotification } from "../create-notification.js";
import { isDuplicateKeyValueError } from "@/misc/is-duplicate-key-value-error.js"; import { isDuplicateKeyValueError } from "@/misc/is-duplicate-key-value-error.js";
import { checkHitAntenna } from "@/misc/check-hit-antenna.js"; import { checkHitAntenna } from "@/misc/check-hit-antenna.js";
import { getWordMute } from "@/misc/check-word-mute.js"; import { getWordHardMute } from "@/misc/check-word-mute.js";
import { addNoteToAntenna } from "../add-note-to-antenna.js"; import { addNoteToAntenna } from "../add-note-to-antenna.js";
import { countSameRenotes } from "@/misc/count-same-renotes.js"; import { countSameRenotes } from "@/misc/count-same-renotes.js";
import { deliverToRelays } from "../relay.js"; import { deliverToRelays } from "../relay.js";
@ -355,9 +355,9 @@ export default async (
) )
.then((us) => { .then((us) => {
for (const u of us) { for (const u of us) {
getWordMute(note, { id: u.userId }, u.mutedWords).then( getWordHardMute(data, { id: u.userId }, u.mutedWords).then(
(shouldMute) => { (shouldMute) => {
if (shouldMute.muted) { if (shouldMute) {
MutedNotes.insert({ MutedNotes.insert({
id: genId(), id: genId(),
userId: u.userId, userId: u.userId,

View File

@ -198,14 +198,14 @@
</article> </article>
</div> </div>
<div v-else class="muted" @click="muted.muted = false"> <div v-else class="muted" @click="muted.muted = false">
<I18n :src="i18n.ts.userSaysSomethingReason" tag="small"> <I18n :src="softMuteReasonI18nSrc(muted.what)" tag="small">
<template #name> <template #name>
<MkA <MkA
v-user-preview="appearNote.userId" v-user-preview="note.userId"
class="name" class="name"
:to="userPage(appearNote.user)" :to="userPage(note.user)"
> >
<MkUserName :user="appearNote.user" /> <MkUserName :user="note.user" />
</MkA> </MkA>
</template> </template>
<template #reason> <template #reason>
@ -236,7 +236,7 @@ import MkUrlPreview from "@/components/MkUrlPreview.vue";
import MkVisibility from "@/components/MkVisibility.vue"; import MkVisibility from "@/components/MkVisibility.vue";
import { pleaseLogin } from "@/scripts/please-login"; import { pleaseLogin } from "@/scripts/please-login";
import { focusPrev, focusNext } from "@/scripts/focus"; import { focusPrev, focusNext } from "@/scripts/focus";
import { getWordMute } from "@/scripts/check-word-mute"; import { getWordSoftMute } from "@/scripts/check-word-mute";
import { useRouter } from "@/router"; import { useRouter } from "@/router";
import { userPage } from "@/filters/user"; import { userPage } from "@/filters/user";
import * as os from "@/os"; import * as os from "@/os";
@ -261,6 +261,16 @@ const inChannel = inject("inChannel", null);
let note = $ref(deepClone(props.note)); let note = $ref(deepClone(props.note));
const softMuteReasonI18nSrc = (what?: string) => {
if (what === "note") return i18n.ts.userSaysSomethingReason;
if (what === "reply") return i18n.ts.userSaysSomethingReasonReply;
if (what === "renote") return i18n.ts.userSaysSomethingReasonRenote;
if (what === "quote") return i18n.ts.userSaysSomethingReasonQuote;
// I don't think here is reachable, but just in case
return i18n.ts.userSaysSomething;
};
// plugin // plugin
if (noteViewInterruptors.length > 0) { if (noteViewInterruptors.length > 0) {
onMounted(async () => { onMounted(async () => {
@ -291,7 +301,7 @@ let appearNote = $computed(() =>
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(getWordMute(appearNote, $i, defaultStore.state.mutedWords)); const muted = ref(getWordSoftMute(note, $i, defaultStore.state.mutedWords));
const translation = ref(null); const translation = ref(null);
const translating = ref(false); const translating = ref(false);
const enableEmojiReactions = defaultStore.state.enableEmojiReactions; const enableEmojiReactions = defaultStore.state.enableEmojiReactions;

View File

@ -39,14 +39,14 @@
/> />
</div> </div>
<div v-else class="_panel muted" @click="muted.muted = false"> <div v-else class="_panel muted" @click="muted.muted = false">
<I18n :src="i18n.ts.userSaysSomethingReason" tag="small"> <I18n :src="softMuteReasonI18nSrc(muted.what)" tag="small">
<template #name> <template #name>
<MkA <MkA
v-user-preview="appearNote.userId" v-user-preview="note.userId"
class="name" class="name"
:to="userPage(appearNote.user)" :to="userPage(note.user)"
> >
<MkUserName :user="appearNote.user" /> <MkUserName :user="note.user" />
</MkA> </MkA>
</template> </template>
<template #reason> <template #reason>
@ -83,7 +83,7 @@ import MkUrlPreview from "@/components/MkUrlPreview.vue";
import MkInstanceTicker from "@/components/MkInstanceTicker.vue"; import MkInstanceTicker from "@/components/MkInstanceTicker.vue";
import MkVisibility from "@/components/MkVisibility.vue"; import MkVisibility from "@/components/MkVisibility.vue";
import { pleaseLogin } from "@/scripts/please-login"; import { pleaseLogin } from "@/scripts/please-login";
import { getWordMute } from "@/scripts/check-word-mute"; import { getWordSoftMute } from "@/scripts/check-word-mute";
import { userPage } from "@/filters/user"; import { userPage } from "@/filters/user";
import { notePage } from "@/filters/note"; import { notePage } from "@/filters/note";
import { useRouter } from "@/router"; import { useRouter } from "@/router";
@ -110,6 +110,16 @@ const inChannel = inject("inChannel", null);
let note = $ref(deepClone(props.note)); let note = $ref(deepClone(props.note));
const softMuteReasonI18nSrc = (what?: string) => {
if (what === "note") return i18n.ts.userSaysSomethingReason;
if (what === "reply") return i18n.ts.userSaysSomethingReasonReply;
if (what === "renote") return i18n.ts.userSaysSomethingReasonRenote;
if (what === "quote") return i18n.ts.userSaysSomethingReasonQuote;
// I don't think here is reachable, but just in case
return i18n.ts.userSaysSomething;
};
const enableEmojiReactions = defaultStore.state.enableEmojiReactions; const enableEmojiReactions = defaultStore.state.enableEmojiReactions;
// plugin // plugin
@ -142,7 +152,7 @@ let appearNote = $computed(() =>
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(getWordMute(appearNote, $i, defaultStore.state.mutedWords)); const muted = ref(getWordSoftMute(note, $i, defaultStore.state.mutedWords));
const translation = ref(null); const translation = ref(null);
const translating = ref(false); const translating = ref(false);
const urls = appearNote.text const urls = appearNote.text

View File

@ -1,5 +1,6 @@
<template> <template>
<div <div
v-if="!muted.muted || muted.what === 'reply'"
ref="el" ref="el"
v-size="{ max: [450, 500] }" v-size="{ max: [450, 500] }"
class="wrpstxzv" class="wrpstxzv"
@ -161,6 +162,22 @@
</div> </div>
</template> </template>
</div> </div>
<div v-else class="muted" @click="muted.muted = false">
<I18n :src="softMuteReasonI18nSrc(muted.what)" tag="small">
<template #name>
<MkA
v-user-preview="note.userId"
class="name"
:to="userPage(note.user)"
>
<MkUserName :user="note.user" />
</MkA>
</template>
<template #reason>
<b class="_blur_text">{{ muted.matched.join(", ") }}</b>
</template>
</I18n>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -176,10 +193,13 @@ import XRenoteButton from "@/components/MkRenoteButton.vue";
import XQuoteButton from "@/components/MkQuoteButton.vue"; import XQuoteButton from "@/components/MkQuoteButton.vue";
import { pleaseLogin } from "@/scripts/please-login"; import { pleaseLogin } from "@/scripts/please-login";
import { getNoteMenu } from "@/scripts/get-note-menu"; import { getNoteMenu } from "@/scripts/get-note-menu";
import { getWordSoftMute } from "@/scripts/check-word-mute";
import { notePage } from "@/filters/note"; import { notePage } from "@/filters/note";
import { useRouter } from "@/router"; import { useRouter } from "@/router";
import { userPage } from "@/filters/user";
import * as os from "@/os"; import * as os from "@/os";
import { reactionPicker } from "@/scripts/reaction-picker"; import { reactionPicker } from "@/scripts/reaction-picker";
import { $i } from "@/account";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
import { useNoteCapture } from "@/scripts/use-note-capture"; import { useNoteCapture } from "@/scripts/use-note-capture";
import { defaultStore } from "@/store"; import { defaultStore } from "@/store";
@ -206,6 +226,16 @@ const props = withDefaults(
let note = $ref(deepClone(props.note)); let note = $ref(deepClone(props.note));
const softMuteReasonI18nSrc = (what?: string) => {
if (what === "note") return i18n.ts.userSaysSomethingReason;
if (what === "reply") return i18n.ts.userSaysSomethingReasonReply;
if (what === "renote") return i18n.ts.userSaysSomethingReasonRenote;
if (what === "quote") return i18n.ts.userSaysSomethingReasonQuote;
// I don't think here is reachable, but just in case
return i18n.ts.userSaysSomething;
};
const isRenote = const isRenote =
note.renote != null && note.renote != null &&
note.text == null && note.text == null &&
@ -222,6 +252,7 @@ let appearNote = $computed(() =>
isRenote ? (note.renote as misskey.entities.Note) : note isRenote ? (note.renote as misskey.entities.Note) : note
); );
const isDeleted = ref(false); const isDeleted = ref(false);
const muted = ref(getWordSoftMute(note, $i, defaultStore.state.mutedWords));
const translation = ref(null); const translation = ref(null);
const translating = ref(false); const translating = ref(false);
const replies: misskey.entities.Note[] = const replies: misskey.entities.Note[] =
@ -615,4 +646,10 @@ function noteClick(e) {
} }
} }
} }
.muted {
padding: 8px;
text-align: center;
opacity: 0.7;
}
</style> </style>

View File

@ -1,15 +1,58 @@
export type Muted = { export type Muted = {
muted: boolean; muted: boolean;
matched: string[]; matched: string[];
what?: string; // "note" || "reply" || "renote" || "quote"
}; };
const NotMuted = { muted: false, matched: [] }; const NotMuted = { muted: false, matched: [] };
function escapeRegExp(x: string) { function checkWordMute(
return x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string note: NoteLike,
mutedWords: Array<string | string[]>,
): Muted {
const text = ((note.cw ?? "") + " " + (note.text ?? "")).trim();
if (text === "") return NotMuted;
let result = { muted: false, matched: [] };
for (const mutePattern of mutedWords) {
if (Array.isArray(mutePattern)) {
// Clean up
const keywords = mutePattern.filter((keyword) => keyword !== "");
if (
keywords.length > 0 &&
keywords.every((keyword) => text.includes(keyword))
) {
result.muted = true;
result.matched.push(...keywords);
}
} else {
// represents RegExp
const regexp = mutePattern.match(/^\/(.+)\/(.*)$/);
// This should never happen due to input sanitisation.
if (!regexp) {
console.warn(`Found invalid regex in word mutes: ${mutePattern}`);
continue;
}
try {
if (new RegExp(regexp[1], regexp[2]).test(text)) {
result.muted = true;
result.matched.push(mutePattern);
}
} catch (err) {
// This should never happen due to input sanitisation.
}
}
}
result.matched = [...new Set(result.matched)];
return result;
} }
export function getWordMute( export function getWordSoftMute(
note: Record<string, any>, note: Record<string, any>,
me: Record<string, any> | null | undefined, me: Record<string, any> | null | undefined,
mutedWords: Array<string | string[]>, mutedWords: Array<string | string[]>,
@ -20,42 +63,25 @@ export function getWordMute(
} }
if (mutedWords.length > 0) { if (mutedWords.length > 0) {
const text = ((note.cw ?? "") + "\n" + (note.text ?? "")).trim(); let noteMuted = checkWordMute(note, mutedWords);
if (noteMuted.muted) {
if (text === "") { noteMuted.what = "note";
return NotMuted; return noteMuted;
} }
for (const mutePattern of mutedWords) { if (note.renote) {
let mute: RegExp; let renoteMuted = checkWordMute(note.renote, mutedWords);
let matched: string[]; if (renoteMuted.muted) {
if (Array.isArray(mutePattern)) { renoteMuted.what = note.text == null ? "renote" : "quote";
matched = mutePattern.filter((keyword) => keyword !== ""); return renoteMuted;
if (matched.length === 0) {
continue;
}
mute = new RegExp(
`\\b${matched.map(escapeRegExp).join("\\b.*\\b")}\\b`,
"g",
);
} else {
const regexp = mutePattern.match(/^\/(.+)\/(.*)$/);
// This should never happen due to input sanitisation.
if (!regexp) {
console.warn(`Found invalid regex in word mutes: ${mutePattern}`);
continue;
}
mute = new RegExp(regexp[1], regexp[2]);
matched = [mutePattern];
} }
}
try { if (note.reply) {
if (mute.test(text)) { let replyMuted = checkWordMute(note.reply, mutedWords);
return { muted: true, matched }; if (replyMuted.muted) {
} replyMuted.what = "reply";
} catch (err) { return replyMuted;
// This should never happen due to input sanitisation.
} }
} }
} }