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:" 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;
} }