implement mandatory CW for notes (resolves #910)

This commit is contained in:
Hazelnoot 2025-06-27 22:32:26 -04:00
parent 6f8d831e09
commit 92750240eb
29 changed files with 305 additions and 11 deletions

View file

@ -13,6 +13,11 @@ Displays a placeholder for a muted note.
<!-- If hard muted, we want to hide *everything*, including the placeholders and controls to expand. -->
<div v-else-if="!mute.hardMuted" :class="[$style.muted, mutedClass]" class="_gaps_s" @click.stop="expandNote = true">
<!-- Mandatory CWs -->
<I18n v-if="mute.noteMandatoryCW" :src="i18n.ts.noteIsFlaggedAs" tag="small">
<template #cw>
{{ mute.noteMandatoryCW }}
</template>
</I18n>
<I18n v-if="mute.userMandatoryCW" :src="i18n.ts.userIsFlaggedAs" tag="small">
<template #name>
<MkUserName :user="note.user"/>
@ -84,7 +89,7 @@ const expandNote = ref(false);
const mute = computed(() => checkMute(props.note, props.withHardMute));
const mutedWords = computed(() => mute.value.softMutedWords?.join(', '));
const isMuted = computed(() => mute.value.hardMuted || mutedWords.value || mute.value.userMandatoryCW || mute.value.noteMuted || mute.value.threadMuted || mute.value.sensitiveMuted);
const isMuted = computed(() => mute.value.hardMuted || mutedWords.value || mute.value.noteMandatoryCW || mute.value.userMandatoryCW || mute.value.noteMuted || mute.value.threadMuted || mute.value.sensitiveMuted);
const isExpanded = computed(() => expandNote.value || !isMuted.value);
const rootClass = computed(() => isExpanded.value ? props.expandedClass : undefined);

View file

@ -27,6 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
'markSensitiveDriveFile',
'resetPassword',
'setMandatoryCW',
'setMandatoryCWForNote',
'suspendRemoteInstance',
'setRemoteInstanceNSFW',
'unsetRemoteInstanceNSFW',
@ -79,6 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'unsuspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'resetPassword'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'setMandatoryCW'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'setMandatoryCWForNote'">: @{{ log.info.noteUserUsername }}{{ log.info.noteUserHost ? '@' + log.info.noteUserHost : '' }}</span>
<span v-else-if="log.type === 'assignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} <i class="ti ti-arrow-right"></i> {{ log.info.roleName }}</span>
<span v-else-if="log.type === 'unassignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} <i class="ti ti-equal-not"></i> {{ log.info.roleName }}</span>
<span v-else-if="log.type === 'createRole'">: {{ log.info.role.name }}</span>
@ -208,6 +210,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<CodeDiff :context="0" :hideHeader="true" :oldString="log.info.oldCW ?? ''" :newString="log.info.newCW ?? ''" maxHeight="150px"/>
</div>
</template>
<template v-else-if="log.type === 'setMandatoryCWForNote'">
<div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.noteUserId}`" class="_link">@{{ log.info.noteUserUsername }}{{ log.info.noteUserHost ? '@' + log.info.noteUserHost : '' }}</MkA></div>
<div>{{ i18n.ts.note }}: <MkA :to="`/notes/${log.info.noteId}`" class="_link">{{ log.info.noteId }}</MkA></div>
<div :class="$style.diff">
<CodeDiff :context="0" :hideHeader="true" :oldString="log.info.oldCW ?? ''" :newString="log.info.newCW ?? ''" maxHeight="150px"/>
</div>
</template>
<template v-else-if="log.type === 'unsuspend'">
<div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div>
</template>
@ -323,6 +332,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
<MkUrlPreview v-if="'noteId' in log.info" :url="`${url}/notes/${log.info.noteId}`" :compact="false" :detail="false" :showAsQuote="true"></MkUrlPreview>
<details>
<summary>raw</summary>
<pre>{{ JSON5.stringify(log, null, '\t') }}</pre>
@ -338,6 +349,8 @@ import JSON5 from 'json5';
import { i18n } from '@/i18n.js';
import MkFolder from '@/components/MkFolder.vue';
import SkFetchNote from '@/components/SkFetchNote.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import { url } from '@@/js/config.js';
const props = defineProps<{
log: Misskey.entities.ModerationLog;

View file

@ -18,6 +18,7 @@ export interface Mute {
threadMuted?: boolean;
noteMuted?: boolean;
noteMandatoryCW?: string | null;
// TODO show this as a single block on user timelines
userMandatoryCW?: string | null;
}
@ -32,21 +33,22 @@ export function checkMute(note: Misskey.entities.Note, withHardMute?: boolean):
const threadMuted = note.isMutingThread;
const noteMuted = note.isMutingNote;
const noteMandatoryCW = note.mandatoryCW;
const userMandatoryCW = note.user.mandatoryCW;
// Hard mute
if (withHardMute && isHardMuted(note)) {
return { hardMuted: true, sensitiveMuted, threadMuted, noteMuted, userMandatoryCW };
return { hardMuted: true, sensitiveMuted, threadMuted, noteMuted, noteMandatoryCW, userMandatoryCW };
}
// Soft mute
const softMutedWords = isSoftMuted(note);
if (softMutedWords.length > 0) {
return { softMutedWords, sensitiveMuted, threadMuted, noteMuted, userMandatoryCW };
return { softMutedWords, sensitiveMuted, threadMuted, noteMuted, noteMandatoryCW, userMandatoryCW };
}
// Other / no mute
return { sensitiveMuted, threadMuted, noteMuted, userMandatoryCW };
return { sensitiveMuted, threadMuted, noteMuted, noteMandatoryCW, userMandatoryCW };
}
function isHardMuted(note: Misskey.entities.Note): boolean {

View file

@ -151,6 +151,28 @@ export function getAbuseNoteMenu(note: Misskey.entities.Note, text: string): Men
};
}
export function getMandatoryCWMenu(note: Misskey.entities.Note): MenuItem {
return {
icon: 'ph-warning ph-bold ph-lg',
text: i18n.ts.mandatoryCWForNote,
action: async () => {
const result = await os.inputText({
type: 'text',
title: i18n.ts.mandatoryCWForNote,
text: i18n.ts.mandatoryCWForNoteDescription,
default: note.mandatoryCW ?? '',
});
if (result.canceled) return;
await os.apiWithDialog('admin/cw-note', {
noteId: note.id,
cw: result.result || null,
});
},
};
}
export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string): MenuItem {
return {
icon: 'ti ti-link',
@ -442,6 +464,10 @@ export function getNoteMenu(props: {
if (appearNote.userId !== $i.id) {
menuItems.push({ type: 'divider' });
menuItems.push(getAbuseNoteMenu(appearNote, i18n.ts.reportAbuse));
if ($i.isModerator || $i.isAdmin) {
menuItems.push(getMandatoryCWMenu(appearNote));
}
}
if (appearNote.channel && (appearNote.channel.userId === $i.id || $i.isModerator || $i.isAdmin)) {

View file

@ -28,6 +28,9 @@ export const getNoteSummary = (note?: Misskey.entities.Note | null): string => {
// Append mandatory CW, if applicable
let cw = note.cw;
if (note.mandatoryCW) {
cw = appendContentWarning(cw, note.mandatoryCW);
}
if (note.user.mandatoryCW) {
cw = appendContentWarning(cw, note.user.mandatoryCW);
}