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

22
locales/index.d.ts vendored
View file

@ -9212,6 +9212,10 @@ export interface Locale extends ILocale {
* Apply mandatory CW on users
*/
"write:admin:cw-user": string;
/**
* Apply mandatory CW on notes
*/
"write:admin:cw-note": string;
/**
* Silence users
*/
@ -10948,6 +10952,10 @@ export interface Locale extends ILocale {
* Set content warning for user
*/
"setMandatoryCW": string;
/**
* Set content warning for note
*/
"setMandatoryCWForNote": string;
/**
* Set remote instance as NSFW
*/
@ -12088,6 +12096,10 @@ export interface Locale extends ILocale {
* {name} is flagged: "{cw}"
*/
"userIsFlaggedAs": ParameterizedString<"name" | "cw">;
/**
* Note is flagged: "{cw}"
*/
"noteIsFlaggedAs": ParameterizedString<"cw">;
/**
* Mark all media from user as NSFW
*/
@ -13038,9 +13050,17 @@ export interface Locale extends ILocale {
*/
"mandatoryCW": string;
/**
* Applies a content warning to all posts created by this user. If the post already has a CW, then this is appended to the end.
* Applies a content warning to all posts created by this user. The forced warnings will appear like a word mute to distinguish them from the author's own content warnings.
*/
"mandatoryCWDescription": string;
/**
* Add content warning
*/
"mandatoryCWForNote": string;
/**
* Applies an additional content warning to this post. The new warning will appear like a word mute to distinguish it from the author's own content warning.
*/
"mandatoryCWForNoteDescription": string;
/**
* Fetch linked note
*/

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddNoteMandatoryCW1751077195277 {
name = 'AddNoteMandatoryCW1751077195277'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" ADD "mandatoryCW" text`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "mandatoryCW"`);
}
}

View file

@ -127,6 +127,7 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
renoteUserInstance: null,
updatedAt: null,
processErrors: [],
mandatoryCW: null,
...override,
};
}

View file

@ -497,6 +497,9 @@ export class ApRendererService {
let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
// Apply mandatory CW, if applicable
if (note.mandatoryCW) {
summary = appendContentWarning(summary, note.mandatoryCW);
}
if (author.mandatoryCW) {
summary = appendContentWarning(summary, author.mandatoryCW);
}

View file

@ -651,6 +651,7 @@ export class NoteEntityService implements OnModuleInit {
user: packedUsers?.get(note.userId) ?? this.userEntityService.pack(note.user ?? note.userId, me),
text: text,
cw: note.cw,
mandatoryCW: note.mandatoryCW,
visibility: note.visibility,
localOnly: note.localOnly,
reactionAcceptance: note.reactionAcceptance,

View file

@ -23,6 +23,9 @@ export const getNoteSummary = (note: Packed<'Note'>): 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);
}

View file

@ -228,6 +228,15 @@ export class MiNote {
})
public processErrors: string[] | null;
/**
* Specifies a Content Warning that should be forcibly attached to this note.
* Does not replace the user's own CW.
*/
@Column('text', {
nullable: true,
})
public mandatoryCW: string | null;
//#region Denormalized fields
@Column('varchar', {
length: 128, nullable: true,

View file

@ -41,6 +41,10 @@ export const packedNoteSchema = {
type: 'string',
optional: true, nullable: true,
},
mandatoryCW: {
type: 'string',
optional: true, nullable: true,
},
userId: {
type: 'string',
optional: false, nullable: false,

View file

@ -34,6 +34,7 @@ export * as 'admin/avatar-decorations/list' from './endpoints/admin/avatar-decor
export * as 'admin/avatar-decorations/update' from './endpoints/admin/avatar-decorations/update.js';
export * as 'admin/captcha/current' from './endpoints/admin/captcha/current.js';
export * as 'admin/captcha/save' from './endpoints/admin/captcha/save.js';
export * as 'admin/cw-note' from './endpoints/admin/cw-note.js';
export * as 'admin/cw-user' from './endpoints/admin/cw-user.js';
export * as 'admin/decline-user' from './endpoints/admin/decline-user.js';
export * as 'admin/delete-account' from './endpoints/admin/delete-account.js';

View file

@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { MiNote, MiUser, NotesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:cw-note',
} as const;
export const paramDef = {
type: 'object',
properties: {
noteId: { type: 'string', format: 'misskey:id' },
cw: { type: 'string', nullable: true },
},
required: ['noteId', 'cw'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.notesRepository)
private readonly notesRepository: NotesRepository,
private readonly moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
const note = await this.notesRepository.findOneOrFail({
where: { id: ps.noteId },
relations: { user: true },
}) as MiNote & { user: MiUser };
// Skip if there's nothing to do
if (note.mandatoryCW === ps.cw) return;
// Log event first.
// This ensures that we don't "lose" the log if an error occurs
await this.moderationLogService.log(me, 'setMandatoryCWForNote', {
newCW: ps.cw,
oldCW: note.mandatoryCW,
noteId: note.id,
noteUserId: note.user.id,
noteUserUsername: note.user.username,
noteUserHost: note.user.host,
});
await this.notesRepository.update(ps.noteId, {
// Collapse empty strings to null
mandatoryCW: ps.cw || null,
});
});
}
}

View file

@ -46,7 +46,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
await this.usersRepository.update(ps.userId, {
// Collapse empty strings to null
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
mandatoryCW: ps.cw || null,
});

View file

@ -224,7 +224,13 @@ export class MastodonConverters {
// TODO avoid re-packing files for each edit
const files = await this.driveFileEntityService.packManyByIds(edit.fileIds);
const cw = appendContentWarning(edit.cw, noteUser.mandatoryCW) ?? '';
let cw = edit.cw ?? '';
if (note.mandatoryCW) {
cw = appendContentWarning(cw, note.mandatoryCW);
}
if (noteUser.mandatoryCW) {
cw = appendContentWarning(cw, noteUser.mandatoryCW);
}
const isQuote = renote && (edit.cw || edit.newText || edit.fileIds.length > 0 || note.replyId);
const quoteUri = isQuote
@ -299,7 +305,13 @@ export class MastodonConverters {
? quoteUri.then(quote => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quote) ?? escapeMFM(text))
: '';
const cw = appendContentWarning(note.cw, noteUser.mandatoryCW) ?? '';
let cw = note.cw ?? '';
if (note.mandatoryCW) {
cw = appendContentWarning(cw, note.mandatoryCW);
}
if (noteUser.mandatoryCW) {
cw = appendContentWarning(cw, noteUser.mandatoryCW);
}
const reblogged = await this.mastodonDataService.hasReblog(note.id, me);

View file

@ -106,6 +106,7 @@ export const moderationLogTypes = [
'deleteUserAnnouncement',
'resetPassword',
'setMandatoryCW',
'setMandatoryCWForNote',
'setRemoteInstanceNSFW',
'unsetRemoteInstanceNSFW',
'suspendRemoteInstance',
@ -294,6 +295,14 @@ export type ModerationLogPayloads = {
userUsername: string;
userHost: string | null;
};
setMandatoryCWForNote: {
newCW: string | null;
oldCW: string | null;
noteId: string;
noteUserId: string;
noteUserUsername: string;
noteUserHost: string | null;
};
setRemoteInstanceNSFW: {
id: string;
host: string;

View file

@ -65,6 +65,7 @@ describe('NoteCreateService', () => {
renoteUserHost: null,
renoteUserInstance: null,
processErrors: [],
mandatoryCW: null,
};
const poll: IPoll = {

View file

@ -48,6 +48,7 @@ const base: MiNote = {
renoteUserHost: null,
renoteUserInstance: null,
processErrors: [],
mandatoryCW: null,
};
describe('misc:is-renote', () => {

View file

@ -9,6 +9,9 @@ import { appendContentWarning } from '@@/js/append-content-warning.js';
export function computeMergedCw(note: Misskey.entities.Note): string | null {
let cw = note.cw;
if (note.mandatoryCW) {
cw = appendContentWarning(cw, note.mandatoryCW);
}
if (note.user.mandatoryCW) {
cw = appendContentWarning(cw, note.user.mandatoryCW);
}

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);
}

View file

@ -152,6 +152,9 @@ type AdminCaptchaCurrentResponse = operations['admin___captcha___current']['resp
// @public (undocumented)
type AdminCaptchaSaveRequest = operations['admin___captcha___save']['requestBody']['content']['application/json'];
// @public (undocumented)
type AdminCwNoteRequest = operations['admin___cw-note']['requestBody']['content']['application/json'];
// @public (undocumented)
type AdminCwUserRequest = operations['admin___cw-user']['requestBody']['content']['application/json'];
@ -1542,6 +1545,7 @@ declare namespace entities {
AdminAvatarDecorationsUpdateRequest,
AdminCaptchaCurrentResponse,
AdminCaptchaSaveRequest,
AdminCwNoteRequest,
AdminCwUserRequest,
AdminDeclineUserRequest,
AdminDeleteAccountRequest,
@ -3097,7 +3101,7 @@ type ModerationLog = {
});
// @public (undocumented)
export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "approve", "decline", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "setMandatoryCW", "setRemoteInstanceNSFW", "unsetRemoteInstanceNSFW", "suspendRemoteInstance", "unsuspendRemoteInstance", "rejectRemoteInstanceReports", "acceptRemoteInstanceReports", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "forwardAbuseReport", "updateAbuseReportNote", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner", "createSystemWebhook", "updateSystemWebhook", "deleteSystemWebhook", "createAbuseReportNotificationRecipient", "updateAbuseReportNotificationRecipient", "deleteAbuseReportNotificationRecipient", "deleteAccount", "deletePage", "deleteFlash", "deleteGalleryPost", "deleteChatRoom"];
export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "approve", "decline", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "setMandatoryCW", "setMandatoryCWForNote", "setRemoteInstanceNSFW", "unsetRemoteInstanceNSFW", "suspendRemoteInstance", "unsuspendRemoteInstance", "rejectRemoteInstanceReports", "acceptRemoteInstanceReports", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "forwardAbuseReport", "updateAbuseReportNote", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner", "createSystemWebhook", "updateSystemWebhook", "deleteSystemWebhook", "createAbuseReportNotificationRecipient", "updateAbuseReportNotificationRecipient", "deleteAbuseReportNotificationRecipient", "deleteAccount", "deletePage", "deleteFlash", "deleteGalleryPost", "deleteChatRoom"];
// @public (undocumented)
type MuteCreateRequest = operations['mute___create']['requestBody']['content']['application/json'];
@ -3407,7 +3411,7 @@ type PartialRolePolicyOverride = Partial<{
}>;
// @public (undocumented)
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notes-schedule", "write:notes-schedule", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:nsfw-user", "write:admin:unnsfw-user", "write:admin:cw-user", "write:admin:silence-user", "write:admin:unsilence-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:reject-quotes", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse", "write:chat", "read:chat"];
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notes-schedule", "write:notes-schedule", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:nsfw-user", "write:admin:unnsfw-user", "write:admin:cw-user", "write:admin:cw-note", "write:admin:silence-user", "write:admin:unsilence-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:reject-quotes", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse", "write:chat", "read:chat"];
// @public (undocumented)
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];

View file

@ -272,6 +272,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:cw-note*
*/
request<E extends 'admin/cw-note', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View file

@ -38,6 +38,7 @@ import type {
AdminAvatarDecorationsUpdateRequest,
AdminCaptchaCurrentResponse,
AdminCaptchaSaveRequest,
AdminCwNoteRequest,
AdminCwUserRequest,
AdminDeclineUserRequest,
AdminDeleteAccountRequest,
@ -689,6 +690,7 @@ export type Endpoints = {
'admin/avatar-decorations/update': { req: AdminAvatarDecorationsUpdateRequest; res: EmptyResponse };
'admin/captcha/current': { req: EmptyRequest; res: AdminCaptchaCurrentResponse };
'admin/captcha/save': { req: AdminCaptchaSaveRequest; res: EmptyResponse };
'admin/cw-note': { req: AdminCwNoteRequest; res: EmptyResponse };
'admin/cw-user': { req: AdminCwUserRequest; res: EmptyResponse };
'admin/decline-user': { req: AdminDeclineUserRequest; res: EmptyResponse };
'admin/delete-account': { req: AdminDeleteAccountRequest; res: EmptyResponse };

View file

@ -41,6 +41,7 @@ export type AdminAvatarDecorationsListResponse = operations['admin___avatar-deco
export type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json'];
export type AdminCaptchaCurrentResponse = operations['admin___captcha___current']['responses']['200']['content']['application/json'];
export type AdminCaptchaSaveRequest = operations['admin___captcha___save']['requestBody']['content']['application/json'];
export type AdminCwNoteRequest = operations['admin___cw-note']['requestBody']['content']['application/json'];
export type AdminCwUserRequest = operations['admin___cw-user']['requestBody']['content']['application/json'];
export type AdminDeclineUserRequest = operations['admin___decline-user']['requestBody']['content']['application/json'];
export type AdminDeleteAccountRequest = operations['admin___delete-account']['requestBody']['content']['application/json'];

View file

@ -233,6 +233,15 @@ export type paths = {
*/
post: operations['admin___captcha___save'];
};
'/admin/cw-note': {
/**
* admin/cw-note
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:cw-note*
*/
post: operations['admin___cw-note'];
};
'/admin/cw-user': {
/**
* admin/cw-user
@ -4671,6 +4680,7 @@ export type components = {
deletedAt?: string | null;
text: string | null;
cw?: string | null;
mandatoryCW?: string | null;
/** Format: id */
userId: string;
user: components['schemas']['UserLite'];
@ -7389,6 +7399,59 @@ export type operations = {
};
};
};
/**
* admin/cw-note
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:cw-note*
*/
'admin___cw-note': {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
noteId: string;
cw: string | null;
};
};
};
responses: {
/** @description OK (without any results) */
204: {
content: never;
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* admin/cw-user
* @description No description provided.

View file

@ -81,6 +81,7 @@ export const permissions = [
'write:admin:nsfw-user',
'write:admin:unnsfw-user',
'write:admin:cw-user',
'write:admin:cw-note',
'write:admin:silence-user',
'write:admin:unsilence-user',
'write:admin:unset-user-avatar',
@ -148,6 +149,7 @@ export const moderationLogTypes = [
'deleteUserAnnouncement',
'resetPassword',
'setMandatoryCW',
'setMandatoryCWForNote',
'setRemoteInstanceNSFW',
'unsetRemoteInstanceNSFW',
'suspendRemoteInstance',
@ -343,6 +345,14 @@ export type ModerationLogPayloads = {
userUsername: string;
userHost: string | null;
};
setMandatoryCWForNote: {
newCW: string | null;
oldCW: string | null;
noteId: string;
noteUserId: string;
noteUserUsername: string;
noteUserHost: string | null;
};
setRemoteInstanceNSFW: {
id: string;
host: string;

View file

@ -133,6 +133,9 @@ export type ModerationLog = {
} | {
type: 'setMandatoryCW';
info: ModerationLogPayloads['setMandatoryCW'];
} | {
type: 'setMandatoryCWForNote';
info: ModerationLogPayloads['setMandatoryCWForNote'];
} | {
type: 'setRemoteInstanceNSFW';
info: ModerationLogPayloads['setRemoteInstanceNSFW'];

View file

@ -36,6 +36,7 @@ unmuteNote: "Unmute note"
userSaysSomethingInMutedNote: "{name} said something in a muted post"
userSaysSomethingInMutedThread: "{name} said something in a muted thread"
userIsFlaggedAs: "{name} is flagged: \"{cw}\""
noteIsFlaggedAs: "Note is flagged: \"{cw}\""
markAsNSFW: "Mark all media from user as NSFW"
markInstanceAsNSFW: "Mark as NSFW"
nsfwConfirm: "Are you sure that you want to mark all media from this account as NSFW?"
@ -344,6 +345,7 @@ _moderationLogTypes:
approve: "Approved"
decline: "Declined"
setMandatoryCW: "Set content warning for user"
setMandatoryCWForNote: "Set content warning for note"
setRemoteInstanceNSFW: "Set remote instance as NSFW"
unsetRemoteInstanceNSFW: "Unset remote instance as NSFW"
rejectRemoteInstanceReports: "Rejected reports from remote instance"
@ -504,6 +506,7 @@ _permissions:
"write:admin:nsfw-user": "Mark users as NSFW"
"write:admin:unnsfw-user": "Mark users as not NSFW"
"write:admin:cw-user": "Apply mandatory CW on users"
"write:admin:cw-note": "Apply mandatory CW on notes"
"write:admin:silence-user": "Silence users"
"write:admin:unsilence-user": "Un-silence users"
"write:admin:reject-quotes": "Allow/Prohibit quote posts from a user"
@ -542,7 +545,9 @@ _noteSearch:
id: "ID"
mandatoryCW: "Force content warning"
mandatoryCWDescription: "Applies a content warning to all posts created by this user. If the post already has a CW, then this is appended to the end."
mandatoryCWDescription: "Applies a content warning to all posts created by this user. The forced warnings will appear like a word mute to distinguish them from the author's own content warnings."
mandatoryCWForNote: "Force content warning"
mandatoryCWForNoteDescription: "Applies an additional content warning to this post. The new warning will appear like a word mute to distinguish it from the author's own content warning."
fetchLinkedNote: "Fetch linked note"
showTranslationButtonInNoteFooter: "Add \"Translate\" to note action menu"
translationFailed: "Failed to translate note. Please try again later or contact an administrator for assistance."