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:" regexpErrorDescription: "An error occurred in the regular expression on line {line} of your {tab} word mutes:"
instanceMute: "Instance Mutes" instanceMute: "Instance Mutes"
userSaysSomething: "{name} said something" userSaysSomething: "{name} said something"
userSaysSomethingReason: "{name} said {reason}"
makeActive: "Activate" makeActive: "Activate"
display: "Display" display: "Display"
copy: "Copy" copy: "Copy"

View File

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

View File

@ -5,46 +5,74 @@ import type { User } from "@/models/entities/user.js";
type NoteLike = { type NoteLike = {
userId: Note["userId"]; userId: Note["userId"];
text: Note["text"]; text: Note["text"];
cw?: Note["cw"];
}; };
type UserLike = { type UserLike = {
id: User["id"]; 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, note: NoteLike,
me: UserLike | null | undefined, me: UserLike | null | undefined,
mutedWords: Array<string | string[]>, 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) { if (mutedWords.length > 0) {
const text = ((note.cw ?? "") + "\n" + (note.text ?? "")).trim(); const text = ((note.cw ?? "") + "\n" + (note.text ?? "")).trim();
if (text === "") return false; if (text === "") {
return NotMuted;
}
const matched = mutedWords.some((filter) => { for (const mutePattern of mutedWords) {
if (Array.isArray(filter)) { let mute: RE2;
return filter.every((keyword) => text.includes(keyword)); let matched: string[];
} else { if (Array.isArray(mutePattern)) {
// represents RegExp matched = mutePattern.filter((keyword) => keyword !== "");
const regexp = filter.match(/^\/(.+)\/(.*)$/);
// This should never happen due to input sanitisation. if (matched.length === 0) {
if (!regexp) return false; continue;
try {
return new RE2(regexp[1], regexp[2]).test(text);
} catch (err) {
// This should never happen due to input sanitisation.
return false;
} }
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 Channel from "../channel.js";
import { fetchMeta } from "@/misc/fetch-meta.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 { 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";
@ -60,10 +60,10 @@ export default class extends Channel {
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordMuteを呼んでいる
if ( if (
this.userProfile && this.userProfile &&
(await checkWordMute(note, this.user, this.userProfile.mutedWords)) (await getWordMute(note, this.user, this.userProfile.mutedWords)).muted
) )
return; return;

View File

@ -1,5 +1,5 @@
import Channel from "../channel.js"; 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 { 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";
@ -58,10 +58,10 @@ export default class extends Channel {
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordMuteを呼んでいる
if ( if (
this.userProfile && this.userProfile &&
(await checkWordMute(note, this.user, this.userProfile.mutedWords)) (await getWordMute(note, this.user, this.userProfile.mutedWords)).muted
) )
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 { checkWordMute } from "@/misc/check-word-mute.js"; import { getWordMute } 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";
@ -75,10 +75,10 @@ export default class extends Channel {
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordMuteを呼んでいる
if ( if (
this.userProfile && this.userProfile &&
(await checkWordMute(note, this.user, this.userProfile.mutedWords)) (await getWordMute(note, this.user, this.userProfile.mutedWords)).muted
) )
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 { checkWordMute } from "@/misc/check-word-mute.js"; import { getWordMute } 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";
@ -52,10 +52,10 @@ export default class extends Channel {
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordMuteを呼んでいる
if ( if (
this.userProfile && this.userProfile &&
(await checkWordMute(note, this.user, this.userProfile.mutedWords)) (await getWordMute(note, this.user, this.userProfile.mutedWords)).muted
) )
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 { checkWordMute } from "@/misc/check-word-mute.js"; import { getWordMute } 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";
@ -73,10 +73,10 @@ export default class extends Channel {
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordMuteを呼んでいる
if ( if (
this.userProfile && this.userProfile &&
(await checkWordMute(note, this.user, this.userProfile.mutedWords)) (await getWordMute(note, this.user, this.userProfile.mutedWords)).muted
) )
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 { checkWordMute } from "@/misc/check-word-mute.js"; import { getWordMute } 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";
@ -343,9 +343,9 @@ export default async (
) )
.then((us) => { .then((us) => {
for (const u of us) { for (const u of us) {
checkWordMute(note, { id: u.userId }, u.mutedWords).then( getWordMute(note, { id: u.userId }, u.mutedWords).then(
(shouldMute) => { (shouldMute) => {
if (shouldMute) { if (shouldMute.muted) {
MutedNotes.insert({ MutedNotes.insert({
id: genId(), id: genId(),
userId: u.userId, userId: u.userId,

View File

@ -1,6 +1,6 @@
<template> <template>
<div <div
v-if="!muted" v-if="!muted.muted"
v-show="!isDeleted" v-show="!isDeleted"
ref="el" ref="el"
v-hotkey="keymap" v-hotkey="keymap"
@ -96,13 +96,16 @@
</div> </div>
</article> </article>
</div> </div>
<div v-else class="muted" @click="muted = false"> <div v-else class="muted" @click="muted.muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small"> <I18n :src="i18n.ts.userSaysSomethingReason" tag="small">
<template #name> <template #name>
<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)"> <MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/> <MkUserName :user="appearNote.user"/>
</MkA> </MkA>
</template> </template>
<template #reason>
<b>{{ muted.matched.join(", ") }}</b>
</template>
</I18n> </I18n>
</div> </div>
</template> </template>
@ -126,7 +129,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 { checkWordMute } from '@/scripts/check-word-mute'; import { getWordMute } 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';
@ -184,7 +187,7 @@ const isLong = (appearNote.cw == null && appearNote.text != null && (
)); ));
const collapsed = ref(appearNote.cw == null && isLong); const collapsed = ref(appearNote.cw == null && isLong);
const isDeleted = 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 translation = ref(null);
const translating = ref(false); const translating = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).slice(0, 5) : null; const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).slice(0, 5) : null;

View File

@ -1,6 +1,6 @@
<template> <template>
<div <div
v-if="!muted" v-if="!muted.muted"
v-show="!isDeleted" v-show="!isDeleted"
ref="el" ref="el"
v-hotkey="keymap" v-hotkey="keymap"
@ -102,13 +102,16 @@
</article> </article>
<MkNoteSub v-for="note in directReplies" :key="note.id" :note="note" class="reply" :conversation="replies"/> <MkNoteSub v-for="note in directReplies" :key="note.id" :note="note" class="reply" :conversation="replies"/>
</div> </div>
<div v-else class="_panel muted" @click="muted = false"> <div v-else class="_panel muted" @click="muted.muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small"> <I18n :src="i18n.ts.userSaysSomethingReason" tag="small">
<template #name> <template #name>
<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)"> <MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/> <MkUserName :user="appearNote.user"/>
</MkA> </MkA>
</template> </template>
<template #reason>
<b>{{ muted.matched.join(", ") }}</b>
</template>
</I18n> </I18n>
</div> </div>
</template> </template>
@ -130,7 +133,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 { checkWordMute } from '@/scripts/check-word-mute'; import { getWordMute } 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';
@ -186,7 +189,7 @@ let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note
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(checkWordMute(appearNote, $i, defaultStore.state.mutedWords)); const muted = ref(getWordMute(appearNote, $i, defaultStore.state.mutedWords));
const translation = ref(null); const translation = ref(null);
const translating = ref(false); const translating = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).slice(0, 5) : null; 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>, note: Record<string, any>,
me: Record<string, any> | null | undefined, me: Record<string, any> | null | undefined,
mutedWords: Array<string | string[]>, 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) { if (mutedWords.length > 0) {
const text = ((note.cw ?? "") + "\n" + (note.text ?? "")).trim(); const text = ((note.cw ?? "") + "\n" + (note.text ?? "")).trim();
if (text === "") return false; if (text === "") {
return NotMuted;
}
const matched = mutedWords.some((filter) => { for (const mutePattern of mutedWords) {
if (Array.isArray(filter)) { let mute: RegExp;
// Clean up let matched: string[];
const filteredFilter = filter.filter((keyword) => keyword !== ""); if (Array.isArray(mutePattern)) {
if (filteredFilter.length === 0) return false; matched = mutePattern.filter((keyword) => keyword !== "");
return filteredFilter.every((keyword) => text.includes(keyword)); if (matched.length === 0) {
} else { continue;
// 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;
} }
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;
} }