feat: give reason for soft mutes

Bad UX when a post is muted and it just says "Some chick said something". Now
provide some context too to help people decide if they want to view something
potentially triggering.
This commit is contained in:
amy bones 2023-01-19 18:11:27 -08:00
parent 73b778de2a
commit 15b1109947
No known key found for this signature in database
GPG Key ID: 607951E00F4C0B0F
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));
for (const mutePattern of mutedWords) {
let mute: RE2;
let matched: string[];
if (Array.isArray(mutePattern)) {
matched = mutePattern.filter((keyword) => keyword !== "");
if (matched.length === 0) {
continue;
}
mute = new RE2(
`\\b${matched.map(escapeRegExp).join("\\b.*\\b")}\\b`,
"g",
);
} else {
// represents RegExp
const regexp = filter.match(/^\/(.+)\/(.*)$/);
const regexp = mutePattern.match(/^\/(.+)\/(.*)$/);
// This should never happen due to input sanitisation.
if (!regexp) return false;
if (!regexp) {
console.warn(`Found invalid regex in word mutes: ${mutePattern}`);
continue;
}
mute = new RE2(regexp[1], regexp[2]);
matched = [mutePattern];
}
try {
return new RE2(regexp[1], regexp[2]).test(text);
if (mute.test(text)) {
return { muted: true, matched };
}
} catch (err) {
// This should never happen due to input sanitisation.
return false;
}
}
});
if (matched) return true;
}
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));
if (matched.length === 0) {
continue;
}
mute = new RegExp(
`\\b${matched.map(escapeRegExp).join("\\b.*\\b")}\\b`,
"g",
);
} else {
// represents RegExp
const regexp = filter.match(/^\/(.+)\/(.*)$/);
const regexp = mutePattern.match(/^\/(.+)\/(.*)$/);
// This should never happen due to input sanitisation.
if (!regexp) return false;
if (!regexp) {
console.warn(`Found invalid regex in word mutes: ${mutePattern}`);
continue;
}
mute = new RegExp(regexp[1], regexp[2]);
matched = [mutePattern];
}
try {
return new RegExp(regexp[1], regexp[2]).test(text);
if (mute.test(text)) {
return { muted: true, matched };
}
} catch (err) {
// This should never happen due to input sanitisation.
return false;
}
}
});
if (matched) return true;
}
return false;
return NotMuted;
}