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:
parent
73b778de2a
commit
15b1109947
|
@ -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"
|
||||
|
|
|
@ -612,6 +612,7 @@ regexpError: "正規表現エラー"
|
|||
regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが発生しました:"
|
||||
instanceMute: "インスタンスミュート"
|
||||
userSaysSomething: "{name}が何かを言いました"
|
||||
userSaysSomethingReason: "{name}前記{reason}"
|
||||
makeActive: "アクティブにする"
|
||||
display: "表示"
|
||||
copy: "コピー"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue