merge: Add "force content warning" setting for user moderation (resolves #905) (!876)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/876

Closes #905

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
dakkar 2025-02-20 10:20:49 +00:00
commit 534c35cca2
46 changed files with 843 additions and 108 deletions

20
locales/index.d.ts vendored
View file

@ -8554,6 +8554,10 @@ export interface Locale extends ILocale {
* Mark users an not NSFW * Mark users an not NSFW
*/ */
"write:admin:unnsfw-user": string; "write:admin:unnsfw-user": string;
/**
* Apply mandatory CW on users
*/
"write:admin:cw-user": string;
/** /**
* Silence users * Silence users
*/ */
@ -10214,6 +10218,14 @@ export interface Locale extends ILocale {
* Approved * Approved
*/ */
"approve": string; "approve": string;
/**
* Declined
*/
"decline": string;
/**
* Set content warning for user
*/
"setMandatoryCW": string;
/** /**
* Set remote instance as NSFW * Set remote instance as NSFW
*/ */
@ -12089,6 +12101,14 @@ export interface Locale extends ILocale {
* ID * ID
*/ */
"id": string; "id": string;
/**
* Force content warning
*/
"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.
*/
"mandatoryCWDescription": string;
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View file

@ -0,0 +1,11 @@
export class AddUserMandatoryCW1738043621143 {
name = 'AddUserCW1738043621143'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "mandatoryCW" text`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "mandatoryCW"`);
}
}

View file

@ -228,7 +228,7 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
@bindThis @bindThis
public async create(user: { public async create(user: MiUser & {
id: MiUser['id']; id: MiUser['id'];
username: MiUser['username']; username: MiUser['username'];
host: MiUser['host']; host: MiUser['host'];
@ -435,7 +435,7 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
@bindThis @bindThis
public async import(user: { public async import(user: MiUser & {
id: MiUser['id']; id: MiUser['id'];
username: MiUser['username']; username: MiUser['username'];
host: MiUser['host']; host: MiUser['host'];
@ -486,10 +486,10 @@ export class NoteCreateService implements OnApplicationShutdown {
// should really not happen, but better safe than sorry // should really not happen, but better safe than sorry
if (data.reply?.id === insert.id) { if (data.reply?.id === insert.id) {
throw new Error("A note can't reply to itself"); throw new Error('A note can\'t reply to itself');
} }
if (data.renote?.id === insert.id) { if (data.renote?.id === insert.id) {
throw new Error("A note can't renote itself"); throw new Error('A note can\'t renote itself');
} }
if (data.uri != null) insert.uri = data.uri; if (data.uri != null) insert.uri = data.uri;
@ -552,7 +552,7 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
@bindThis @bindThis
private async postNoteCreated(note: MiNote, user: { private async postNoteCreated(note: MiNote, user: MiUser & {
id: MiUser['id']; id: MiUser['id'];
username: MiUser['username']; username: MiUser['username'];
host: MiUser['host']; host: MiUser['host'];
@ -753,7 +753,7 @@ export class NoteCreateService implements OnApplicationShutdown {
//#region AP deliver //#region AP deliver
if (!data.localOnly && this.userEntityService.isLocalUser(user)) { if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
(async () => { (async () => {
const noteActivity = await this.renderNoteOrRenoteActivity(data, note); const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
// メンションされたリモートユーザーに配送 // メンションされたリモートユーザーに配送
@ -899,12 +899,12 @@ export class NoteCreateService implements OnApplicationShutdown {
} }
@bindThis @bindThis
private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) {
if (data.localOnly) return null; if (data.localOnly) return null;
const content = this.isRenote(data) && !this.isQuote(data) const content = this.isRenote(data) && !this.isQuote(data)
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
: this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, user, false), note);
return this.apRendererService.addContext(content); return this.apRendererService.addContext(content);
} }

View file

@ -224,7 +224,7 @@ export class NoteEditService implements OnApplicationShutdown {
} }
@bindThis @bindThis
public async edit(user: { public async edit(user: MiUser & {
id: MiUser['id']; id: MiUser['id'];
username: MiUser['username']; username: MiUser['username'];
host: MiUser['host']; host: MiUser['host'];
@ -309,7 +309,7 @@ export class NoteEditService implements OnApplicationShutdown {
if (this.isRenote(data)) { if (this.isRenote(data)) {
if (data.renote.id === oldnote.id) { if (data.renote.id === oldnote.id) {
throw new Error("A note can't renote itself"); throw new Error('A note can\'t renote itself');
} }
switch (data.renote.visibility) { switch (data.renote.visibility) {
@ -584,7 +584,7 @@ export class NoteEditService implements OnApplicationShutdown {
} }
@bindThis @bindThis
private async postNoteEdited(note: MiNote, oldNote: MiNote, user: { private async postNoteEdited(note: MiNote, oldNote: MiNote, user: MiUser & {
id: MiUser['id']; id: MiUser['id'];
username: MiUser['username']; username: MiUser['username'];
host: MiUser['host']; host: MiUser['host'];
@ -703,7 +703,7 @@ export class NoteEditService implements OnApplicationShutdown {
//#region AP deliver //#region AP deliver
if (!data.localOnly && this.userEntityService.isLocalUser(user)) { if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
(async () => { (async () => {
const noteActivity = await this.renderNoteOrRenoteActivity(data, note); const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
// メンションされたリモートユーザーに配送 // メンションされたリモートユーザーに配送
@ -834,14 +834,12 @@ export class NoteEditService implements OnApplicationShutdown {
} }
@bindThis @bindThis
private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) {
if (data.localOnly) return null; if (data.localOnly) return null;
const user = await this.usersRepository.findOneBy({ id: note.userId });
if (user == null) throw new Error('user not found');
const content = this.isRenote(data) && !this.isQuote(data) const content = this.isRenote(data) && !this.isQuote(data)
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
: this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, false), user); : this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, user, false), user);
return this.apRendererService.addContext(content); return this.apRendererService.addContext(content);
} }

View file

@ -100,7 +100,7 @@ export class PollService {
if (user == null) throw new Error('note not found'); if (user == null) throw new Error('note not found');
if (this.userEntityService.isLocalUser(user)) { if (this.userEntityService.isLocalUser(user)) {
const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user)); const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, user, false), user));
this.apDeliverManagerService.deliverToFollowers(user, content); this.apDeliverManagerService.deliverToFollowers(user, content);
this.relayService.deliverToRelays(user, content); this.relayService.deliverToRelays(user, content);
} }

View file

@ -99,6 +99,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
signupReason: null, signupReason: null,
noindex: false, noindex: false,
enableRss: true, enableRss: true,
mandatoryCW: null,
...override, ...override,
}; };
} }
@ -216,6 +217,7 @@ function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<'
isSystem: false, isSystem: false,
isSilenced: user.isSilenced, isSilenced: user.isSilenced,
enableRss: true, enableRss: true,
mandatoryCW: null,
...override, ...override,
}; };
} }

View file

@ -28,6 +28,7 @@ import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFil
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { appendContentWarning } from '@/misc/append-content-warning.js';
import { JsonLdService } from './JsonLdService.js'; import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js'; import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js'; import { CONTEXT } from './misc/contexts.js';
@ -339,7 +340,7 @@ export class ApRendererService {
} }
@bindThis @bindThis
public async renderNote(note: MiNote, dive = true): Promise<IPost> { public async renderNote(note: MiNote, author: MiUser, dive = true): Promise<IPost> {
const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => { const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => {
if (ids.length === 0) return []; if (ids.length === 0) return [];
const items = await this.driveFilesRepository.findBy({ id: In(ids) }); const items = await this.driveFilesRepository.findBy({ id: In(ids) });
@ -353,14 +354,14 @@ export class ApRendererService {
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
if (inReplyToNote != null) { if (inReplyToNote != null) {
const inReplyToUserExist = await this.usersRepository.exists({ where: { id: inReplyToNote.userId } }); const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
if (inReplyToUserExist) { if (inReplyToUser) {
if (inReplyToNote.uri) { if (inReplyToNote.uri) {
inReplyTo = inReplyToNote.uri; inReplyTo = inReplyToNote.uri;
} else { } else {
if (dive) { if (dive) {
inReplyTo = await this.renderNote(inReplyToNote, false); inReplyTo = await this.renderNote(inReplyToNote, inReplyToUser, false);
} else { } else {
inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`; inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`;
} }
@ -423,7 +424,12 @@ export class ApRendererService {
apAppend += `\n\nRE: ${quote}`; apAppend += `\n\nRE: ${quote}`;
} }
const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
// Apply mandatory CW, if applicable
if (author.mandatoryCW) {
summary = appendContentWarning(summary, author.mandatoryCW);
}
const { content } = this.apMfmService.getNoteHtml(note, apAppend); const { content } = this.apMfmService.getNoteHtml(note, apAppend);
@ -636,7 +642,7 @@ export class ApRendererService {
} }
@bindThis @bindThis
public async renderUpNote(note: MiNote, dive = true): Promise<IPost> { public async renderUpNote(note: MiNote, author: MiUser, dive = true): Promise<IPost> {
const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => { const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => {
if (ids.length === 0) return []; if (ids.length === 0) return [];
const items = await this.driveFilesRepository.findBy({ id: In(ids) }); const items = await this.driveFilesRepository.findBy({ id: In(ids) });
@ -650,14 +656,14 @@ export class ApRendererService {
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
if (inReplyToNote != null) { if (inReplyToNote != null) {
const inReplyToUserExist = await this.usersRepository.exists({ where: { id: inReplyToNote.userId } }); const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
if (inReplyToUserExist) { if (inReplyToUser) {
if (inReplyToNote.uri) { if (inReplyToNote.uri) {
inReplyTo = inReplyToNote.uri; inReplyTo = inReplyToNote.uri;
} else { } else {
if (dive) { if (dive) {
inReplyTo = await this.renderUpNote(inReplyToNote, false); inReplyTo = await this.renderUpNote(inReplyToNote, inReplyToUser, false);
} else { } else {
inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`; inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`;
} }
@ -720,7 +726,12 @@ export class ApRendererService {
apAppend += `\n\nRE: ${quote}`; apAppend += `\n\nRE: ${quote}`;
} }
const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
// Apply mandatory CW, if applicable
if (author.mandatoryCW) {
summary = appendContentWarning(summary, author.mandatoryCW);
}
const { content } = this.apMfmService.getNoteHtml(note, apAppend); const { content } = this.apMfmService.getNoteHtml(note, apAppend);

View file

@ -209,11 +209,12 @@ export class Resolver {
case 'notes': case 'notes':
return this.notesRepository.findOneByOrFail({ id: parsed.id }) return this.notesRepository.findOneByOrFail({ id: parsed.id })
.then(async note => { .then(async note => {
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
if (parsed.rest === 'activity') { if (parsed.rest === 'activity') {
// this refers to the create activity and not the note itself // this refers to the create activity and not the note itself
return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note), note)); return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author), note));
} else { } else {
return this.apRendererService.renderNote(note); return this.apRendererService.renderNote(note, author);
} }
}); });
case 'users': case 'users':

View file

@ -592,6 +592,7 @@ export class UserEntityService implements OnModuleInit {
isCat: user.isCat, isCat: user.isCat,
noindex: user.noindex, noindex: user.noindex,
enableRss: user.enableRss, enableRss: user.enableRss,
mandatoryCW: user.mandatoryCW,
isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
speakAsCat: user.speakAsCat ?? false, speakAsCat: user.speakAsCat ?? false,
approved: user.approved, approved: user.approved,

View file

@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
/*
* Important Note: this file must be kept in sync with packages/frontend-shared/js/append-content-warning.ts
*/
/**
* Appends an additional content warning onto an existing one.
* The additional value will not be added if it already exists within the original input.
* @param original Existing content warning
* @param additional Content warning to append
* @param reverse If true, then the additional CW will be prepended instead of appended.
*/
export function appendContentWarning(original: string | null | undefined, additional: string, reverse = false): string {
// Easy case - if original is empty, then additional replaces it.
if (!original) {
return additional;
}
// Easy case - if the additional CW is empty, then don't append it.
if (!additional) {
return original;
}
// If the additional CW already exists in the input, then we *don't* append another copy!
if (includesWholeWord(original, additional)) {
return original;
}
return reverse
? `${additional}, ${original}`
: `${original}, ${additional}`;
}
/**
* Emulates a regular expression like /\b(pattern)\b/, but with a raw non-regex pattern.
* We're checking to see whether the default CW appears inside the existing CW, but *only* if there's word boundaries on either side.
* @param input Input string to search
* @param target Target word / phrase to search for
*/
function includesWholeWord(input: string, target: string): boolean {
const parts = input.split(target);
// The additional string could appear multiple times within the original input.
// We need to check each occurrence, since any of them could potentially match.
for (let i = 0; i + 1 < parts.length; i++) {
const before = parts[i];
const after = parts[i + 1];
// If either the preceding or following tokens are a "word", then this "match" is actually just part of a longer word.
// Likewise, if *neither* token is a word, then this is a real match and the CW already exists in the input.
if (!/\w$/.test(before) && !/^\w/.test(after)) {
return true;
}
}
// If we don't match, then there is no existing CW.
return false;
}

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { appendContentWarning } from './append-content-warning.js';
import type { Packed } from './json-schema.js'; import type { Packed } from './json-schema.js';
/** /**
@ -20,9 +21,15 @@ export const getNoteSummary = (note: Packed<'Note'>): string => {
let summary = ''; let summary = '';
// Append mandatory CW, if applicable
let cw = note.cw;
if (note.user.mandatoryCW) {
cw = appendContentWarning(cw, note.user.mandatoryCW);
}
// 本文 // 本文
if (note.cw != null) { if (cw != null) {
summary += `CW: ${note.cw}`; summary += `CW: ${cw}`;
} else if (note.text) { } else if (note.text) {
summary += note.text; summary += note.text;
} }

View file

@ -339,6 +339,15 @@ export class MiUser {
}) })
public enableRss: boolean; public enableRss: boolean;
/**
* Specifies a Content Warning that should be forcibly applied to all notes by this user.
* If null (default), then no Content Warning is applied.
*/
@Column('text', {
nullable: true,
})
public mandatoryCW: string | null;
constructor(data: Partial<MiUser>) { constructor(data: Partial<MiUser>) {
if (data == null) return; if (data == null) return;

View file

@ -134,6 +134,10 @@ export const packedUserLiteSchema = {
type: 'boolean', type: 'boolean',
nullable: false, optional: false, nullable: false, optional: false,
}, },
mandatoryCW: {
type: 'string',
nullable: true, optional: false,
},
isBot: { isBot: {
type: 'boolean', type: 'boolean',
nullable: false, optional: true, nullable: false, optional: true,

View file

@ -103,15 +103,16 @@ export class ActivityPubServerService {
/** /**
* Pack Create<Note> or Announce Activity * Pack Create<Note> or Announce Activity
* @param note Note * @param note Note
* @param author Author of the note
*/ */
@bindThis @bindThis
private async packActivity(note: MiNote): Promise<any> { private async packActivity(note: MiNote, author: MiUser): Promise<any> {
if (isRenote(note) && !isQuote(note)) { if (isRenote(note) && !isQuote(note)) {
const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId });
return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note); return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note);
} }
return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author, false), note);
} }
@bindThis @bindThis
@ -506,7 +507,7 @@ export class ActivityPubServerService {
this.notesRepository.findOneByOrFail({ id: pining.noteId })))) this.notesRepository.findOneByOrFail({ id: pining.noteId }))))
.filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility)); .filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility));
const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note))); const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note, user)));
const rendered = this.apRendererService.renderOrderedCollection( const rendered = this.apRendererService.renderOrderedCollection(
`${this.config.url}/users/${userId}/collections/featured`, `${this.config.url}/users/${userId}/collections/featured`,
@ -579,7 +580,7 @@ export class ActivityPubServerService {
if (sinceId) notes.reverse(); if (sinceId) notes.reverse();
const activities = await Promise.all(notes.map(note => this.packActivity(note))); const activities = await Promise.all(notes.map(note => this.packActivity(note, user)));
const rendered = this.apRendererService.renderOrderedCollectionPage( const rendered = this.apRendererService.renderOrderedCollectionPage(
`${partOf}?${url.query({ `${partOf}?${url.query({
page: 'true', page: 'true',
@ -723,7 +724,9 @@ export class ActivityPubServerService {
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply); this.setResponseType(request, reply);
return this.apRendererService.addContext(await this.apRendererService.renderNote(note, false));
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
return this.apRendererService.addContext(await this.apRendererService.renderNote(note, author, false));
}); });
// note activity // note activity
@ -746,7 +749,9 @@ export class ActivityPubServerService {
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply); this.setResponseType(request, reply);
return (this.apRendererService.addContext(await this.packActivity(note)));
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
return (this.apRendererService.addContext(await this.packActivity(note, author)));
}); });
// outbox // outbox

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/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/current' from './endpoints/admin/captcha/current.js';
export * as 'admin/captcha/save' from './endpoints/admin/captcha/save.js'; export * as 'admin/captcha/save' from './endpoints/admin/captcha/save.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/decline-user' from './endpoints/admin/decline-user.js';
export * as 'admin/delete-account' from './endpoints/admin/delete-account.js'; export * as 'admin/delete-account' from './endpoints/admin/delete-account.js';
export * as 'admin/delete-all-files-of-a-user' from './endpoints/admin/delete-all-files-of-a-user.js'; export * as 'admin/delete-all-files-of-a-user' from './endpoints/admin/delete-all-files-of-a-user.js';

View file

@ -0,0 +1,67 @@
/*
* 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 { UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:cw-user',
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
cw: { type: 'string', nullable: true },
},
required: ['userId', 'cw'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.usersRepository)
private readonly usersRepository: UsersRepository,
private readonly globalEventService: GlobalEventService,
private readonly cacheService: CacheService,
private readonly moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.cacheService.findUserById(ps.userId);
// Skip if there's nothing to do
if (user.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, 'setMandatoryCW', {
newCW: ps.cw,
oldCW: user.mandatoryCW,
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
await this.usersRepository.update(ps.userId, {
// Collapse empty strings to null
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
mandatoryCW: ps.cw || null,
});
// Synchronize caches and other processes
this.globalEventService.publishInternalEvent('localUserUpdated', { id: ps.userId });
});
}
}

View file

@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; import type { UsersRepository, UserProfilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js';
export const meta = { export const meta = {
tags: ['admin'], tags: ['admin'],
@ -28,10 +29,12 @@ export const paramDef = {
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private readonly usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository) @Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository, private readonly userProfilesRepository: UserProfilesRepository,
private readonly cacheService: CacheService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId }); const user = await this.usersRepository.findOneBy({ id: ps.userId });
@ -43,6 +46,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
await this.userProfilesRepository.update(user.id, { await this.userProfilesRepository.update(user.id, {
alwaysMarkNsfw: true, alwaysMarkNsfw: true,
}); });
await this.cacheService.userProfileCache.refresh(ps.userId);
}); });
} }
} }

View file

@ -100,6 +100,7 @@ export const moderationLogTypes = [
'deleteGlobalAnnouncement', 'deleteGlobalAnnouncement',
'deleteUserAnnouncement', 'deleteUserAnnouncement',
'resetPassword', 'resetPassword',
'setMandatoryCW',
'setRemoteInstanceNSFW', 'setRemoteInstanceNSFW',
'unsetRemoteInstanceNSFW', 'unsetRemoteInstanceNSFW',
'suspendRemoteInstance', 'suspendRemoteInstance',
@ -261,6 +262,13 @@ export type ModerationLogPayloads = {
userUsername: string; userUsername: string;
userHost: string | null; userHost: string | null;
}; };
setMandatoryCW: {
newCW: string | null;
oldCW: string | null;
userId: string;
userUsername: string;
userHost: string | null;
};
setRemoteInstanceNSFW: { setRemoteInstanceNSFW: {
id: string; id: string;
host: string; host: string;

View file

@ -3,6 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { IdService } from '@/core/IdService.js';
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
@ -20,7 +22,7 @@ import { CoreModule } from '@/core/CoreModule.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import type { IActor, IApDocument, ICollection, IObject, IPost } from '@/core/activitypub/type.js'; import type { IActor, IApDocument, ICollection, IObject, IPost } from '@/core/activitypub/type.js';
import { MiMeta, MiNote, UserProfilesRepository } from '@/models/_.js'; import { MiMeta, MiNote, MiUser, UserProfilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { secureRndstr } from '@/misc/secure-rndstr.js'; import { secureRndstr } from '@/misc/secure-rndstr.js';
import { DownloadService } from '@/core/DownloadService.js'; import { DownloadService } from '@/core/DownloadService.js';
@ -93,6 +95,7 @@ describe('ActivityPub', () => {
let rendererService: ApRendererService; let rendererService: ApRendererService;
let jsonLdService: JsonLdService; let jsonLdService: JsonLdService;
let resolver: MockResolver; let resolver: MockResolver;
let idService: IdService;
const metaInitial = { const metaInitial = {
cacheRemoteFiles: true, cacheRemoteFiles: true,
@ -140,6 +143,7 @@ describe('ActivityPub', () => {
imageService = app.get<ApImageService>(ApImageService); imageService = app.get<ApImageService>(ApImageService);
jsonLdService = app.get<JsonLdService>(JsonLdService); jsonLdService = app.get<JsonLdService>(JsonLdService);
resolver = new MockResolver(await app.resolve<LoggerService>(LoggerService)); resolver = new MockResolver(await app.resolve<LoggerService>(LoggerService));
idService = app.get<IdService>(IdService);
// Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error // Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error
const federatedInstanceService = app.get<FederatedInstanceService>(FederatedInstanceService); const federatedInstanceService = app.get<FederatedInstanceService>(FederatedInstanceService);
@ -477,4 +481,143 @@ describe('ActivityPub', () => {
}); });
}); });
}); });
describe(ApRendererService, () => {
let note: MiNote;
let author: MiUser;
beforeEach(() => {
author = new MiUser({
id: idService.gen(),
});
note = new MiNote({
id: idService.gen(),
userId: author.id,
visibility: 'public',
localOnly: false,
text: 'Note text',
cw: null,
renoteCount: 0,
repliesCount: 0,
clippedCount: 0,
reactions: {},
fileIds: [],
attachedFileTypes: [],
visibleUserIds: [],
mentions: [],
// This is fucked tbh - it's JSON stored in a TEXT column that gets parsed/serialized all over the place
mentionedRemoteUsers: '[]',
reactionAndUserPairCache: [],
emojis: [],
tags: [],
hasPoll: false,
});
});
describe('renderNote', () => {
describe('summary', () => {
// I actually don't know why it does this, but the logic was already there so I've preserved it.
it('should be zero-width space when CW is empty string', async () => {
note.cw = '';
const result = await rendererService.renderNote(note, author, false);
expect(result.summary).toBe(String.fromCharCode(0x200B));
});
it('should be undefined when CW is null', async () => {
const result = await rendererService.renderNote(note, author, false);
expect(result.summary).toBeUndefined();
});
it('should be CW when present without mandatoryCW', async () => {
note.cw = 'original';
const result = await rendererService.renderNote(note, author, false);
expect(result.summary).toBe('original');
});
it('should be mandatoryCW when present without CW', async () => {
author.mandatoryCW = 'mandatory';
const result = await rendererService.renderNote(note, author, false);
expect(result.summary).toBe('mandatory');
});
it('should be merged when CW and mandatoryCW are both present', async () => {
note.cw = 'original';
author.mandatoryCW = 'mandatory';
const result = await rendererService.renderNote(note, author, false);
expect(result.summary).toBe('original, mandatory');
});
it('should be CW when CW includes mandatoryCW', async () => {
note.cw = 'original and mandatory';
author.mandatoryCW = 'mandatory';
const result = await rendererService.renderNote(note, author, false);
expect(result.summary).toBe('original and mandatory');
});
});
});
describe('renderUpnote', () => {
describe('summary', () => {
// I actually don't know why it does this, but the logic was already there so I've preserved it.
it('should be zero-width space when CW is empty string', async () => {
note.cw = '';
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBe(String.fromCharCode(0x200B));
});
it('should be undefined when CW is null', async () => {
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBeUndefined();
});
it('should be CW when present without mandatoryCW', async () => {
note.cw = 'original';
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBe('original');
});
it('should be mandatoryCW when present without CW', async () => {
author.mandatoryCW = 'mandatory';
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBe('mandatory');
});
it('should be merged when CW and mandatoryCW are both present', async () => {
note.cw = 'original';
author.mandatoryCW = 'mandatory';
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBe('original, mandatory');
});
it('should be CW when CW includes mandatoryCW', async () => {
note.cw = 'original and mandatory';
author.mandatoryCW = 'mandatory';
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBe('original and mandatory');
});
});
});
});
}); });

View file

@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { appendContentWarning } from '@/misc/append-content-warning.js';
describe(appendContentWarning, () => {
it('should return additional when original is null', () => {
const result = appendContentWarning(null, 'additional');
expect(result).toBe('additional');
});
it('should return additional when original is undefined', () => {
const result = appendContentWarning(undefined, 'additional');
expect(result).toBe('additional');
});
it('should return additional when original is empty', () => {
const result = appendContentWarning('', 'additional');
expect(result).toBe('additional');
});
it('should return original when additional is empty', () => {
const result = appendContentWarning('original', '');
expect(result).toBe('original');
});
it('should append additional when it does not exist in original', () => {
const result = appendContentWarning('original', 'additional');
expect(result).toBe('original, additional');
});
it('should append additional when it exists in original but has preceeding word', () => {
const result = appendContentWarning('notadditional', 'additional');
expect(result).toBe('notadditional, additional');
});
it('should append additional when it exists in original but has following word', () => {
const result = appendContentWarning('additionalnot', 'additional');
expect(result).toBe('additionalnot, additional');
});
it('should append additional when it exists in original multiple times but has preceeding or following word', () => {
const result = appendContentWarning('notadditional additionalnot', 'additional');
expect(result).toBe('notadditional additionalnot, additional');
});
it('should not append additional when it exists in original', () => {
const result = appendContentWarning('an additional word', 'additional');
expect(result).toBe('an additional word');
});
it('should not append additional when original starts with it', () => {
const result = appendContentWarning('additional word', 'additional');
expect(result).toBe('additional word');
});
it('should not append additional when original ends with it', () => {
const result = appendContentWarning('an additional', 'additional');
expect(result).toBe('an additional');
});
it('should not append additional when it appears multiple times', () => {
const result = appendContentWarning('an additional additional word', 'additional');
expect(result).toBe('an additional additional word');
});
it('should not append additional when it appears multiple times but some have preceeding or following', () => {
const result = appendContentWarning('a notadditional additional additionalnot word', 'additional');
expect(result).toBe('a notadditional additional additionalnot word');
});
it('should prepend additional when reverse is true', () => {
const result = appendContentWarning('original', 'additional', true);
expect(result).toBe('additional, original');
});
});

View file

@ -46,11 +46,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<EmNoteHeader :note="appearNote" :mini="true"/> <EmNoteHeader :note="appearNote" :mini="true"/>
<EmInstanceTicker v-if="appearNote.user.instance != null" :instance="appearNote.user.instance"/> <EmInstanceTicker v-if="appearNote.user.instance != null" :instance="appearNote.user.instance"/>
<div style="container-type: inline-size;"> <div style="container-type: inline-size;">
<p v-if="appearNote.cw != null" :class="$style.cw"> <p v-if="mergedCW != null" :class="$style.cw">
<EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'" :isBlock="true"/> <EmMfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :author="appearNote.user" :nyaize="'respect'" :isBlock="true"/>
<button style="display: block; width: 100%; margin: 4px 0;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button> <button style="display: block; width: 100%; margin: 4px 0;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
</p> </p>
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> <div v-show="mergedCW == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
<div :class="$style.text"> <div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<EmA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA> <EmA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
@ -109,6 +109,7 @@ import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { shouldCollapsed } from '@@/js/collapsed.js'; import { shouldCollapsed } from '@@/js/collapsed.js';
import { url } from '@@/js/config.js'; import { url } from '@@/js/config.js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import I18n from '@/components/I18n.vue'; import I18n from '@/components/I18n.vue';
import EmNoteSub from '@/components/EmNoteSub.vue'; import EmNoteSub from '@/components/EmNoteSub.vue';
import EmNoteHeader from '@/components/EmNoteHeader.vue'; import EmNoteHeader from '@/components/EmNoteHeader.vue';
@ -154,6 +155,8 @@ const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value
const isLong = shouldCollapsed(appearNote.value, []); const isLong = shouldCollapsed(appearNote.value, []);
const collapsed = ref(appearNote.value.cw == null && isLong); const collapsed = ref(appearNote.value.cw == null && isLong);
const isDeleted = ref(false); const isDeleted = ref(false);
const mergedCW = computed(() => computeMergedCw(appearNote.value));
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -58,11 +58,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</header> </header>
<div :class="[$style.noteContent, { [$style.contentCollapsed]: collapsed }]"> <div :class="[$style.noteContent, { [$style.contentCollapsed]: collapsed }]">
<p v-if="appearNote.cw != null" :class="$style.cw"> <p v-if="mergedCW != null" :class="$style.cw">
<EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'" :isBlock="true"/> <EmMfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :author="appearNote.user" :nyaize="'respect'" :isBlock="true"/>
<button style="display: block; width: 100%; margin: 4px 0;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button> <button style="display: block; width: 100%; margin: 4px 0;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
</p> </p>
<div v-show="appearNote.cw == null || showContent"> <div v-show="mergedCW == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<EmA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA> <EmA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
<EmMfm <EmMfm
@ -130,6 +130,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, inject, ref } from 'vue'; import { computed, inject, ref } from 'vue';
import * as mfm from '@transfem-org/sfm-js'; import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import I18n from '@/components/I18n.vue'; import I18n from '@/components/I18n.vue';
import EmMediaList from '@/components/EmMediaList.vue'; import EmMediaList from '@/components/EmMediaList.vue';
import EmNoteSub from '@/components/EmNoteSub.vue'; import EmNoteSub from '@/components/EmNoteSub.vue';
@ -175,6 +176,8 @@ const isDeleted = ref(false);
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
const isLong = shouldCollapsed(appearNote.value, []); const isLong = shouldCollapsed(appearNote.value, []);
const collapsed = ref(appearNote.value.cw == null && isLong); const collapsed = ref(appearNote.value.cw == null && isLong);
const mergedCW = computed(() => computeMergedCw(appearNote.value));
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -9,11 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.main"> <div :class="$style.main">
<EmNoteHeader :class="$style.header" :note="note" :mini="true"/> <EmNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div> <div>
<p v-if="note.cw != null" :class="$style.cw"> <p v-if="mergedCW != null" :class="$style.cw">
<EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis" :isBlock="true"/> <EmMfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis" :isBlock="true"/>
<button style="display: block; width: 100%;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button> <button style="display: block; width: 100%;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
</p> </p>
<div v-show="note.cw == null || showContent"> <div v-show="mergedCW == null || showContent">
<EmSubNoteContent :class="$style.text" :note="note"/> <EmSubNoteContent :class="$style.text" :note="note"/>
</div> </div>
</div> </div>
@ -22,8 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import EmAvatar from '@/components/EmAvatar.vue'; import EmAvatar from '@/components/EmAvatar.vue';
import EmNoteHeader from '@/components/EmNoteHeader.vue'; import EmNoteHeader from '@/components/EmNoteHeader.vue';
@ -35,6 +36,8 @@ const props = defineProps<{
}>(); }>();
const showContent = ref(false); const showContent = ref(false);
const mergedCW = computed(() => computeMergedCw(props.note));
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -11,11 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.body"> <div :class="$style.body">
<EmNoteHeader :class="$style.header" :note="note" :mini="true"/> <EmNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div> <div>
<p v-if="note.cw != null" :class="$style.cw"> <p v-if="mergedCW != null" :class="$style.cw">
<EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :isBlock="true"/> <EmMfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :author="note.user" :nyaize="'respect'" :isBlock="true"/>
<button style="display: block; width: 100%;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button> <button style="display: block; width: 100%;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
</p> </p>
<div v-show="note.cw == null || showContent"> <div v-show="mergedCW == null || showContent">
<EmSubNoteContent :class="$style.text" :note="note"/> <EmSubNoteContent :class="$style.text" :note="note"/>
</div> </div>
</div> </div>
@ -31,8 +31,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import EmA from '@/components/EmA.vue'; import EmA from '@/components/EmA.vue';
import EmAvatar from '@/components/EmAvatar.vue'; import EmAvatar from '@/components/EmAvatar.vue';
import EmNoteHeader from '@/components/EmNoteHeader.vue'; import EmNoteHeader from '@/components/EmNoteHeader.vue';
@ -55,6 +56,8 @@ const props = withDefaults(defineProps<{
const showContent = ref(false); const showContent = ref(false);
const replies = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]);
const mergedCW = computed(() => computeMergedCw(props.note));
if (props.detail) { if (props.detail) {
misskeyApi('notes/children', { misskeyApi('notes/children', {
noteId: props.note.id, noteId: props.note.id,

View file

@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
/*
* Important Note: this file must be kept in sync with packages/backend/src/misc/append-content-warning.ts
*/
/**
* Appends an additional content warning onto an existing one.
* The additional value will not be added if it already exists within the original input.
* @param original Existing content warning
* @param additional Content warning to append
* @param reverse If true, then the additional CW will be prepended instead of appended.
*/
export function appendContentWarning(original: string | null | undefined, additional: string, reverse = false): string {
// Easy case - if original is empty, then additional replaces it.
if (!original) {
return additional;
}
// Easy case - if the additional CW is empty, then don't append it.
if (!additional) {
return original;
}
// If the additional CW already exists in the input, then we *don't* append another copy!
if (includesWholeWord(original, additional)) {
return original;
}
return reverse
? `${additional}, ${original}`
: `${original}, ${additional}`;
}
/**
* Emulates a regular expression like /\b(pattern)\b/, but with a raw non-regex pattern.
* We're checking to see whether the default CW appears inside the existing CW, but *only* if there's word boundaries on either side.
* @param input Input string to search
* @param target Target word / phrase to search for
*/
function includesWholeWord(input: string, target: string): boolean {
const parts = input.split(target);
// The additional string could appear multiple times within the original input.
// We need to check each occurrence, since any of them could potentially match.
for (let i = 0; i + 1 < parts.length; i++) {
const before = parts[i];
const after = parts[i + 1];
// If either the preceding or following tokens are a "word", then this "match" is actually just part of a longer word.
// Likewise, if *neither* token is a word, then this is a real match and the CW already exists in the input.
if (!/\w$/.test(before) && !/^\w/.test(after)) {
return true;
}
}
// If we don't match, then there is no existing CW.
return false;
}

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Misskey from 'misskey-js';
import { appendContentWarning } from '@@/js/append-content-warning.js';
export function computeMergedCw(note: Misskey.entities.Note): string | null {
let cw = note.cw;
if (note.user.mandatoryCW) {
cw = appendContentWarning(cw, note.user.mandatoryCW);
}
return cw ?? null;
}

View file

@ -59,10 +59,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/> <MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
<div style="container-type: inline-size;"> <div style="container-type: inline-size;">
<bdi> <bdi>
<p v-if="appearNote.cw != null" :class="$style.cw"> <p v-if="mergedCW != null" :class="$style.cw">
<Mfm <Mfm
v-if="appearNote.cw != ''" v-if="mergedCW != ''"
:text="appearNote.cw" :text="mergedCW"
:author="appearNote.user" :author="appearNote.user"
:nyaize="'respect'" :nyaize="'respect'"
:enableEmojiMenu="true" :enableEmojiMenu="true"
@ -71,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only
/> />
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;" @click.stop/> <MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;" @click.stop/>
</p> </p>
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> <div v-show="mergedCW == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
<div :class="$style.text"> <div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> <MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA>
@ -212,6 +212,7 @@ import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js'; import { isLink } from '@@/js/is-link.js';
import { shouldCollapsed } from '@@/js/collapsed.js'; import { shouldCollapsed } from '@@/js/collapsed.js';
import { host } from '@@/js/config.js'; import { host } from '@@/js/config.js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import type { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue';
@ -350,6 +351,8 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
url: `https://${host}/notes/${appearNote.value.id}`, url: `https://${host}/notes/${appearNote.value.id}`,
})); }));
const mergedCW = computed(() => computeMergedCw(appearNote.value));
const renoteTooltip = computeRenoteTooltip(renoted); const renoteTooltip = computeRenoteTooltip(renoted);
/* Overload FunctionLint /* Overload FunctionLint

View file

@ -75,10 +75,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</header> </header>
<div :class="$style.noteContent"> <div :class="$style.noteContent">
<p v-if="appearNote.cw != null" :class="$style.cw"> <p v-if="mergedCW != null" :class="$style.cw">
<Mfm <Mfm
v-if="appearNote.cw != ''" v-if="mergedCW != ''"
:text="appearNote.cw" :text="mergedCW"
:author="appearNote.user" :author="appearNote.user"
:nyaize="'respect'" :nyaize="'respect'"
:enableEmojiMenu="true" :enableEmojiMenu="true"
@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
/> />
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/> <MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/>
</p> </p>
<div v-show="appearNote.cw == null || showContent"> <div v-show="mergedCW == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> <MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA>
<Mfm <Mfm
@ -245,6 +245,7 @@ import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js'; import { isLink } from '@@/js/is-link.js';
import { host } from '@@/js/config.js'; import { host } from '@@/js/config.js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
@ -348,6 +349,8 @@ const quotes = ref<Misskey.entities.Note[]>([]);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
const mergedCW = computed(() => computeMergedCw(appearNote.value));
const renoteTooltip = computeRenoteTooltip(renoted); const renoteTooltip = computeRenoteTooltip(renoted);
watch(() => props.expandAllCws, (expandAllCws) => { watch(() => props.expandAllCws, (expandAllCws) => {

View file

@ -9,11 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.main"> <div :class="$style.main">
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/> <MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div> <div>
<p v-if="note.cw != null" :class="$style.cw"> <p v-if="mergedCW != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> <Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/> <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/>
</p> </p>
<div v-show="note.cw == null || showContent"> <div v-show="mergedCW == null || showContent">
<MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/> <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/>
<div v-if="note.isSchedule" style="margin-top: 10px;"> <div v-if="note.isSchedule" style="margin-top: 10px;">
<MkButton :class="$style.button" inline @click.stop.prevent="editScheduleNote()"><i class="ti ti-eraser"></i> {{ i18n.ts.edit }}</MkButton> <MkButton :class="$style.button" inline @click.stop.prevent="editScheduleNote()"><i class="ti ti-eraser"></i> {{ i18n.ts.edit }}</MkButton>
@ -26,8 +26,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
@ -48,6 +49,8 @@ const props = defineProps<{
let showContent = ref(defaultStore.state.uncollapseCW); let showContent = ref(defaultStore.state.uncollapseCW);
const isDeleted = ref(false); const isDeleted = ref(false);
const mergedCW = computed(() => computeMergedCw(props.note));
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'editScheduleNote'): void; (ev: 'editScheduleNote'): void;
}>(); }>();

View file

@ -11,11 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.body"> <div :class="$style.body">
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/> <MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div :class="$style.content"> <div :class="$style.content">
<p v-if="note.cw != null" :class="$style.cw"> <p v-if="mergedCW != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'"/> <Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :isBlock="true" :author="note.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/> <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
</p> </p>
<div v-show="note.cw == null || showContent"> <div v-show="mergedCW == null || showContent">
<MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/> <MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/>
</div> </div>
</div> </div>
@ -86,6 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref, shallowRef, watch } from 'vue'; import { computed, ref, shallowRef, watch } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
@ -142,6 +143,8 @@ let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities
const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
const replies = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]);
const mergedCW = computed(() => computeMergedCw(appearNote.value));
const isRenote = ( const isRenote = (
props.note.renote != null && props.note.renote != null &&
props.note.text == null && props.note.text == null &&

View file

@ -112,6 +112,7 @@ import * as Misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor'; import insertTextAtCursor from 'insert-text-at-cursor';
import { toASCII } from 'punycode.js'; import { toASCII } from 'punycode.js';
import { host, url } from '@@/js/config.js'; import { host, url } from '@@/js/config.js';
import { appendContentWarning } from '@@/js/append-content-warning.js';
import type { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
import type { PostFormProps } from '@/types/post-form.js'; import type { PostFormProps } from '@/types/post-form.js';
import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue';
@ -373,18 +374,8 @@ if ($i.defaultCW) {
if (!cw.value || $i.defaultCWPriority === 'default') { if (!cw.value || $i.defaultCWPriority === 'default') {
cw.value = $i.defaultCW; cw.value = $i.defaultCW;
} else if ($i.defaultCWPriority !== 'parent') { } else if ($i.defaultCWPriority !== 'parent') {
// This is a fancy way of simulating /\bsearch\b/ without a regular expression. const putDefaultFirst = $i.defaultCWPriority === 'defaultParent';
// We're checking to see whether the default CW appears inside the existing CW, but *only* if there's word boundaries. cw.value = appendContentWarning(cw.value, $i.defaultCW, putDefaultFirst);
const parts = cw.value.split($i.defaultCW);
const hasExistingDefaultCW = parts.length === 2 && !/\w$/.test(parts[0]) && !/^\w/.test(parts[1]);
if (!hasExistingDefaultCW) {
// We need to merge the CWs
if ($i.defaultCWPriority === 'defaultParent') {
cw.value = `${$i.defaultCW}, ${cw.value}`;
} else if ($i.defaultCWPriority === 'parentDefault') {
cw.value = `${cw.value}, ${$i.defaultCW}`;
}
}
} }
// else { do nothing, because existing CW takes priority. } // else { do nothing, because existing CW takes priority. }
} }

View file

@ -62,10 +62,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div :class="[{ [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click.stop="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> <div :class="[{ [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click.stop="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined">
<div style="container-type: inline-size;"> <div style="container-type: inline-size;">
<p v-if="appearNote.cw != null" :class="$style.cw"> <p v-if="mergedCW != null" :class="$style.cw">
<Mfm <Mfm
v-if="appearNote.cw != ''" v-if="mergedCW != ''"
:text="appearNote.cw" :text="mergedCW"
:author="appearNote.user" :author="appearNote.user"
:nyaize="'respect'" :nyaize="'respect'"
:enableEmojiMenu="true" :enableEmojiMenu="true"
@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only
/> />
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;" @click.stop/> <MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;" @click.stop/>
</p> </p>
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> <div v-show="mergedCW == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
<div :class="$style.text"> <div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<Mfm <Mfm
@ -213,6 +213,7 @@ import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js'; import { isLink } from '@@/js/is-link.js';
import { shouldCollapsed } from '@@/js/collapsed.js'; import { shouldCollapsed } from '@@/js/collapsed.js';
import { host } from '@@/js/config.js'; import { host } from '@@/js/config.js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import type { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
import SkNoteSub from '@/components/SkNoteSub.vue'; import SkNoteSub from '@/components/SkNoteSub.vue';
import SkNoteHeader from '@/components/SkNoteHeader.vue'; import SkNoteHeader from '@/components/SkNoteHeader.vue';
@ -345,6 +346,8 @@ const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.
const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false);
const mergedCW = computed(() => computeMergedCw(appearNote.value));
const renoteTooltip = computeRenoteTooltip(renoted); const renoteTooltip = computeRenoteTooltip(renoted);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({

View file

@ -81,10 +81,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</header> </header>
<div :class="$style.noteContent"> <div :class="$style.noteContent">
<p v-if="appearNote.cw != null" :class="$style.cw"> <p v-if="mergedCW != null" :class="$style.cw">
<Mfm <Mfm
v-if="appearNote.cw != ''" v-if="mergedCW != ''"
:text="appearNote.cw" :text="mergedCW"
:author="appearNote.user" :author="appearNote.user"
:nyaize="'respect'" :nyaize="'respect'"
:enableEmojiMenu="true" :enableEmojiMenu="true"
@ -93,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only
/> />
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/> <MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/>
</p> </p>
<div v-show="appearNote.cw == null || showContent"> <div v-show="mergedCW == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<Mfm <Mfm
v-if="appearNote.text" v-if="appearNote.text"
@ -250,6 +250,7 @@ import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js'; import { isLink } from '@@/js/is-link.js';
import { host } from '@@/js/config.js'; import { host } from '@@/js/config.js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import SkNoteSub from '@/components/SkNoteSub.vue'; import SkNoteSub from '@/components/SkNoteSub.vue';
import SkNoteSimple from '@/components/SkNoteSimple.vue'; import SkNoteSimple from '@/components/SkNoteSimple.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
@ -354,6 +355,8 @@ const quotes = ref<Misskey.entities.Note[]>([]);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
const mergedCW = computed(() => computeMergedCw(appearNote.value));
const renoteTooltip = computeRenoteTooltip(renoted); const renoteTooltip = computeRenoteTooltip(renoted);
watch(() => props.expandAllCws, (expandAllCws) => { watch(() => props.expandAllCws, (expandAllCws) => {

View file

@ -9,11 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.main"> <div :class="$style.main">
<MkNoteHeader :class="$style.header" :classic="true" :note="note" :mini="true"/> <MkNoteHeader :class="$style.header" :classic="true" :note="note" :mini="true"/>
<div> <div>
<p v-if="note.cw != null" :class="$style.cw"> <p v-if="mergedCW != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> <Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/> <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/>
</p> </p>
<div v-show="note.cw == null || showContent"> <div v-show="mergedCW == null || showContent">
<MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/> <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/>
</div> </div>
</div> </div>
@ -22,8 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { watch, ref } from 'vue'; import { watch, ref, computed } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
import MkCwButton from '@/components/MkCwButton.vue'; import MkCwButton from '@/components/MkCwButton.vue';
@ -37,6 +38,8 @@ const props = defineProps<{
let showContent = ref(defaultStore.state.uncollapseCW); let showContent = ref(defaultStore.state.uncollapseCW);
const mergedCW = computed(() => computeMergedCw(props.note));
watch(() => props.expandAllCws, (expandAllCws) => { watch(() => props.expandAllCws, (expandAllCws) => {
if (expandAllCws !== showContent.value) showContent.value = expandAllCws; if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
}); });

View file

@ -19,11 +19,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.body"> <div :class="$style.body">
<SkNoteHeader :class="$style.header" :note="note" :classic="true" :mini="true"/> <SkNoteHeader :class="$style.header" :note="note" :classic="true" :mini="true"/>
<div :class="$style.content"> <div :class="$style.content">
<p v-if="note.cw != null" :class="$style.cw"> <p v-if="mergedCW != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'"/> <Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :isBlock="true" :author="note.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/> <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
</p> </p>
<div v-show="note.cw == null || showContent"> <div v-show="mergedCW == null || showContent">
<MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/> <MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/>
</div> </div>
</div> </div>
@ -94,6 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref, shallowRef, watch } from 'vue'; import { computed, ref, shallowRef, watch } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import SkNoteHeader from '@/components/SkNoteHeader.vue'; import SkNoteHeader from '@/components/SkNoteHeader.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
@ -156,6 +157,8 @@ let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities
const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
const replies = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]);
const mergedCW = computed(() => computeMergedCw(appearNote.value));
const isRenote = ( const isRenote = (
props.note.renote != null && props.note.renote != null &&
props.note.text == null && props.note.text == null &&

View file

@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkKeyValue> </MkKeyValue>
</div> </div>
<MkTextarea v-model="moderationNote" manualSave> <MkTextarea v-model="moderationNote" manualSave @update:modelValue="onModerationNoteChanged">
<template #label>{{ i18n.ts.moderationNote }}</template> <template #label>{{ i18n.ts.moderationNote }}</template>
<template #caption>{{ i18n.ts.moderationNoteDescription }}</template> <template #caption>{{ i18n.ts.moderationNoteDescription }}</template>
</MkTextarea> </MkTextarea>
@ -83,6 +83,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-if="!isSystem" v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch> <MkSwitch v-if="!isSystem" v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch>
<MkSwitch v-model="markedAsNSFW" @update:modelValue="toggleNSFW">{{ i18n.ts.markAsNSFW }}</MkSwitch> <MkSwitch v-model="markedAsNSFW" @update:modelValue="toggleNSFW">{{ i18n.ts.markAsNSFW }}</MkSwitch>
<MkInput v-model="mandatoryCW" type="text" manualSave @update:modelValue="onMandatoryCWChanged">
<template #label>{{ i18n.ts.mandatoryCW }}</template>
<template #caption>{{ i18n.ts.mandatoryCWDescription }}</template>
</MkInput>
<div> <div>
<MkButton v-if="user.host == null && !isSystem" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</MkButton> <MkButton v-if="user.host == null && !isSystem" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</MkButton>
</div> </div>
@ -222,6 +227,7 @@ import { i18n } from '@/i18n.js';
import { iAmAdmin, $i, iAmModerator } from '@/account.js'; import { iAmAdmin, $i, iAmModerator } from '@/account.js';
import MkRolePreview from '@/components/MkRolePreview.vue'; import MkRolePreview from '@/components/MkRolePreview.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import MkInput from '@/components/MkInput.vue';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
userId: string; userId: string;
@ -243,6 +249,7 @@ const approved = ref(false);
const suspended = ref(false); const suspended = ref(false);
const markedAsNSFW = ref(false); const markedAsNSFW = ref(false);
const moderationNote = ref(''); const moderationNote = ref('');
const mandatoryCW = ref<string | null>(null);
const isSystem = computed(() => info.value?.isSystem ?? false); const isSystem = computed(() => info.value?.isSystem ?? false);
const filesPagination = { const filesPagination = {
endpoint: 'admin/drive/files' as const, endpoint: 'admin/drive/files' as const,
@ -281,11 +288,7 @@ function createFetcher() {
markedAsNSFW.value = info.value.alwaysMarkNsfw; markedAsNSFW.value = info.value.alwaysMarkNsfw;
suspended.value = info.value.isSuspended; suspended.value = info.value.isSuspended;
moderationNote.value = info.value.moderationNote; moderationNote.value = info.value.moderationNote;
mandatoryCW.value = user.value.mandatoryCW;
watch(moderationNote, async () => {
await misskeyApi('admin/update-user-note', { userId: user.value.id, text: moderationNote.value });
await refreshUser();
});
}); });
} }
@ -293,6 +296,16 @@ function refreshUser() {
init.value = createFetcher(); init.value = createFetcher();
} }
async function onMandatoryCWChanged(value: string) {
await os.apiWithDialog('admin/cw-user', { userId: props.userId, cw: value });
refreshUser();
}
async function onModerationNoteChanged(value: string) {
await misskeyApi('admin/update-user-note', { userId: props.userId, text: value });
refreshUser();
}
async function updateRemoteUser() { async function updateRemoteUser() {
await os.apiWithDialog('federation/update-remote-user', { userId: user.value.id }); await os.apiWithDialog('federation/update-remote-user', { userId: user.value.id });
refreshUser(); refreshUser();

View file

@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.logYellow]: [ [$style.logYellow]: [
'markSensitiveDriveFile', 'markSensitiveDriveFile',
'resetPassword', 'resetPassword',
'setMandatoryCW',
'suspendRemoteInstance', 'suspendRemoteInstance',
'setRemoteInstanceNSFW', 'setRemoteInstanceNSFW',
'unsetRemoteInstanceNSFW', 'unsetRemoteInstanceNSFW',
@ -55,6 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'decline'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> <span v-else-if="log.type === 'decline'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'unsuspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> <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 === '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 === '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 === '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 === '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> <span v-else-if="log.type === 'createRole'">: {{ log.info.role.name }}</span>
@ -93,7 +95,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'deleteFlash'">: @{{ log.info.flashUserUsername }}</span> <span v-else-if="log.type === 'deleteFlash'">: @{{ log.info.flashUserUsername }}</span>
<span v-else-if="log.type === 'deleteGalleryPost'">: @{{ log.info.postUserUsername }}</span> <span v-else-if="log.type === 'deleteGalleryPost'">: @{{ log.info.postUserUsername }}</span>
</template> </template>
<template #icon> <template v-if="log.user" #icon>
<MkAvatar :user="log.user" :class="$style.avatar"/> <MkAvatar :user="log.user" :class="$style.avatar"/>
</template> </template>
<template #suffix> <template #suffix>
@ -123,6 +125,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-else-if="log.type === 'approve'"> <template v-else-if="log.type === 'approve'">
<div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div> <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> </template>
<template v-else-if="log.type === 'setMandatoryCW'">
<div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</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'"> <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> <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> </template>

View file

@ -4,6 +4,7 @@
*/ */
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { appendContentWarning } from '@@/js/append-content-warning.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
/** /**
@ -25,9 +26,15 @@ export const getNoteSummary = (note?: Misskey.entities.Note | null): string => {
let summary = ''; let summary = '';
// Append mandatory CW, if applicable
let cw = note.cw;
if (note.user.mandatoryCW) {
cw = appendContentWarning(cw, note.user.mandatoryCW);
}
// 本文 // 本文
if (note.cw != null) { if (cw != null) {
summary += `CW: ${note.cw}`; summary += `CW: ${cw}`;
} else if (note.text) { } else if (note.text) {
summary += note.text; summary += note.text;
} }

View file

@ -146,6 +146,9 @@ type AdminCaptchaCurrentResponse = operations['admin___captcha___current']['resp
// @public (undocumented) // @public (undocumented)
type AdminCaptchaSaveRequest = operations['admin___captcha___save']['requestBody']['content']['application/json']; type AdminCaptchaSaveRequest = operations['admin___captcha___save']['requestBody']['content']['application/json'];
// @public (undocumented)
type AdminCwUserRequest = operations['admin___cw-user']['requestBody']['content']['application/json'];
// @public (undocumented) // @public (undocumented)
type AdminDeclineUserRequest = operations['admin___decline-user']['requestBody']['content']['application/json']; type AdminDeclineUserRequest = operations['admin___decline-user']['requestBody']['content']['application/json'];
@ -1307,6 +1310,7 @@ declare namespace entities {
AdminAvatarDecorationsUpdateRequest, AdminAvatarDecorationsUpdateRequest,
AdminCaptchaCurrentResponse, AdminCaptchaCurrentResponse,
AdminCaptchaSaveRequest, AdminCaptchaSaveRequest,
AdminCwUserRequest,
AdminDeclineUserRequest, AdminDeclineUserRequest,
AdminDeleteAccountRequest, AdminDeleteAccountRequest,
AdminDeleteAllFilesOfAUserRequest, AdminDeleteAllFilesOfAUserRequest,
@ -2609,6 +2613,15 @@ type ModerationLog = {
} | { } | {
type: 'deleteUserAnnouncement'; type: 'deleteUserAnnouncement';
info: ModerationLogPayloads['deleteUserAnnouncement']; info: ModerationLogPayloads['deleteUserAnnouncement'];
} | {
type: 'setMandatoryCW';
info: ModerationLogPayloads['setMandatoryCW'];
} | {
type: 'setRemoteInstanceNSFW';
info: ModerationLogPayloads['setRemoteInstanceNSFW'];
} | {
type: 'unsetRemoteInstanceNSFW';
info: ModerationLogPayloads['unsetRemoteInstanceNSFW'];
} | { } | {
type: 'resetPassword'; type: 'resetPassword';
info: ModerationLogPayloads['resetPassword']; info: ModerationLogPayloads['resetPassword'];
@ -2618,6 +2631,12 @@ type ModerationLog = {
} | { } | {
type: 'unsuspendRemoteInstance'; type: 'unsuspendRemoteInstance';
info: ModerationLogPayloads['unsuspendRemoteInstance']; info: ModerationLogPayloads['unsuspendRemoteInstance'];
} | {
type: 'rejectRemoteInstanceReports';
info: ModerationLogPayloads['rejectRemoteInstanceReports'];
} | {
type: 'acceptRemoteInstanceReports';
info: ModerationLogPayloads['acceptRemoteInstanceReports'];
} | { } | {
type: 'updateRemoteInstanceNote'; type: 'updateRemoteInstanceNote';
info: ModerationLogPayloads['updateRemoteInstanceNote']; info: ModerationLogPayloads['updateRemoteInstanceNote'];
@ -2696,7 +2715,7 @@ type ModerationLog = {
}); });
// @public (undocumented) // @public (undocumented)
export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "approve", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "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"]; 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"];
// @public (undocumented) // @public (undocumented)
type MuteCreateRequest = operations['mute___create']['requestBody']['content']['application/json']; type MuteCreateRequest = operations['mute___create']['requestBody']['content']['application/json'];
@ -3006,7 +3025,7 @@ type PartialRolePolicyOverride = Partial<{
}>; }>;
// @public (undocumented) // @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:silence-user", "write:admin:unsilence-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "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"]; 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: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"];
// @public (undocumented) // @public (undocumented)
type PingResponse = operations['ping']['responses']['200']['content']['application/json']; type PingResponse = operations['ping']['responses']['200']['content']['application/json'];

View file

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

View file

@ -38,6 +38,7 @@ import type {
AdminAvatarDecorationsUpdateRequest, AdminAvatarDecorationsUpdateRequest,
AdminCaptchaCurrentResponse, AdminCaptchaCurrentResponse,
AdminCaptchaSaveRequest, AdminCaptchaSaveRequest,
AdminCwUserRequest,
AdminDeclineUserRequest, AdminDeclineUserRequest,
AdminDeleteAccountRequest, AdminDeleteAccountRequest,
AdminDeleteAllFilesOfAUserRequest, AdminDeleteAllFilesOfAUserRequest,
@ -633,6 +634,7 @@ export type Endpoints = {
'admin/avatar-decorations/update': { req: AdminAvatarDecorationsUpdateRequest; res: EmptyResponse }; 'admin/avatar-decorations/update': { req: AdminAvatarDecorationsUpdateRequest; res: EmptyResponse };
'admin/captcha/current': { req: EmptyRequest; res: AdminCaptchaCurrentResponse }; 'admin/captcha/current': { req: EmptyRequest; res: AdminCaptchaCurrentResponse };
'admin/captcha/save': { req: AdminCaptchaSaveRequest; res: EmptyResponse }; 'admin/captcha/save': { req: AdminCaptchaSaveRequest; res: EmptyResponse };
'admin/cw-user': { req: AdminCwUserRequest; res: EmptyResponse };
'admin/decline-user': { req: AdminDeclineUserRequest; res: EmptyResponse }; 'admin/decline-user': { req: AdminDeclineUserRequest; res: EmptyResponse };
'admin/delete-account': { req: AdminDeleteAccountRequest; res: EmptyResponse }; 'admin/delete-account': { req: AdminDeleteAccountRequest; res: EmptyResponse };
'admin/delete-all-files-of-a-user': { req: AdminDeleteAllFilesOfAUserRequest; res: EmptyResponse }; 'admin/delete-all-files-of-a-user': { req: AdminDeleteAllFilesOfAUserRequest; 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 AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json'];
export type AdminCaptchaCurrentResponse = operations['admin___captcha___current']['responses']['200']['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 AdminCaptchaSaveRequest = operations['admin___captcha___save']['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 AdminDeclineUserRequest = operations['admin___decline-user']['requestBody']['content']['application/json'];
export type AdminDeleteAccountRequest = operations['admin___delete-account']['requestBody']['content']['application/json']; export type AdminDeleteAccountRequest = operations['admin___delete-account']['requestBody']['content']['application/json'];
export type AdminDeleteAllFilesOfAUserRequest = operations['admin___delete-all-files-of-a-user']['requestBody']['content']['application/json']; export type AdminDeleteAllFilesOfAUserRequest = operations['admin___delete-all-files-of-a-user']['requestBody']['content']['application/json'];

View file

@ -233,6 +233,15 @@ export type paths = {
*/ */
post: operations['admin___captcha___save']; post: operations['admin___captcha___save'];
}; };
'/admin/cw-user': {
/**
* admin/cw-user
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:cw-user*
*/
post: operations['admin___cw-user'];
};
'/admin/decline-user': { '/admin/decline-user': {
/** /**
* admin/decline-user * admin/decline-user
@ -3968,6 +3977,7 @@ export type components = {
isSystem?: boolean; isSystem?: boolean;
noindex: boolean; noindex: boolean;
enableRss: boolean; enableRss: boolean;
mandatoryCW: string | null;
isBot?: boolean; isBot?: boolean;
isCat?: boolean; isCat?: boolean;
speakAsCat?: boolean; speakAsCat?: boolean;
@ -6887,6 +6897,59 @@ export type operations = {
}; };
}; };
}; };
/**
* admin/cw-user
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:cw-user*
*/
'admin___cw-user': {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
userId: 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/decline-user * admin/decline-user
* @description No description provided. * @description No description provided.

View file

@ -83,6 +83,7 @@ export const permissions = [
'write:admin:decline-user', 'write:admin:decline-user',
'write:admin:nsfw-user', 'write:admin:nsfw-user',
'write:admin:unnsfw-user', 'write:admin:unnsfw-user',
'write:admin:cw-user',
'write:admin:silence-user', 'write:admin:silence-user',
'write:admin:unsilence-user', 'write:admin:unsilence-user',
'write:admin:unset-user-avatar', 'write:admin:unset-user-avatar',
@ -124,6 +125,7 @@ export const moderationLogTypes = [
'updateServerSettings', 'updateServerSettings',
'suspend', 'suspend',
'approve', 'approve',
'decline',
'unsuspend', 'unsuspend',
'updateUserNote', 'updateUserNote',
'addCustomEmoji', 'addCustomEmoji',
@ -145,8 +147,13 @@ export const moderationLogTypes = [
'deleteGlobalAnnouncement', 'deleteGlobalAnnouncement',
'deleteUserAnnouncement', 'deleteUserAnnouncement',
'resetPassword', 'resetPassword',
'setMandatoryCW',
'setRemoteInstanceNSFW',
'unsetRemoteInstanceNSFW',
'suspendRemoteInstance', 'suspendRemoteInstance',
'unsuspendRemoteInstance', 'unsuspendRemoteInstance',
'rejectRemoteInstanceReports',
'acceptRemoteInstanceReports',
'updateRemoteInstanceNote', 'updateRemoteInstanceNote',
'markSensitiveDriveFile', 'markSensitiveDriveFile',
'unmarkSensitiveDriveFile', 'unmarkSensitiveDriveFile',
@ -186,7 +193,14 @@ export const reversiUpdateKeys = [
export type ReversiUpdateKey = typeof reversiUpdateKeys[number]; export type ReversiUpdateKey = typeof reversiUpdateKeys[number];
type AvatarDecoration = UserLite['avatarDecorations'][number]; interface AvatarDecoration {
id: string;
updatedAt: string | null;
url: string;
name: string;
description: string;
roleIdsThatCanBeUsedThisDecoration: string[];
}
type ReceivedAbuseReport = { type ReceivedAbuseReport = {
reportId: AbuseReportNotificationRecipient['id']; reportId: AbuseReportNotificationRecipient['id'];
@ -322,6 +336,21 @@ export type ModerationLogPayloads = {
userUsername: string; userUsername: string;
userHost: string | null; userHost: string | null;
}; };
setMandatoryCW: {
newCW: string | null;
oldCW: string | null;
userId: string;
userUsername: string;
userHost: string | null;
};
setRemoteInstanceNSFW: {
id: string;
host: string;
};
unsetRemoteInstanceNSFW: {
id: string;
host: string;
};
suspendRemoteInstance: { suspendRemoteInstance: {
id: string; id: string;
host: string; host: string;
@ -330,6 +359,14 @@ export type ModerationLogPayloads = {
id: string; id: string;
host: string; host: string;
}; };
rejectRemoteInstanceReports: {
id: string;
host: string;
};
acceptRemoteInstanceReports: {
id: string;
host: string;
};
updateRemoteInstanceNote: { updateRemoteInstanceNote: {
id: string; id: string;
host: string; host: string;

View file

@ -117,6 +117,15 @@ export type ModerationLog = {
} | { } | {
type: 'deleteUserAnnouncement'; type: 'deleteUserAnnouncement';
info: ModerationLogPayloads['deleteUserAnnouncement']; info: ModerationLogPayloads['deleteUserAnnouncement'];
} | {
type: 'setMandatoryCW';
info: ModerationLogPayloads['setMandatoryCW'];
} | {
type: 'setRemoteInstanceNSFW';
info: ModerationLogPayloads['setRemoteInstanceNSFW'];
} | {
type: 'unsetRemoteInstanceNSFW';
info: ModerationLogPayloads['unsetRemoteInstanceNSFW'];
} | { } | {
type: 'resetPassword'; type: 'resetPassword';
info: ModerationLogPayloads['resetPassword']; info: ModerationLogPayloads['resetPassword'];
@ -126,6 +135,12 @@ export type ModerationLog = {
} | { } | {
type: 'unsuspendRemoteInstance'; type: 'unsuspendRemoteInstance';
info: ModerationLogPayloads['unsuspendRemoteInstance']; info: ModerationLogPayloads['unsuspendRemoteInstance'];
} | {
type: 'rejectRemoteInstanceReports';
info: ModerationLogPayloads['rejectRemoteInstanceReports'];
} | {
type: 'acceptRemoteInstanceReports';
info: ModerationLogPayloads['acceptRemoteInstanceReports'];
} | { } | {
type: 'updateRemoteInstanceNote'; type: 'updateRemoteInstanceNote';
info: ModerationLogPayloads['updateRemoteInstanceNote']; info: ModerationLogPayloads['updateRemoteInstanceNote'];

View file

@ -303,6 +303,8 @@ _abuseReport:
webhook: "Send a notification to the SystemWebhook when an abuse report is received or resolved." webhook: "Send a notification to the SystemWebhook when an abuse report is received or resolved."
_moderationLogTypes: _moderationLogTypes:
approve: "Approved" approve: "Approved"
decline: "Declined"
setMandatoryCW: "Set content warning for user"
setRemoteInstanceNSFW: "Set remote instance as NSFW" setRemoteInstanceNSFW: "Set remote instance as NSFW"
unsetRemoteInstanceNSFW: "Set remote instance as NSFW" unsetRemoteInstanceNSFW: "Set remote instance as NSFW"
rejectRemoteInstanceReports: "Rejected reports from remote instance" rejectRemoteInstanceReports: "Rejected reports from remote instance"
@ -436,6 +438,7 @@ _permissions:
"write:admin:decline-user": "Decline new users" "write:admin:decline-user": "Decline new users"
"write:admin:nsfw-user": "Mark users as NSFW" "write:admin:nsfw-user": "Mark users as NSFW"
"write:admin:unnsfw-user": "Mark users an not NSFW" "write:admin:unnsfw-user": "Mark users an not NSFW"
"write:admin:cw-user": "Apply mandatory CW on users"
"write:admin:silence-user": "Silence users" "write:admin:silence-user": "Silence users"
"write:admin:unsilence-user": "Un-silence users" "write:admin:unsilence-user": "Un-silence users"
"read:notes-schedule": "View your list of scheduled notes" "read:notes-schedule": "View your list of scheduled notes"
@ -471,3 +474,6 @@ _noteSearch:
flash: "Flash" flash: "Flash"
id: "ID" 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."