Merge pull request 'feat: give reason for soft word mutes' (#9815) from amybones/calckey:feat_soft_mute_reasons into develop

Reviewed-on: https://codeberg.org/calckey/calckey/pulls/9815
This commit is contained in:
Kainoa Kanter 2023-04-07 05:07:24 +00:00
commit 1d57a0da15
12 changed files with 133 additions and 74 deletions

View file

@ -612,6 +612,7 @@ regexpError: "Regular Expression error"
regexpErrorDescription: "An error occurred in the regular expression on line {line} of your {tab} word mutes:"
instanceMute: "Instance Mutes"
userSaysSomething: "{name} said something"
userSaysSomethingReason: "{name} said {reason}"
makeActive: "Activate"
display: "Display"
copy: "Copy"

View file

@ -612,6 +612,7 @@ regexpError: "正規表現エラー"
regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが発生しました:"
instanceMute: "インスタンスミュート"
userSaysSomething: "{name}が何かを言いました"
userSaysSomethingReason: "{name}前記{reason}"
makeActive: "アクティブにする"
display: "表示"
copy: "コピー"

View file

@ -5,46 +5,74 @@ import type { User } from "@/models/entities/user.js";
type NoteLike = {
userId: Note["userId"];
text: Note["text"];
cw?: Note["cw"];
};
type UserLike = {
id: User["id"];
};
export async function checkWordMute(
export type Muted = {
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,
me: UserLike | null | undefined,
mutedWords: Array<string | string[]>,
): Promise<boolean> {
): Promise<Muted> {
// 自分自身
if (me && note.userId === me.id) return false;
if (me && note.userId === me.id) {
return NotMuted;
}
if (mutedWords.length > 0) {
const text = ((note.cw ?? "") + "\n" + (note.text ?? "")).trim();
if (text === "") return false;
if (text === "") {
return NotMuted;
}
const matched = mutedWords.some((filter) => {
if (Array.isArray(filter)) {
return filter.every((keyword) => text.includes(keyword));
} else {
// represents RegExp
const regexp = filter.match(/^\/(.+)\/(.*)$/);
for (const mutePattern of mutedWords) {
let mute: RE2;
let matched: string[];
if (Array.isArray(mutePattern)) {
matched = mutePattern.filter((keyword) => keyword !== "");
// This should never happen due to input sanitisation.
if (!regexp) return false;
try {
return new RE2(regexp[1], regexp[2]).test(text);
} catch (err) {
// This should never happen due to input sanitisation.
return false;
if (matched.length === 0) {
continue;
}
mute = new RE2(
`\\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];
}
});
if (matched) return true;
try {
if (mute.test(text)) {
return { muted: true, matched };
}
} catch (err) {
// This should never happen due to input sanitisation.
}
}
}
return false;
return NotMuted;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
<template>
<div
v-if="!muted"
v-if="!muted.muted"
v-show="!isDeleted"
ref="el"
v-hotkey="keymap"
@ -96,13 +96,16 @@
</div>
</article>
</div>
<div v-else class="muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<div v-else class="muted" @click="muted.muted = false">
<I18n :src="i18n.ts.userSaysSomethingReason" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
</template>
<template #reason>
<b>{{ muted.matched.join(", ") }}</b>
</template>
</I18n>
</div>
</template>
@ -126,7 +129,7 @@ import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkVisibility from '@/components/MkVisibility.vue';
import { pleaseLogin } from '@/scripts/please-login';
import { focusPrev, focusNext } from '@/scripts/focus';
import { checkWordMute } from '@/scripts/check-word-mute';
import { getWordMute } from '@/scripts/check-word-mute';
import { useRouter } from '@/router';
import { userPage } from '@/filters/user';
import * as os from '@/os';
@ -184,7 +187,7 @@ const isLong = (appearNote.cw == null && appearNote.text != null && (
));
const collapsed = ref(appearNote.cw == null && isLong);
const isDeleted = ref(false);
const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
const muted = ref(getWordMute(appearNote, $i, defaultStore.state.mutedWords));
const translation = ref(null);
const translating = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).slice(0, 5) : null;

View file

@ -1,6 +1,6 @@
<template>
<div
v-if="!muted"
v-if="!muted.muted"
v-show="!isDeleted"
ref="el"
v-hotkey="keymap"
@ -102,13 +102,16 @@
</article>
<MkNoteSub v-for="note in directReplies" :key="note.id" :note="note" class="reply" :conversation="replies"/>
</div>
<div v-else class="_panel muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<div v-else class="_panel muted" @click="muted.muted = false">
<I18n :src="i18n.ts.userSaysSomethingReason" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
</template>
<template #reason>
<b>{{ muted.matched.join(", ") }}</b>
</template>
</I18n>
</div>
</template>
@ -130,7 +133,7 @@ import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import MkVisibility from '@/components/MkVisibility.vue';
import { pleaseLogin } from '@/scripts/please-login';
import { checkWordMute } from '@/scripts/check-word-mute';
import { getWordMute } from '@/scripts/check-word-mute';
import { userPage } from '@/filters/user';
import { notePage } from '@/filters/note';
import { useRouter } from '@/router';
@ -186,7 +189,7 @@ let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note
const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false);
const isDeleted = ref(false);
const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
const muted = ref(getWordMute(appearNote, $i, defaultStore.state.mutedWords));
const translation = ref(null);
const translating = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).slice(0, 5) : null;

View file

@ -1,41 +1,64 @@
export function checkWordMute(
export type Muted = {
muted: boolean;
matched: string[];
};
const NotMuted = { muted: false, matched: [] };
function escapeRegExp(x: string) {
return x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
}
export function getWordMute(
note: Record<string, any>,
me: Record<string, any> | null | undefined,
mutedWords: Array<string | string[]>,
): boolean {
): Muted {
// 自分自身
if (me && note.userId === me.id) return false;
if (me && note.userId === me.id) {
return NotMuted;
}
if (mutedWords.length > 0) {
const text = ((note.cw ?? "") + "\n" + (note.text ?? "")).trim();
if (text === "") return false;
if (text === "") {
return NotMuted;
}
const matched = mutedWords.some((filter) => {
if (Array.isArray(filter)) {
// Clean up
const filteredFilter = filter.filter((keyword) => keyword !== "");
if (filteredFilter.length === 0) return false;
for (const mutePattern of mutedWords) {
let mute: RegExp;
let matched: string[];
if (Array.isArray(mutePattern)) {
matched = mutePattern.filter((keyword) => keyword !== "");
return filteredFilter.every((keyword) => text.includes(keyword));
} 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;
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];
}
});
if (matched) return true;
try {
if (mute.test(text)) {
return { muted: true, matched };
}
} catch (err) {
// This should never happen due to input sanitisation.
}
}
}
return false;
return NotMuted;
}