summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordakkar <dakkar@thenautilus.net>2025-02-20 10:20:49 +0000
committerdakkar <dakkar@thenautilus.net>2025-02-20 10:20:49 +0000
commit534c35cca2b2492dea05b7c7b23802af5435291a (patch)
treed3dec52a465dc2797919e03a7d2d049960a1c86e
parentmerge: Add separate redis for rate limit (!908) (diff)
parentadd `admin/cw-user` to new endpoints list (diff)
downloadsharkey-534c35cca2b2492dea05b7c7b23802af5435291a.tar.gz
sharkey-534c35cca2b2492dea05b7c7b23802af5435291a.tar.bz2
sharkey-534c35cca2b2492dea05b7c7b23802af5435291a.zip
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>
-rw-r--r--locales/index.d.ts20
-rw-r--r--packages/backend/migration/1738043621143-add_user_mandatoryCW.js11
-rw-r--r--packages/backend/src/core/NoteCreateService.ts16
-rw-r--r--packages/backend/src/core/NoteEditService.ts14
-rw-r--r--packages/backend/src/core/PollService.ts2
-rw-r--r--packages/backend/src/core/WebhookTestService.ts2
-rw-r--r--packages/backend/src/core/activitypub/ApRendererService.ts31
-rw-r--r--packages/backend/src/core/activitypub/ApResolverService.ts5
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts1
-rw-r--r--packages/backend/src/misc/append-content-warning.ts62
-rw-r--r--packages/backend/src/misc/get-note-summary.ts11
-rw-r--r--packages/backend/src/models/User.ts9
-rw-r--r--packages/backend/src/models/json-schema/user.ts4
-rw-r--r--packages/backend/src/server/ActivityPubServerService.ts17
-rw-r--r--packages/backend/src/server/api/endpoint-list.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/admin/cw-user.ts67
-rw-r--r--packages/backend/src/server/api/endpoints/admin/nsfw-user.ts9
-rw-r--r--packages/backend/src/types.ts8
-rw-r--r--packages/backend/test/unit/activitypub.ts145
-rw-r--r--packages/backend/test/unit/misc/append-content-warning.ts92
-rw-r--r--packages/frontend-embed/src/components/EmNote.vue9
-rw-r--r--packages/frontend-embed/src/components/EmNoteDetailed.vue9
-rw-r--r--packages/frontend-embed/src/components/EmNoteSimple.vue11
-rw-r--r--packages/frontend-embed/src/components/EmNoteSub.vue11
-rw-r--r--packages/frontend-shared/js/append-content-warning.ts62
-rw-r--r--packages/frontend-shared/js/compute-merged-cw.ts17
-rw-r--r--packages/frontend/src/components/MkNote.vue11
-rw-r--r--packages/frontend/src/components/MkNoteDetailed.vue11
-rw-r--r--packages/frontend/src/components/MkNoteSimple.vue11
-rw-r--r--packages/frontend/src/components/MkNoteSub.vue9
-rw-r--r--packages/frontend/src/components/MkPostForm.vue15
-rw-r--r--packages/frontend/src/components/SkNote.vue11
-rw-r--r--packages/frontend/src/components/SkNoteDetailed.vue11
-rw-r--r--packages/frontend/src/components/SkNoteSimple.vue11
-rw-r--r--packages/frontend/src/components/SkNoteSub.vue9
-rw-r--r--packages/frontend/src/pages/admin-user.vue25
-rw-r--r--packages/frontend/src/pages/admin/modlog.ModLog.vue10
-rw-r--r--packages/frontend/src/scripts/get-note-summary.ts11
-rw-r--r--packages/misskey-js/etc/misskey-js.api.md23
-rw-r--r--packages/misskey-js/src/autogen/apiClientJSDoc.ts11
-rw-r--r--packages/misskey-js/src/autogen/endpoint.ts2
-rw-r--r--packages/misskey-js/src/autogen/entities.ts1
-rw-r--r--packages/misskey-js/src/autogen/types.ts63
-rw-r--r--packages/misskey-js/src/consts.ts39
-rw-r--r--packages/misskey-js/src/entities.ts15
-rw-r--r--sharkey-locales/en-US.yml6
46 files changed, 843 insertions, 108 deletions
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 9624b48b42..bf49869bf8 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -8555,6 +8555,10 @@ export interface Locale extends ILocale {
*/
"write:admin:unnsfw-user": string;
/**
+ * Apply mandatory CW on users
+ */
+ "write:admin:cw-user": string;
+ /**
* Silence users
*/
"write:admin:silence-user": string;
@@ -10215,6 +10219,14 @@ export interface Locale extends ILocale {
*/
"approve": string;
/**
+ * Declined
+ */
+ "decline": string;
+ /**
+ * Set content warning for user
+ */
+ "setMandatoryCW": string;
+ /**
* Set remote instance as NSFW
*/
"setRemoteInstanceNSFW": string;
@@ -12089,6 +12101,14 @@ export interface Locale extends ILocale {
* ID
*/
"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: {
[lang: string]: Locale;
diff --git a/packages/backend/migration/1738043621143-add_user_mandatoryCW.js b/packages/backend/migration/1738043621143-add_user_mandatoryCW.js
new file mode 100644
index 0000000000..dd05076dd2
--- /dev/null
+++ b/packages/backend/migration/1738043621143-add_user_mandatoryCW.js
@@ -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"`);
+ }
+}
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 3bfced1d80..8291db9b42 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -228,7 +228,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
- public async create(user: {
+ public async create(user: MiUser & {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
@@ -435,7 +435,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
- public async import(user: {
+ public async import(user: MiUser & {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
@@ -486,10 +486,10 @@ export class NoteCreateService implements OnApplicationShutdown {
// should really not happen, but better safe than sorry
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) {
- 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;
@@ -552,7 +552,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
- private async postNoteCreated(note: MiNote, user: {
+ private async postNoteCreated(note: MiNote, user: MiUser & {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
@@ -753,7 +753,7 @@ export class NoteCreateService implements OnApplicationShutdown {
//#region AP deliver
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
(async () => {
- const noteActivity = await this.renderNoteOrRenoteActivity(data, note);
+ const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
// メンションされたリモートユーザーに配送
@@ -899,12 +899,12 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
- private async renderNoteOrRenoteActivity(data: Option, note: MiNote) {
+ private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) {
if (data.localOnly) return null;
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.renderCreate(await this.apRendererService.renderNote(note, false), note);
+ : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, user, false), note);
return this.apRendererService.addContext(content);
}
diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts
index 453ad5d9d0..24a99156d2 100644
--- a/packages/backend/src/core/NoteEditService.ts
+++ b/packages/backend/src/core/NoteEditService.ts
@@ -224,7 +224,7 @@ export class NoteEditService implements OnApplicationShutdown {
}
@bindThis
- public async edit(user: {
+ public async edit(user: MiUser & {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
@@ -309,7 +309,7 @@ export class NoteEditService implements OnApplicationShutdown {
if (this.isRenote(data)) {
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) {
@@ -584,7 +584,7 @@ export class NoteEditService implements OnApplicationShutdown {
}
@bindThis
- private async postNoteEdited(note: MiNote, oldNote: MiNote, user: {
+ private async postNoteEdited(note: MiNote, oldNote: MiNote, user: MiUser & {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
@@ -703,7 +703,7 @@ export class NoteEditService implements OnApplicationShutdown {
//#region AP deliver
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
(async () => {
- const noteActivity = await this.renderNoteOrRenoteActivity(data, note);
+ const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
// メンションされたリモートユーザーに配送
@@ -834,14 +834,12 @@ export class NoteEditService implements OnApplicationShutdown {
}
@bindThis
- private async renderNoteOrRenoteActivity(data: Option, note: MiNote) {
+ private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) {
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)
? 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);
}
diff --git a/packages/backend/src/core/PollService.ts b/packages/backend/src/core/PollService.ts
index 6c96ab16cf..d6364613bd 100644
--- a/packages/backend/src/core/PollService.ts
+++ b/packages/backend/src/core/PollService.ts
@@ -100,7 +100,7 @@ export class PollService {
if (user == null) throw new Error('note not found');
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.relayService.deliverToRelays(user, content);
}
diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts
index dfe7a259c4..93693216cb 100644
--- a/packages/backend/src/core/WebhookTestService.ts
+++ b/packages/backend/src/core/WebhookTestService.ts
@@ -99,6 +99,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
signupReason: null,
noindex: false,
enableRss: true,
+ mandatoryCW: null,
...override,
};
}
@@ -216,6 +217,7 @@ function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<'
isSystem: false,
isSilenced: user.isSilenced,
enableRss: true,
+ mandatoryCW: null,
...override,
};
}
diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts
index 721cb77b2f..cb9b74f6d7 100644
--- a/packages/backend/src/core/activitypub/ApRendererService.ts
+++ b/packages/backend/src/core/activitypub/ApRendererService.ts
@@ -28,6 +28,7 @@ import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFil
import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { IdService } from '@/core/IdService.js';
+import { appendContentWarning } from '@/misc/append-content-warning.js';
import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js';
@@ -339,7 +340,7 @@ export class ApRendererService {
}
@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[]> => {
if (ids.length === 0) return [];
const items = await this.driveFilesRepository.findBy({ id: In(ids) });
@@ -353,14 +354,14 @@ export class ApRendererService {
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
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) {
inReplyTo = inReplyToNote.uri;
} else {
if (dive) {
- inReplyTo = await this.renderNote(inReplyToNote, false);
+ inReplyTo = await this.renderNote(inReplyToNote, inReplyToUser, false);
} else {
inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`;
}
@@ -423,7 +424,12 @@ export class ApRendererService {
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);
@@ -636,7 +642,7 @@ export class ApRendererService {
}
@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[]> => {
if (ids.length === 0) return [];
const items = await this.driveFilesRepository.findBy({ id: In(ids) });
@@ -650,14 +656,14 @@ export class ApRendererService {
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
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) {
inReplyTo = inReplyToNote.uri;
} else {
if (dive) {
- inReplyTo = await this.renderUpNote(inReplyToNote, false);
+ inReplyTo = await this.renderUpNote(inReplyToNote, inReplyToUser, false);
} else {
inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`;
}
@@ -720,7 +726,12 @@ export class ApRendererService {
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);
diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts
index 410803609c..f5b63a2827 100644
--- a/packages/backend/src/core/activitypub/ApResolverService.ts
+++ b/packages/backend/src/core/activitypub/ApResolverService.ts
@@ -209,11 +209,12 @@ export class Resolver {
case 'notes':
return this.notesRepository.findOneByOrFail({ id: parsed.id })
.then(async note => {
+ const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
if (parsed.rest === 'activity') {
// 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 {
- return this.apRendererService.renderNote(note);
+ return this.apRendererService.renderNote(note, author);
}
});
case 'users':
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index ef0b5213c8..4fbbbdd379 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -592,6 +592,7 @@ export class UserEntityService implements OnModuleInit {
isCat: user.isCat,
noindex: user.noindex,
enableRss: user.enableRss,
+ mandatoryCW: user.mandatoryCW,
isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
speakAsCat: user.speakAsCat ?? false,
approved: user.approved,
diff --git a/packages/backend/src/misc/append-content-warning.ts b/packages/backend/src/misc/append-content-warning.ts
new file mode 100644
index 0000000000..152cd6760e
--- /dev/null
+++ b/packages/backend/src/misc/append-content-warning.ts
@@ -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;
+}
diff --git a/packages/backend/src/misc/get-note-summary.ts b/packages/backend/src/misc/get-note-summary.ts
index 60dddee9a2..be2d3ea98d 100644
--- a/packages/backend/src/misc/get-note-summary.ts
+++ b/packages/backend/src/misc/get-note-summary.ts
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
+import { appendContentWarning } from './append-content-warning.js';
import type { Packed } from './json-schema.js';
/**
@@ -20,9 +21,15 @@ export const getNoteSummary = (note: Packed<'Note'>): string => {
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) {
- summary += `CW: ${note.cw}`;
+ if (cw != null) {
+ summary += `CW: ${cw}`;
} else if (note.text) {
summary += note.text;
}
diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts
index 3a825d36a7..8a3ad1003d 100644
--- a/packages/backend/src/models/User.ts
+++ b/packages/backend/src/models/User.ts
@@ -339,6 +339,15 @@ export class MiUser {
})
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>) {
if (data == null) return;
diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts
index 93b031e9c5..1c2ba538c1 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -134,6 +134,10 @@ export const packedUserLiteSchema = {
type: 'boolean',
nullable: false, optional: false,
},
+ mandatoryCW: {
+ type: 'string',
+ nullable: true, optional: false,
+ },
isBot: {
type: 'boolean',
nullable: false, optional: true,
diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts
index 72faa3318c..10dba1660f 100644
--- a/packages/backend/src/server/ActivityPubServerService.ts
+++ b/packages/backend/src/server/ActivityPubServerService.ts
@@ -103,15 +103,16 @@ export class ActivityPubServerService {
/**
* Pack Create<Note> or Announce Activity
* @param note Note
+ * @param author Author of the note
*/
@bindThis
- private async packActivity(note: MiNote): Promise<any> {
+ private async packActivity(note: MiNote, author: MiUser): Promise<any> {
if (isRenote(note) && !isQuote(note)) {
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.renderCreate(await this.apRendererService.renderNote(note, false), note);
+ return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author, false), note);
}
@bindThis
@@ -506,7 +507,7 @@ export class ActivityPubServerService {
this.notesRepository.findOneByOrFail({ id: pining.noteId }))))
.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(
`${this.config.url}/users/${userId}/collections/featured`,
@@ -579,7 +580,7 @@ export class ActivityPubServerService {
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(
`${partOf}?${url.query({
page: 'true',
@@ -723,7 +724,9 @@ export class ActivityPubServerService {
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
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
@@ -746,7 +749,9 @@ export class ActivityPubServerService {
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
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
diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts
index 6dce7f1a3d..551d7b17c2 100644
--- a/packages/backend/src/server/api/endpoint-list.ts
+++ b/packages/backend/src/server/api/endpoint-list.ts
@@ -34,6 +34,7 @@ export * as 'admin/avatar-decorations/list' from './endpoints/admin/avatar-decor
export * as 'admin/avatar-decorations/update' from './endpoints/admin/avatar-decorations/update.js';
export * as 'admin/captcha/current' from './endpoints/admin/captcha/current.js';
export * as 'admin/captcha/save' from './endpoints/admin/captcha/save.js';
+export * as 'admin/cw-user' from './endpoints/admin/cw-user.js';
export * as 'admin/decline-user' from './endpoints/admin/decline-user.js';
export * as 'admin/delete-account' from './endpoints/admin/delete-account.js';
export * as 'admin/delete-all-files-of-a-user' from './endpoints/admin/delete-all-files-of-a-user.js';
diff --git a/packages/backend/src/server/api/endpoints/admin/cw-user.ts b/packages/backend/src/server/api/endpoints/admin/cw-user.ts
new file mode 100644
index 0000000000..bdcfa6a0d9
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/cw-user.ts
@@ -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 });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts b/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts
index d3fa4251dd..f64ba7f48a 100644
--- a/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts
+++ b/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts
@@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository, UserProfilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
+import { CacheService } from '@/core/CacheService.js';
export const meta = {
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
constructor(
@Inject(DI.usersRepository)
- private usersRepository: UsersRepository,
+ private readonly usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository)
- private userProfilesRepository: UserProfilesRepository,
+ private readonly userProfilesRepository: UserProfilesRepository,
+
+ private readonly cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
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, {
alwaysMarkNsfw: true,
});
+
+ await this.cacheService.userProfileCache.refresh(ps.userId);
});
}
}
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index 067481d9da..b359fa5a39 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -100,6 +100,7 @@ export const moderationLogTypes = [
'deleteGlobalAnnouncement',
'deleteUserAnnouncement',
'resetPassword',
+ 'setMandatoryCW',
'setRemoteInstanceNSFW',
'unsetRemoteInstanceNSFW',
'suspendRemoteInstance',
@@ -261,6 +262,13 @@ export type ModerationLogPayloads = {
userUsername: string;
userHost: string | null;
};
+ setMandatoryCW: {
+ newCW: string | null;
+ oldCW: string | null;
+ userId: string;
+ userUsername: string;
+ userHost: string | null;
+ };
setRemoteInstanceNSFW: {
id: string;
host: string;
diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts
index 73d6186edf..435dbb5bea 100644
--- a/packages/backend/test/unit/activitypub.ts
+++ b/packages/backend/test/unit/activitypub.ts
@@ -3,6 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
+import { IdService } from '@/core/IdService.js';
+
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
@@ -20,7 +22,7 @@ import { CoreModule } from '@/core/CoreModule.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { LoggerService } from '@/core/LoggerService.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 { secureRndstr } from '@/misc/secure-rndstr.js';
import { DownloadService } from '@/core/DownloadService.js';
@@ -93,6 +95,7 @@ describe('ActivityPub', () => {
let rendererService: ApRendererService;
let jsonLdService: JsonLdService;
let resolver: MockResolver;
+ let idService: IdService;
const metaInitial = {
cacheRemoteFiles: true,
@@ -140,6 +143,7 @@ describe('ActivityPub', () => {
imageService = app.get<ApImageService>(ApImageService);
jsonLdService = app.get<JsonLdService>(JsonLdService);
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
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');
+ });
+ });
+ });
+ });
});
diff --git a/packages/backend/test/unit/misc/append-content-warning.ts b/packages/backend/test/unit/misc/append-content-warning.ts
new file mode 100644
index 0000000000..d25d7c4925
--- /dev/null
+++ b/packages/backend/test/unit/misc/append-content-warning.ts
@@ -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');
+ });
+});
diff --git a/packages/frontend-embed/src/components/EmNote.vue b/packages/frontend-embed/src/components/EmNote.vue
index 025c4c0734..bf96c557ea 100644
--- a/packages/frontend-embed/src/components/EmNote.vue
+++ b/packages/frontend-embed/src/components/EmNote.vue
@@ -46,11 +46,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<EmNoteHeader :note="appearNote" :mini="true"/>
<EmInstanceTicker v-if="appearNote.user.instance != null" :instance="appearNote.user.instance"/>
<div style="container-type: inline-size;">
- <p v-if="appearNote.cw != null" :class="$style.cw">
- <EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'" :isBlock="true"/>
+ <p v-if="mergedCW != null" :class="$style.cw">
+ <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>
</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">
<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>
@@ -109,6 +109,7 @@ import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js';
import { shouldCollapsed } from '@@/js/collapsed.js';
import { url } from '@@/js/config.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import I18n from '@/components/I18n.vue';
import EmNoteSub from '@/components/EmNoteSub.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 collapsed = ref(appearNote.value.cw == null && isLong);
const isDeleted = ref(false);
+
+const mergedCW = computed(() => computeMergedCw(appearNote.value));
</script>
<style lang="scss" module>
diff --git a/packages/frontend-embed/src/components/EmNoteDetailed.vue b/packages/frontend-embed/src/components/EmNoteDetailed.vue
index c4ea9b4f2e..0961b36e35 100644
--- a/packages/frontend-embed/src/components/EmNoteDetailed.vue
+++ b/packages/frontend-embed/src/components/EmNoteDetailed.vue
@@ -58,11 +58,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</header>
<div :class="[$style.noteContent, { [$style.contentCollapsed]: collapsed }]">
- <p v-if="appearNote.cw != null" :class="$style.cw">
- <EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'" :isBlock="true"/>
+ <p v-if="mergedCW != null" :class="$style.cw">
+ <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>
</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>
<EmA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
<EmMfm
@@ -130,6 +130,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, inject, ref } from 'vue';
import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import I18n from '@/components/I18n.vue';
import EmMediaList from '@/components/EmMediaList.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 isLong = shouldCollapsed(appearNote.value, []);
const collapsed = ref(appearNote.value.cw == null && isLong);
+
+const mergedCW = computed(() => computeMergedCw(appearNote.value));
</script>
<style lang="scss" module>
diff --git a/packages/frontend-embed/src/components/EmNoteSimple.vue b/packages/frontend-embed/src/components/EmNoteSimple.vue
index 83e73f9870..688758edb6 100644
--- a/packages/frontend-embed/src/components/EmNoteSimple.vue
+++ b/packages/frontend-embed/src/components/EmNoteSimple.vue
@@ -9,11 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.main">
<EmNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
- <p v-if="note.cw != 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"/>
+ <p v-if="mergedCW != null" :class="$style.cw">
+ <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>
</p>
- <div v-show="note.cw == null || showContent">
+ <div v-show="mergedCW == null || showContent">
<EmSubNoteContent :class="$style.text" :note="note"/>
</div>
</div>
@@ -22,8 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { ref } from 'vue';
+import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import { i18n } from '@/i18n.js';
import EmAvatar from '@/components/EmAvatar.vue';
import EmNoteHeader from '@/components/EmNoteHeader.vue';
@@ -35,6 +36,8 @@ const props = defineProps<{
}>();
const showContent = ref(false);
+
+const mergedCW = computed(() => computeMergedCw(props.note));
</script>
<style lang="scss" module>
diff --git a/packages/frontend-embed/src/components/EmNoteSub.vue b/packages/frontend-embed/src/components/EmNoteSub.vue
index cc379e8281..629f0bffcd 100644
--- a/packages/frontend-embed/src/components/EmNoteSub.vue
+++ b/packages/frontend-embed/src/components/EmNoteSub.vue
@@ -11,11 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.body">
<EmNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
- <p v-if="note.cw != null" :class="$style.cw">
- <EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :isBlock="true"/>
+ <p v-if="mergedCW != null" :class="$style.cw">
+ <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>
</p>
- <div v-show="note.cw == null || showContent">
+ <div v-show="mergedCW == null || showContent">
<EmSubNoteContent :class="$style.text" :note="note"/>
</div>
</div>
@@ -31,8 +31,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { ref } from 'vue';
+import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import EmA from '@/components/EmA.vue';
import EmAvatar from '@/components/EmAvatar.vue';
import EmNoteHeader from '@/components/EmNoteHeader.vue';
@@ -55,6 +56,8 @@ const props = withDefaults(defineProps<{
const showContent = ref(false);
const replies = ref<Misskey.entities.Note[]>([]);
+const mergedCW = computed(() => computeMergedCw(props.note));
+
if (props.detail) {
misskeyApi('notes/children', {
noteId: props.note.id,
diff --git a/packages/frontend-shared/js/append-content-warning.ts b/packages/frontend-shared/js/append-content-warning.ts
new file mode 100644
index 0000000000..7f24a66f23
--- /dev/null
+++ b/packages/frontend-shared/js/append-content-warning.ts
@@ -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;
+}
diff --git a/packages/frontend-shared/js/compute-merged-cw.ts b/packages/frontend-shared/js/compute-merged-cw.ts
new file mode 100644
index 0000000000..dfea57fdce
--- /dev/null
+++ b/packages/frontend-shared/js/compute-merged-cw.ts
@@ -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;
+}
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 9271e9e4b7..0bac6a67b9 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -59,10 +59,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
<div style="container-type: inline-size;">
<bdi>
- <p v-if="appearNote.cw != null" :class="$style.cw">
+ <p v-if="mergedCW != null" :class="$style.cw">
<Mfm
- v-if="appearNote.cw != ''"
- :text="appearNote.cw"
+ v-if="mergedCW != ''"
+ :text="mergedCW"
:author="appearNote.user"
:nyaize="'respect'"
: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/>
</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">
<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>
@@ -212,6 +212,7 @@ import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { shouldCollapsed } from '@@/js/collapsed.js';
import { host } from '@@/js/config.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import type { MenuItem } from '@/types/menu.js';
import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
@@ -350,6 +351,8 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
url: `https://${host}/notes/${appearNote.value.id}`,
}));
+const mergedCW = computed(() => computeMergedCw(appearNote.value));
+
const renoteTooltip = computeRenoteTooltip(renoted);
/* Overload FunctionにLintが対応していないのでコメントアウト
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 6c52714f46..582838ebca 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -75,10 +75,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</header>
<div :class="$style.noteContent">
- <p v-if="appearNote.cw != null" :class="$style.cw">
+ <p v-if="mergedCW != null" :class="$style.cw">
<Mfm
- v-if="appearNote.cw != ''"
- :text="appearNote.cw"
+ v-if="mergedCW != ''"
+ :text="mergedCW"
:author="appearNote.user"
:nyaize="'respect'"
: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"/>
</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>
<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
@@ -245,6 +245,7 @@ import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { host } from '@@/js/config.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteSimple from '@/components/MkNoteSimple.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 defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
+const mergedCW = computed(() => computeMergedCw(appearNote.value));
+
const renoteTooltip = computeRenoteTooltip(renoted);
watch(() => props.expandAllCws, (expandAllCws) => {
diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue
index 9ef45a0ec3..3720aa7493 100644
--- a/packages/frontend/src/components/MkNoteSimple.vue
+++ b/packages/frontend/src/components/MkNoteSimple.vue
@@ -9,11 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.main">
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
- <p v-if="note.cw != 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"/>
+ <p v-if="mergedCW != null" :class="$style.cw">
+ <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/>
</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"/>
<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>
@@ -26,8 +26,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { ref, watch } from 'vue';
+import { computed, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import * as os from '@/os.js';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
@@ -48,6 +49,8 @@ const props = defineProps<{
let showContent = ref(defaultStore.state.uncollapseCW);
const isDeleted = ref(false);
+const mergedCW = computed(() => computeMergedCw(props.note));
+
const emit = defineEmits<{
(ev: 'editScheduleNote'): void;
}>();
diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue
index 9febc1219c..61006cfdcc 100644
--- a/packages/frontend/src/components/MkNoteSub.vue
+++ b/packages/frontend/src/components/MkNoteSub.vue
@@ -11,11 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.body">
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div :class="$style.content">
- <p v-if="note.cw != null" :class="$style.cw">
- <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'"/>
+ <p v-if="mergedCW != null" :class="$style.cw">
+ <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"/>
</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"/>
</div>
</div>
@@ -86,6 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref, shallowRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.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 replies = ref<Misskey.entities.Note[]>([]);
+const mergedCW = computed(() => computeMergedCw(appearNote.value));
+
const isRenote = (
props.note.renote != null &&
props.note.text == null &&
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 059de8011c..c5f5f4514d 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -112,6 +112,7 @@ import * as Misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor';
import { toASCII } from 'punycode.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 { PostFormProps } from '@/types/post-form.js';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
@@ -373,18 +374,8 @@ if ($i.defaultCW) {
if (!cw.value || $i.defaultCWPriority === 'default') {
cw.value = $i.defaultCW;
} else if ($i.defaultCWPriority !== 'parent') {
- // This is a fancy way of simulating /\bsearch\b/ without a regular expression.
- // We're checking to see whether the default CW appears inside the existing CW, but *only* if there's word boundaries.
- 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}`;
- }
- }
+ const putDefaultFirst = $i.defaultCWPriority === 'defaultParent';
+ cw.value = appendContentWarning(cw.value, $i.defaultCW, putDefaultFirst);
}
// else { do nothing, because existing CW takes priority. }
}
diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue
index 323ca283bf..777e95593d 100644
--- a/packages/frontend/src/components/SkNote.vue
+++ b/packages/frontend/src/components/SkNote.vue
@@ -62,10 +62,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="[{ [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click.stop="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined">
<div style="container-type: inline-size;">
- <p v-if="appearNote.cw != null" :class="$style.cw">
+ <p v-if="mergedCW != null" :class="$style.cw">
<Mfm
- v-if="appearNote.cw != ''"
- :text="appearNote.cw"
+ v-if="mergedCW != ''"
+ :text="mergedCW"
:author="appearNote.user"
:nyaize="'respect'"
: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/>
</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">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<Mfm
@@ -213,6 +213,7 @@ import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { shouldCollapsed } from '@@/js/collapsed.js';
import { host } from '@@/js/config.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import type { MenuItem } from '@/types/menu.js';
import SkNoteSub from '@/components/SkNoteSub.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 allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false);
+const mergedCW = computed(() => computeMergedCw(appearNote.value));
+
const renoteTooltip = computeRenoteTooltip(renoted);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue
index dc8a5f59b2..471a4ce7d7 100644
--- a/packages/frontend/src/components/SkNoteDetailed.vue
+++ b/packages/frontend/src/components/SkNoteDetailed.vue
@@ -81,10 +81,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</header>
<div :class="$style.noteContent">
- <p v-if="appearNote.cw != null" :class="$style.cw">
+ <p v-if="mergedCW != null" :class="$style.cw">
<Mfm
- v-if="appearNote.cw != ''"
- :text="appearNote.cw"
+ v-if="mergedCW != ''"
+ :text="mergedCW"
:author="appearNote.user"
:nyaize="'respect'"
: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"/>
</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>
<Mfm
v-if="appearNote.text"
@@ -250,6 +250,7 @@ import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { host } from '@@/js/config.js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import SkNoteSub from '@/components/SkNoteSub.vue';
import SkNoteSimple from '@/components/SkNoteSimple.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 defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
+const mergedCW = computed(() => computeMergedCw(appearNote.value));
+
const renoteTooltip = computeRenoteTooltip(renoted);
watch(() => props.expandAllCws, (expandAllCws) => {
diff --git a/packages/frontend/src/components/SkNoteSimple.vue b/packages/frontend/src/components/SkNoteSimple.vue
index b9895305f2..71a5bd4df8 100644
--- a/packages/frontend/src/components/SkNoteSimple.vue
+++ b/packages/frontend/src/components/SkNoteSimple.vue
@@ -9,11 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.main">
<MkNoteHeader :class="$style.header" :classic="true" :note="note" :mini="true"/>
<div>
- <p v-if="note.cw != 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"/>
+ <p v-if="mergedCW != null" :class="$style.cw">
+ <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/>
</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"/>
</div>
</div>
@@ -22,8 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { watch, ref } from 'vue';
+import { watch, ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
import MkCwButton from '@/components/MkCwButton.vue';
@@ -37,6 +38,8 @@ const props = defineProps<{
let showContent = ref(defaultStore.state.uncollapseCW);
+const mergedCW = computed(() => computeMergedCw(props.note));
+
watch(() => props.expandAllCws, (expandAllCws) => {
if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
});
diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue
index a3d27c9e16..060e66be16 100644
--- a/packages/frontend/src/components/SkNoteSub.vue
+++ b/packages/frontend/src/components/SkNoteSub.vue
@@ -19,11 +19,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.body">
<SkNoteHeader :class="$style.header" :note="note" :classic="true" :mini="true"/>
<div :class="$style.content">
- <p v-if="note.cw != null" :class="$style.cw">
- <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'"/>
+ <p v-if="mergedCW != null" :class="$style.cw">
+ <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"/>
</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"/>
</div>
</div>
@@ -94,6 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref, shallowRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
+import { computeMergedCw } from '@@/js/compute-merged-cw.js';
import SkNoteHeader from '@/components/SkNoteHeader.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.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 replies = ref<Misskey.entities.Note[]>([]);
+const mergedCW = computed(() => computeMergedCw(appearNote.value));
+
const isRenote = (
props.note.renote != null &&
props.note.text == null &&
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
index 11a34d34ef..229f581672 100644
--- a/packages/frontend/src/pages/admin-user.vue
+++ b/packages/frontend/src/pages/admin-user.vue
@@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkKeyValue>
</div>
- <MkTextarea v-model="moderationNote" manualSave>
+ <MkTextarea v-model="moderationNote" manualSave @update:modelValue="onModerationNoteChanged">
<template #label>{{ i18n.ts.moderationNote }}</template>
<template #caption>{{ i18n.ts.moderationNoteDescription }}</template>
</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-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>
<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>
@@ -222,6 +227,7 @@ import { i18n } from '@/i18n.js';
import { iAmAdmin, $i, iAmModerator } from '@/account.js';
import MkRolePreview from '@/components/MkRolePreview.vue';
import MkPagination from '@/components/MkPagination.vue';
+import MkInput from '@/components/MkInput.vue';
const props = withDefaults(defineProps<{
userId: string;
@@ -243,6 +249,7 @@ const approved = ref(false);
const suspended = ref(false);
const markedAsNSFW = ref(false);
const moderationNote = ref('');
+const mandatoryCW = ref<string | null>(null);
const isSystem = computed(() => info.value?.isSystem ?? false);
const filesPagination = {
endpoint: 'admin/drive/files' as const,
@@ -281,11 +288,7 @@ function createFetcher() {
markedAsNSFW.value = info.value.alwaysMarkNsfw;
suspended.value = info.value.isSuspended;
moderationNote.value = info.value.moderationNote;
-
- watch(moderationNote, async () => {
- await misskeyApi('admin/update-user-note', { userId: user.value.id, text: moderationNote.value });
- await refreshUser();
- });
+ mandatoryCW.value = user.value.mandatoryCW;
});
}
@@ -293,6 +296,16 @@ function refreshUser() {
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() {
await os.apiWithDialog('federation/update-remote-user', { userId: user.value.id });
refreshUser();
diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue
index 37a9cc83e7..741de875bc 100644
--- a/packages/frontend/src/pages/admin/modlog.ModLog.vue
+++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue
@@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.logYellow]: [
'markSensitiveDriveFile',
'resetPassword',
+ 'setMandatoryCW',
'suspendRemoteInstance',
'setRemoteInstanceNSFW',
'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 === 'unsuspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'resetPassword'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
+ <span v-else-if="log.type === 'setMandatoryCW'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'assignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} <i class="ti ti-arrow-right"></i> {{ log.info.roleName }}</span>
<span v-else-if="log.type === 'unassignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} <i class="ti ti-equal-not"></i> {{ log.info.roleName }}</span>
<span v-else-if="log.type === 'createRole'">: {{ log.info.role.name }}</span>
@@ -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 === 'deleteGalleryPost'">: @{{ log.info.postUserUsername }}</span>
</template>
- <template #icon>
+ <template v-if="log.user" #icon>
<MkAvatar :user="log.user" :class="$style.avatar"/>
</template>
<template #suffix>
@@ -123,6 +125,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<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>
</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'">
<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>
diff --git a/packages/frontend/src/scripts/get-note-summary.ts b/packages/frontend/src/scripts/get-note-summary.ts
index 58d486bf9b..4e093bcf4c 100644
--- a/packages/frontend/src/scripts/get-note-summary.ts
+++ b/packages/frontend/src/scripts/get-note-summary.ts
@@ -4,6 +4,7 @@
*/
import * as Misskey from 'misskey-js';
+import { appendContentWarning } from '@@/js/append-content-warning.js';
import { i18n } from '@/i18n.js';
/**
@@ -25,9 +26,15 @@ export const getNoteSummary = (note?: Misskey.entities.Note | null): string => {
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) {
- summary += `CW: ${note.cw}`;
+ if (cw != null) {
+ summary += `CW: ${cw}`;
} else if (note.text) {
summary += note.text;
}
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index e564600ef1..cc3dcaa765 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -147,6 +147,9 @@ type AdminCaptchaCurrentResponse = operations['admin___captcha___current']['resp
type AdminCaptchaSaveRequest = operations['admin___captcha___save']['requestBody']['content']['application/json'];
// @public (undocumented)
+type AdminCwUserRequest = operations['admin___cw-user']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
type AdminDeclineUserRequest = operations['admin___decline-user']['requestBody']['content']['application/json'];
// @public (undocumented)
@@ -1307,6 +1310,7 @@ declare namespace entities {
AdminAvatarDecorationsUpdateRequest,
AdminCaptchaCurrentResponse,
AdminCaptchaSaveRequest,
+ AdminCwUserRequest,
AdminDeclineUserRequest,
AdminDeleteAccountRequest,
AdminDeleteAllFilesOfAUserRequest,
@@ -2610,6 +2614,15 @@ type ModerationLog = {
type: 'deleteUserAnnouncement';
info: ModerationLogPayloads['deleteUserAnnouncement'];
} | {
+ type: 'setMandatoryCW';
+ info: ModerationLogPayloads['setMandatoryCW'];
+} | {
+ type: 'setRemoteInstanceNSFW';
+ info: ModerationLogPayloads['setRemoteInstanceNSFW'];
+} | {
+ type: 'unsetRemoteInstanceNSFW';
+ info: ModerationLogPayloads['unsetRemoteInstanceNSFW'];
+} | {
type: 'resetPassword';
info: ModerationLogPayloads['resetPassword'];
} | {
@@ -2619,6 +2632,12 @@ type ModerationLog = {
type: 'unsuspendRemoteInstance';
info: ModerationLogPayloads['unsuspendRemoteInstance'];
} | {
+ type: 'rejectRemoteInstanceReports';
+ info: ModerationLogPayloads['rejectRemoteInstanceReports'];
+} | {
+ type: 'acceptRemoteInstanceReports';
+ info: ModerationLogPayloads['acceptRemoteInstanceReports'];
+} | {
type: 'updateRemoteInstanceNote';
info: ModerationLogPayloads['updateRemoteInstanceNote'];
} | {
@@ -2696,7 +2715,7 @@ type ModerationLog = {
});
// @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)
type MuteCreateRequest = operations['mute___create']['requestBody']['content']['application/json'];
@@ -3006,7 +3025,7 @@ type PartialRolePolicyOverride = Partial<{
}>;
// @public (undocumented)
-export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notes-schedule", "write:notes-schedule", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:nsfw-user", "write:admin:unnsfw-user", "write:admin: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)
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index f4120b3afc..99e202a1b6 100644
--- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts
+++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
@@ -275,6 +275,17 @@ declare module '../api.js' {
/**
* 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.
+ *
* **Credential required**: *Yes* / **Permission**: *write:admin:decline-user*
*/
request<E extends 'admin/decline-user', P extends Endpoints[E]['req']>(
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index c8000fc1d0..b31dd2d8f1 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -38,6 +38,7 @@ import type {
AdminAvatarDecorationsUpdateRequest,
AdminCaptchaCurrentResponse,
AdminCaptchaSaveRequest,
+ AdminCwUserRequest,
AdminDeclineUserRequest,
AdminDeleteAccountRequest,
AdminDeleteAllFilesOfAUserRequest,
@@ -633,6 +634,7 @@ export type Endpoints = {
'admin/avatar-decorations/update': { req: AdminAvatarDecorationsUpdateRequest; res: EmptyResponse };
'admin/captcha/current': { req: EmptyRequest; res: AdminCaptchaCurrentResponse };
'admin/captcha/save': { req: AdminCaptchaSaveRequest; res: EmptyResponse };
+ 'admin/cw-user': { req: AdminCwUserRequest; res: EmptyResponse };
'admin/decline-user': { req: AdminDeclineUserRequest; res: EmptyResponse };
'admin/delete-account': { req: AdminDeleteAccountRequest; res: EmptyResponse };
'admin/delete-all-files-of-a-user': { req: AdminDeleteAllFilesOfAUserRequest; res: EmptyResponse };
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index 3db711f9a4..c65669d056 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -41,6 +41,7 @@ export type AdminAvatarDecorationsListResponse = operations['admin___avatar-deco
export type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json'];
export type AdminCaptchaCurrentResponse = operations['admin___captcha___current']['responses']['200']['content']['application/json'];
export type AdminCaptchaSaveRequest = operations['admin___captcha___save']['requestBody']['content']['application/json'];
+export type AdminCwUserRequest = operations['admin___cw-user']['requestBody']['content']['application/json'];
export type AdminDeclineUserRequest = operations['admin___decline-user']['requestBody']['content']['application/json'];
export type AdminDeleteAccountRequest = operations['admin___delete-account']['requestBody']['content']['application/json'];
export type AdminDeleteAllFilesOfAUserRequest = operations['admin___delete-all-files-of-a-user']['requestBody']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 9bac7a812c..46356613ac 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -233,6 +233,15 @@ export type paths = {
*/
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
@@ -3968,6 +3977,7 @@ export type components = {
isSystem?: boolean;
noindex: boolean;
enableRss: boolean;
+ mandatoryCW: string | null;
isBot?: boolean;
isCat?: boolean;
speakAsCat?: boolean;
@@ -6888,6 +6898,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
* @description No description provided.
*
diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts
index 0faf3dddc4..96da0a8fad 100644
--- a/packages/misskey-js/src/consts.ts
+++ b/packages/misskey-js/src/consts.ts
@@ -83,6 +83,7 @@ export const permissions = [
'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',
@@ -124,6 +125,7 @@ export const moderationLogTypes = [
'updateServerSettings',
'suspend',
'approve',
+ 'decline',
'unsuspend',
'updateUserNote',
'addCustomEmoji',
@@ -145,8 +147,13 @@ export const moderationLogTypes = [
'deleteGlobalAnnouncement',
'deleteUserAnnouncement',
'resetPassword',
+ 'setMandatoryCW',
+ 'setRemoteInstanceNSFW',
+ 'unsetRemoteInstanceNSFW',
'suspendRemoteInstance',
'unsuspendRemoteInstance',
+ 'rejectRemoteInstanceReports',
+ 'acceptRemoteInstanceReports',
'updateRemoteInstanceNote',
'markSensitiveDriveFile',
'unmarkSensitiveDriveFile',
@@ -186,7 +193,14 @@ export const reversiUpdateKeys = [
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 = {
reportId: AbuseReportNotificationRecipient['id'];
@@ -322,6 +336,21 @@ export type ModerationLogPayloads = {
userUsername: string;
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: {
id: string;
host: string;
@@ -330,6 +359,14 @@ export type ModerationLogPayloads = {
id: string;
host: string;
};
+ rejectRemoteInstanceReports: {
+ id: string;
+ host: string;
+ };
+ acceptRemoteInstanceReports: {
+ id: string;
+ host: string;
+ };
updateRemoteInstanceNote: {
id: string;
host: string;
diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts
index efe5ba19fb..3e88eae275 100644
--- a/packages/misskey-js/src/entities.ts
+++ b/packages/misskey-js/src/entities.ts
@@ -118,6 +118,15 @@ export type ModerationLog = {
type: 'deleteUserAnnouncement';
info: ModerationLogPayloads['deleteUserAnnouncement'];
} | {
+ type: 'setMandatoryCW';
+ info: ModerationLogPayloads['setMandatoryCW'];
+} | {
+ type: 'setRemoteInstanceNSFW';
+ info: ModerationLogPayloads['setRemoteInstanceNSFW'];
+} | {
+ type: 'unsetRemoteInstanceNSFW';
+ info: ModerationLogPayloads['unsetRemoteInstanceNSFW'];
+} | {
type: 'resetPassword';
info: ModerationLogPayloads['resetPassword'];
} | {
@@ -127,6 +136,12 @@ export type ModerationLog = {
type: 'unsuspendRemoteInstance';
info: ModerationLogPayloads['unsuspendRemoteInstance'];
} | {
+ type: 'rejectRemoteInstanceReports';
+ info: ModerationLogPayloads['rejectRemoteInstanceReports'];
+} | {
+ type: 'acceptRemoteInstanceReports';
+ info: ModerationLogPayloads['acceptRemoteInstanceReports'];
+} | {
type: 'updateRemoteInstanceNote';
info: ModerationLogPayloads['updateRemoteInstanceNote'];
} | {
diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml
index 86eafc8a33..af2db91286 100644
--- a/sharkey-locales/en-US.yml
+++ b/sharkey-locales/en-US.yml
@@ -303,6 +303,8 @@ _abuseReport:
webhook: "Send a notification to the SystemWebhook when an abuse report is received or resolved."
_moderationLogTypes:
approve: "Approved"
+ decline: "Declined"
+ setMandatoryCW: "Set content warning for user"
setRemoteInstanceNSFW: "Set remote instance as NSFW"
unsetRemoteInstanceNSFW: "Set remote instance as NSFW"
rejectRemoteInstanceReports: "Rejected reports from remote instance"
@@ -436,6 +438,7 @@ _permissions:
"write:admin:decline-user": "Decline new users"
"write:admin:nsfw-user": "Mark users as 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:unsilence-user": "Un-silence users"
"read:notes-schedule": "View your list of scheduled notes"
@@ -471,3 +474,6 @@ _noteSearch:
flash: "Flash"
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."