add "reject quotes" toggle at user and instance level

+ improve, cleanup, and de-duplicate quote resolution
+ add warning message when quote cannot be loaded
+ add "process error" framework to display warnings when a note cannot be correctly loaded from another instance
This commit is contained in:
Hazelnoot 2025-02-15 23:08:02 -05:00
parent 93ffd4611c
commit 292d3b9229
36 changed files with 466 additions and 88 deletions

34
locales/index.d.ts vendored
View file

@ -8566,6 +8566,10 @@ export interface Locale extends ILocale {
* Un-silence users
*/
"write:admin:unsilence-user": string;
/**
* Allow/Reject quote posts from a user
*/
"write:admin:reject-quotes": string;
/**
* View your list of scheduled notes
*/
@ -10242,6 +10246,14 @@ export interface Locale extends ILocale {
* Accepted reports from remote instance
*/
"acceptRemoteInstanceReports": string;
/**
* Rejected quotes from user
*/
"rejectQuotesUser": string;
/**
* Allowed quotes from user
*/
"allowQuotesUser": string;
};
"_fileViewer": {
/**
@ -11240,6 +11252,22 @@ export interface Locale extends ILocale {
* Reject reports from this instance
*/
"rejectReports": string;
/**
* Reject quote posts from this instance
*/
"rejectQuotesInstance": string;
/**
* Reject quote posts from this user
*/
"rejectQuotesUser": string;
/**
* Are you sure you wish to reject quote posts?
*/
"rejectQuotesConfirm": string;
/**
* Are you sure you wish to allow quote posts?
*/
"allowQuotesConfirm": string;
/**
* This host is blocked implicitly because a base domain is blocked. To unblock this host, first unblock the base domain(s).
*/
@ -12109,6 +12137,12 @@ export interface Locale extends ILocale {
* 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;
"_processErrors": {
/**
* Unable to process quote. This post may be missing context.
*/
"quoteUnavailable": string;
};
}
declare const locales: {
[lang: string]: Locale;

View file

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

View file

@ -0,0 +1,11 @@
export class AddUserRejectQuotes1739671777344 {
name = 'AddUserRejectQuotes1739671777344'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "rejectQuotes" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "rejectQuotes"`);
}
}

View file

@ -0,0 +1,11 @@
export class AddInstanceRejectQuotes1739671847942 {
name = 'AddInstanceRejectQuotes1739671847942'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "instance" ADD "rejectQuotes" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "rejectQuotes"`);
}
}

View file

@ -144,6 +144,7 @@ type Option = {
uri?: string | null;
url?: string | null;
app?: MiApp | null;
processErrors?: string[] | null;
};
export type PureRenoteOption = Option & { renote: MiNote } & ({ text?: null } | { cw?: null } | { reply?: null } | { poll?: null } | { files?: null | [] });
@ -309,6 +310,9 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
// Check quote permissions
await this.checkQuotePermissions(data, user);
// Check blocking
if (this.isRenote(data) && !this.isQuote(data)) {
if (data.renote.userHost === null) {
@ -482,6 +486,7 @@ export class NoteCreateService implements OnApplicationShutdown {
renoteUserId: data.renote ? data.renote.userId : null,
renoteUserHost: data.renote ? data.renote.userHost : null,
userHost: user.host,
processErrors: data.processErrors,
});
// should really not happen, but better safe than sorry
@ -1147,4 +1152,29 @@ export class NoteCreateService implements OnApplicationShutdown {
public async onApplicationShutdown(signal?: string | undefined): Promise<void> {
await this.dispose();
}
@bindThis
public async checkQuotePermissions(data: Option, user: MiUser): Promise<void> {
// Not a quote
if (!this.isRenote(data) || !this.isQuote(data)) return;
// User cannot quote
if (user.rejectQuotes) {
if (user.host == null) {
throw new IdentifiableError('1c0ea108-d1e3-4e8e-aa3f-4d2487626153', 'QUOTE_DISABLED_FOR_USER');
} else {
(data as Option).renote = null;
(data.processErrors ??= []).push('quoteUnavailable');
}
}
// Instance cannot quote
if (user.host) {
const instance = await this.federatedInstanceService.fetch(user.host);
if (instance?.rejectQuotes) {
(data as Option).renote = null;
(data.processErrors ??= []).push('quoteUnavailable');
}
}
}
}

View file

@ -140,6 +140,7 @@ type Option = {
app?: MiApp | null;
updatedAt?: Date | null;
editcount?: boolean | null;
processErrors?: string[] | null;
};
@Injectable()
@ -337,6 +338,9 @@ export class NoteEditService implements OnApplicationShutdown {
}
}
// Check quote permissions
await this.noteCreateService.checkQuotePermissions(data, user);
// Check blocking
if (this.isRenote(data) && !this.isQuote(data)) {
if (data.renote.userHost === null) {
@ -529,6 +533,7 @@ export class NoteEditService implements OnApplicationShutdown {
if (data.uri != null) note.uri = data.uri;
if (data.url != null) note.url = data.url;
if (data.processErrors !== undefined) note.processErrors = data.processErrors;
if (mentionedUsers.length > 0) {
note.mentions = mentionedUsers.map(u => u.id);

View file

@ -296,44 +296,8 @@ export class ApNoteService {
: null;
// 引用
let quote: MiNote | undefined | null = null;
if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) {
const tryResolveNote = async (uri: unknown): Promise<
| { status: 'ok'; res: MiNote }
| { status: 'permerror' | 'temperror' }
> => {
if (typeof uri !== 'string' || !/^https?:/.test(uri)) {
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: URI is invalid`);
return { status: 'permerror' };
}
try {
const res = await this.resolveNote(uri, { resolver });
if (res == null) {
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: resolution error`);
return { status: 'permerror' };
}
return { status: 'ok', res };
} catch (e) {
const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: ${error}`);
return {
status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror',
};
}
};
const uris = unique([note._misskey_quote, note.quoteUrl, note.quoteUri].filter(x => x != null));
const results = await Promise.all(uris.map(tryResolveNote));
quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0);
if (!quote) {
if (results.some(x => x.status === 'temperror')) {
throw new Error(`temporary error resolving quote for ${entryUri}`);
}
}
}
const quote = await this.getQuote(note, entryUri, resolver);
const processErrors = quote === null ? ['quoteUnavailable'] : null;
// vote
if (reply && reply.hasPoll) {
@ -369,7 +333,8 @@ export class ApNoteService {
createdAt: note.published ? new Date(note.published) : null,
files,
reply,
renote: quote,
renote: quote ?? null,
processErrors,
name: note.name,
cw,
text,
@ -538,44 +503,8 @@ export class ApNoteService {
: null;
// 引用
let quote: MiNote | undefined | null = null;
if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) {
const tryResolveNote = async (uri: unknown): Promise<
| { status: 'ok'; res: MiNote }
| { status: 'permerror' | 'temperror' }
> => {
if (typeof uri !== 'string' || !/^https?:/.test(uri)) {
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: URI is invalid`);
return { status: 'permerror' };
}
try {
const res = await this.resolveNote(uri, { resolver });
if (res == null) {
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: resolution error`);
return { status: 'permerror' };
}
return { status: 'ok', res };
} catch (e) {
const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: ${error}`);
return {
status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror',
};
}
};
const uris = unique([note._misskey_quote, note.quoteUrl, note.quoteUri].filter(x => x != null));
const results = await Promise.all(uris.map(tryResolveNote));
quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0);
if (!quote) {
if (results.some(x => x.status === 'temperror')) {
throw new Error(`temporary error resolving quote for ${entryUri}`);
}
}
}
const quote = await this.getQuote(note, entryUri, resolver);
const processErrors = quote === null ? ['quoteUnavailable'] : null;
// vote
if (reply && reply.hasPoll) {
@ -611,7 +540,8 @@ export class ApNoteService {
createdAt: note.published ? new Date(note.published) : null,
files,
reply,
renote: quote,
renote: quote ?? null,
processErrors,
name: note.name,
cw,
text,
@ -734,6 +664,63 @@ export class ApNoteService {
});
}));
}
/**
* Fetches the note's quoted post.
* On success - returns the note.
* On skip (no quote) - returns undefined.
* On permanent error - returns null.
* On temporary error - throws an exception.
*/
private async getQuote(note: IPost, entryUri: string, resolver: Resolver): Promise<MiNote | null | undefined> {
const quoteUris = new Set<string>();
if (note._misskey_quote) quoteUris.add(note._misskey_quote);
if (note.quoteUrl) quoteUris.add(note.quoteUrl);
if (note.quoteUri) quoteUris.add(note.quoteUri);
// No quote, return undefined
if (quoteUris.size < 1) return undefined;
/**
* Attempts to resolve a quote by URI.
* Returns the note if successful, true if there's a retryable error, and false if there's a permanent error.
*/
const resolveQuote = async (uri: unknown): Promise<MiNote | boolean> => {
if (typeof(uri) !== 'string' || !/^https?:/.test(uri)) {
this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": URI is invalid`);
return false;
}
try {
const quote = await this.resolveNote(uri, { resolver });
if (quote == null) {
this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": request error`);
return false;
}
return quote;
} catch (e) {
const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": ${error}`);
return (e instanceof StatusError && e.isRetryable);
}
};
const results = await Promise.all(Array.from(quoteUris).map(u => resolveQuote(u)));
// Success - return the quote
const quote = results.find(r => typeof(r) === 'object');
if (quote) return quote;
// Temporary / retryable error - throw error
const tempError = results.find(r => r === true);
if (tempError) throw new Error(`temporary error resolving quote for "${entryUri}"`);
// Permanent error - return null
return null;
}
}
function getBestIcon(note: IObject): IObject | null {

View file

@ -60,6 +60,7 @@ export class InstanceEntityService {
latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null,
isNSFW: instance.isNSFW,
rejectReports: instance.rejectReports,
rejectQuotes: instance.rejectQuotes,
moderationNote: iAmModerator ? instance.moderationNote : null,
};
}

View file

@ -490,6 +490,7 @@ export class NoteEntityService implements OnModuleInit {
...(opts.detail ? {
clippedCount: note.clippedCount,
processErrors: note.processErrors,
reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, {
detail: false,

View file

@ -674,6 +674,7 @@ export class UserEntityService implements OnModuleInit {
securityKeys: profile!.twoFactorEnabled
? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1)
: false,
rejectQuotes: user.rejectQuotes,
} : {}),
...(isDetailed && isMe ? {

View file

@ -164,6 +164,15 @@ export class MiInstance {
})
public rejectReports: boolean;
/**
* If true, quote posts from this instance will be downgraded to normal posts.
* The quote will be stripped and a process error will be generated.
*/
@Column('boolean', {
default: false,
})
public rejectQuotes: boolean;
@Column('varchar', {
length: 16384, default: '',
})

View file

@ -203,6 +203,17 @@ export class MiNote {
@JoinColumn()
public channel: MiChannel | null;
/**
* List of non-fatal errors encountered while processing (creating or updating) this note.
* Entries can be a translation key (which will be queried from the "_processErrors" section) or a raw string.
* Errors will be displayed to the user when viewing the note.
*/
@Column('text', {
array: true,
nullable: true,
})
public processErrors: string[] | null;
//#region Denormalized fields
@Index()
@Column('varchar', {

View file

@ -348,6 +348,15 @@ export class MiUser {
})
public mandatoryCW: string | null;
/**
* If true, quote posts from this user will be downgraded to normal posts.
* The quote will be stripped and a process error will be generated.
*/
@Column('boolean', {
default: false,
})
public rejectQuotes: boolean;
constructor(data: Partial<MiUser>) {
if (data == null) return;

View file

@ -126,6 +126,11 @@ export const packedFederationInstanceSchema = {
optional: false,
nullable: false,
},
rejectQuotes: {
type: 'boolean',
optional: false,
nullable: false,
},
moderationNote: {
type: 'string',
optional: true, nullable: true,

View file

@ -256,6 +256,14 @@ export const packedNoteSchema = {
type: 'number',
optional: true, nullable: false,
},
processErrors: {
type: 'array',
optional: true, nullable: true,
items: {
type: 'string',
optional: false, nullable: false,
},
},
myReaction: {
type: 'string',

View file

@ -445,6 +445,10 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'boolean',
nullable: false, optional: true,
},
rejectQuotes: {
type: 'boolean',
nullable: false, optional: true,
},
//#region relations
isFollowing: {
type: 'boolean',

View file

@ -27,6 +27,7 @@ export const paramDef = {
isNSFW: { type: 'boolean' },
rejectReports: { type: 'boolean' },
moderationNote: { type: 'string' },
rejectQuotes: { type: 'boolean' },
},
required: ['host'],
} as const;
@ -59,6 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
suspensionState,
isNSFW: ps.isNSFW,
rejectReports: ps.rejectReports,
rejectQuotes: ps.rejectQuotes,
moderationNote: ps.moderationNote,
});
@ -92,6 +94,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
});
}
if (ps.rejectQuotes != null && instance.rejectQuotes !== ps.rejectQuotes) {
const message = ps.rejectReports ? 'rejectQuotesInstance' : 'acceptQuotesInstance';
this.moderationLogService.log(me, message, {
id: instance.id,
host: instance.host,
});
}
if (ps.moderationNote != null && instance.moderationNote !== ps.moderationNote) {
this.moderationLogService.log(me, 'updateRemoteInstanceNote', {
id: instance.id,

View file

@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { CacheService } from '@/core/CacheService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:reject-quotes',
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
rejectQuotes: { type: 'boolean', nullable: false },
},
required: ['userId', 'rejectQuotes'],
} 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.rejectQuotes === ps.rejectQuotes) return;
// Log event first.
// This ensures that we don't "lose" the log if an error occurs
await this.moderationLogService.log(me, ps.rejectQuotes ? 'rejectQuotesUser' : 'acceptQuotesUser', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
await this.usersRepository.update(ps.userId, {
rejectQuotes: ps.rejectQuotes,
});
// Synchronize caches and other processes
this.globalEventService.publishInternalEvent('localUserUpdated', { id: ps.userId });
});
}
}

View file

@ -143,6 +143,12 @@ export const meta = {
code: 'CONTAINS_TOO_MANY_MENTIONS',
id: '4de0363a-3046-481b-9b0f-feff3e211025',
},
quoteDisabledForUser: {
message: 'You do not have permission to create quote posts.',
code: 'QUOTE_DISABLED_FOR_USER',
id: '1c0ea108-d1e3-4e8e-aa3f-4d2487626153',
},
},
} as const;
@ -415,6 +421,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.containsProhibitedWords);
} else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
throw new ApiError(meta.errors.containsTooManyMentions);
} else if (e.id === '1c0ea108-d1e3-4e8e-aa3f-4d2487626153') {
throw new ApiError(meta.errors.quoteDisabledForUser);
}
}
throw e;

View file

@ -176,6 +176,12 @@ export const meta = {
id: '33510210-8452-094c-6227-4a6c05d99f02',
},
quoteDisabledForUser: {
message: 'You do not have permission to create quote posts.',
code: 'QUOTE_DISABLED_FOR_USER',
id: '1c0ea108-d1e3-4e8e-aa3f-4d2487626153',
},
containsProhibitedWords: {
message: 'Cannot post because it contains prohibited words.',
code: 'CONTAINS_PROHIBITED_WORDS',
@ -469,6 +475,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.containsProhibitedWords);
} else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
throw new ApiError(meta.errors.containsTooManyMentions);
} else if (e.id === '1c0ea108-d1e3-4e8e-aa3f-4d2487626153') {
throw new ApiError(meta.errors.quoteDisabledForUser);
}
}
throw e;

View file

@ -132,6 +132,10 @@ export const moderationLogTypes = [
'deletePage',
'deleteFlash',
'deleteGalleryPost',
'acceptQuotesUser',
'rejectQuotesUser',
'acceptQuotesInstance',
'rejectQuotesInstance',
] as const;
export type ModerationLogPayloads = {
@ -417,6 +421,24 @@ export type ModerationLogPayloads = {
postUserUsername: string;
post: any;
};
acceptQuotesUser: {
userId: string,
userUsername: string,
userHost: string | null,
};
rejectQuotesUser: {
userId: string,
userUsername: string,
userHost: string | null,
};
acceptQuotesInstance: {
id: string;
host: string;
};
rejectQuotesInstance: {
id: string;
host: string;
};
};
export type Serialized<T> = {

View file

@ -142,7 +142,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-ban"></i>
</button>
<button
v-if="canRenote && !props.mock"
v-if="canRenote && !props.mock && !$i?.rejectQuotes"
ref="quoteButton"
:class="$style.footerButton"
class="_button"

View file

@ -153,7 +153,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-ban"></i>
</button>
<button
v-if="canRenote"
v-if="canRenote && !$i?.rejectQuotes"
ref="quoteButton"
class="_button"
:class="$style.noteFooterButton"

View file

@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p>
</button>
<button
v-if="canRenote"
v-if="canRenote && !$i?.rejectQuotes"
ref="quoteButton"
class="_button"
:class="$style.noteFooterButton"

View file

@ -640,7 +640,7 @@ async function onPaste(ev: ClipboardEvent) {
const paste = ev.clipboardData.getData('text');
if (!renoteTargetNote.value && !quoteId.value && paste.startsWith(url + '/notes/')) {
if (!renoteTargetNote.value && !quoteId.value && paste.startsWith(url + '/notes/') && !$i.rejectQuotes) {
ev.preventDefault();
os.confirm({

View file

@ -0,0 +1,43 @@
<!--
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<!-- Match appearance of MkRemoteCaution.vue -->
<div v-for="error of displayErrors" :key="error" :class="$style.root">
<i :class="$style.icon" class="ti ti-alert-triangle"></i>{{ i18n.ts._processErrors[error] ?? error }}
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { i18n } from '@/i18n.js';
const props = defineProps<{
errors?: string[] | null;
}>();
const displayErrors = computed<Iterable<string>>(() => {
if (!props.errors?.length) return [];
// Set constructor preserve order, so we can sort first to avoid a copy operation.
return new Set(props.errors.toSorted());
});
</script>
<style module lang="scss">
.root {
font-size: 0.8em;
padding: 16px;
background: color-mix(in srgb, var(--MI_THEME-infoWarnBg) 65%, transparent);
color: var(--MI_THEME-infoWarnFg);
border-radius: var(--MI-radius);
overflow: clip;
z-index: 1;
}
.icon {
margin-right: 8px;
}
</style>

View file

@ -143,7 +143,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-ban"></i>
</button>
<button
v-if="canRenote && !props.mock"
v-if="canRenote && !props.mock && !$i?.rejectQuotes"
ref="quoteButton"
:class="$style.footerButton"
class="_button"

View file

@ -158,7 +158,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-ban"></i>
</button>
<button
v-if="canRenote"
v-if="canRenote && !$i?.rejectQuotes"
ref="quoteButton"
class="_button"
:class="$style.noteFooterButton"

View file

@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<p v-if="note.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ note.renoteCount }}</p>
</button>
<button
v-if="canRenote"
v-if="canRenote && !$i?.rejectQuotes"
ref="quoteButton"
class="_button"
:class="$style.noteFooterButton"

View file

@ -81,6 +81,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps">
<MkSwitch v-model="silenced" @update:modelValue="toggleSilence">{{ i18n.ts.silence }}</MkSwitch>
<MkSwitch v-if="!isSystem" v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch>
<MkSwitch v-model="rejectQuotes" @update:modelValue="toggleRejectQuotes">{{ i18n.ts.rejectQuotesUser }}</MkSwitch>
<MkSwitch v-model="markedAsNSFW" @update:modelValue="toggleNSFW">{{ i18n.ts.markAsNSFW }}</MkSwitch>
<MkInput v-model="mandatoryCW" type="text" manualSave @update:modelValue="onMandatoryCWChanged">
@ -247,6 +248,7 @@ const moderator = ref(false);
const silenced = ref(false);
const approved = ref(false);
const suspended = ref(false);
const rejectQuotes = ref(false);
const markedAsNSFW = ref(false);
const moderationNote = ref('');
const mandatoryCW = ref<string | null>(null);
@ -287,6 +289,7 @@ function createFetcher() {
approved.value = info.value.approved;
markedAsNSFW.value = info.value.alwaysMarkNsfw;
suspended.value = info.value.isSuspended;
rejectQuotes.value = user.value.rejectQuotes ?? false;
moderationNote.value = info.value.moderationNote;
mandatoryCW.value = user.value.mandatoryCW;
});
@ -368,6 +371,22 @@ async function toggleSuspend(v) {
}
}
async function toggleRejectQuotes(v: boolean): Promise<void> {
const confirm = await os.confirm({
type: 'warning',
text: v ? i18n.ts.rejectQuotesConfirm : i18n.ts.allowQuotesConfirm,
});
if (confirm.canceled) {
rejectQuotes.value = !v;
} else {
await misskeyApi('admin/reject-quotes', {
userId: props.userId,
rejectQuotes: v,
});
await refreshUser();
}
}
async function unsetUserAvatar() {
const confirm = await os.confirm({
type: 'warning',

View file

@ -28,6 +28,8 @@ SPDX-License-Identifier: AGPL-3.0-only
'unsetRemoteInstanceNSFW',
'rejectRemoteInstanceReports',
'acceptRemoteInstanceReports',
'rejectQuotesUser',
'acceptQuotesUser',
].includes(log.type),
[$style.logRed]: [
'suspend',
@ -53,6 +55,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="log.type === 'updateUserNote'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'suspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'approve'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'rejectQuotesUser'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'acceptQuotesUser'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'rejectQuotesInstance'">: {{ log.info.host }}</span>
<span v-else-if="log.type === 'acceptQuotesInstance'">: {{ log.info.host }}</span>
<span v-else-if="log.type === 'decline'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'unsuspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'resetPassword'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
@ -134,6 +140,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<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>
<template v-else-if="log.type === 'rejectQuotesUser'">
<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 === 'acceptQuotesUser'">
<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 === 'updateRole'">
<div :class="$style.diff">
<CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>

View file

@ -53,6 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo v-if="isBaseSilenced" warn>{{ i18n.ts.silencedByBase }}</MkInfo>
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance || isBaseSilenced" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
<MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">{{ i18n.ts.markInstanceAsNSFW }}</MkSwitch>
<MkSwitch v-model="rejectQuotes" :disabled="!instance" @update:modelValue="toggleRejectQuotes">{{ i18n.ts.rejectQuotesInstance }}</MkSwitch>
<MkSwitch v-model="rejectReports" :disabled="!instance" @update:modelValue="toggleRejectReports">{{ i18n.ts.rejectReports }}</MkSwitch>
<MkInfo v-if="isBaseMediaSilenced" warn>{{ i18n.ts.mediaSilencedByBase }}</MkInfo>
<MkSwitch v-model="isMediaSilenced" :disabled="!meta || !instance || isBaseMediaSilenced" @update:modelValue="toggleMediaSilenced">{{ i18n.ts.mediaSilenceThisInstance }}</MkSwitch>
@ -211,6 +212,7 @@ const isSuspended = ref(false);
const isBlocked = ref(false);
const isSilenced = ref(false);
const isNSFW = ref(false);
const rejectQuotes = ref(false);
const rejectReports = ref(false);
const isMediaSilenced = ref(false);
const faviconUrl = ref<string | null>(null);
@ -282,6 +284,7 @@ async function fetch(): Promise<void> {
isSilenced.value = instance.value?.isSilenced ?? false;
isNSFW.value = instance.value?.isNSFW ?? false;
rejectReports.value = instance.value?.rejectReports ?? false;
rejectQuotes.value = instance.value?.rejectQuotes ?? false;
isMediaSilenced.value = instance.value?.isMediaSilenced ?? false;
faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview');
moderationNote.value = instance.value?.moderationNote ?? '';
@ -347,6 +350,15 @@ async function toggleRejectReports(): Promise<void> {
});
}
async function toggleRejectQuotes(): Promise<void> {
if (!iAmModerator) return;
if (!instance.value) throw new Error('No instance?');
await misskeyApi('admin/federation/update-instance', {
host: instance.value.host,
rejectQuotes: rejectQuotes.value,
});
}
function refreshMetadata(): void {
if (!iAmModerator) return;
if (!instance.value) throw new Error('No instance?');

View file

@ -21,6 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div class="_margin _gaps_s">
<MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/>
<SkErrorList :errors="note.processErrors"/>
<MkNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note" :expandAllCws="expandAllCws"/>
</div>
<div v-if="clips && clips.length > 0" class="_margin">
@ -60,6 +61,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js';
import { dateString } from '@/filters/date.js';
import MkClipPreview from '@/components/MkClipPreview.vue';
import SkErrorList from '@/components/SkErrorList.vue';
import { defaultStore } from '@/store.js';
import { pleaseLogin } from '@/scripts/please-login.js';
import { serverContext, assertServerContext } from '@/server-context.js';
@ -69,9 +71,9 @@ import { $i } from '@/account.js';
const CTX_NOTE = !$i && assertServerContext(serverContext, 'note') ? serverContext.note : null;
const MkNoteDetailed = defineAsyncComponent(() =>
(defaultStore.state.noteDesign === 'misskey') ? import('@/components/MkNoteDetailed.vue') :
(defaultStore.state.noteDesign === 'sharkey') ? import('@/components/SkNoteDetailed.vue') :
null
(defaultStore.state.noteDesign === 'misskey')
? import('@/components/MkNoteDetailed.vue')
: import('@/components/SkNoteDetailed.vue'),
);
const props = defineProps<{

View file

@ -89,6 +89,7 @@ export const permissions = [
'write:admin:unset-user-avatar',
'write:admin:unset-user-banner',
'write:admin:unsuspend-user',
'write:admin:reject-quotes',
'write:admin:meta',
'write:admin:user-note',
'write:admin:roles',
@ -491,4 +492,22 @@ export type ModerationLogPayloads = {
postUserUsername: string;
post: GalleryPost;
};
acceptQuotesUser: {
userId: string,
userUsername: string,
userHost: string | null,
};
rejectQuotesUser: {
userId: string,
userUsername: string,
userHost: string | null,
};
acceptQuotesInstance: {
id: string;
host: string;
};
rejectQuotesInstance: {
id: string;
host: string;
};
};

View file

@ -60,6 +60,18 @@ export type ModerationLog = {
} | {
type: 'unsuspend';
info: ModerationLogPayloads['unsuspend'];
} | {
type: 'acceptQuotesUser';
info: ModerationLogPayloads['acceptQuotesUser'];
} | {
type: 'rejectQuotesUser';
info: ModerationLogPayloads['rejectQuotesUser'];
} | {
type: 'acceptQuotesInstance';
info: ModerationLogPayloads['acceptQuotesInstance'];
} | {
type: 'rejectQuotesInstance';
info: ModerationLogPayloads['rejectQuotesInstance'];
} | {
type: 'updateUserNote';
info: ModerationLogPayloads['updateUserNote'];

View file

@ -41,6 +41,10 @@ continueOnRemote: "Continue on remote instance"
chooseServerOnMisskeyHub: "Choose a instance from Misskey Hub"
mediaSilenceThisInstance: "Silence media from this instance"
rejectReports: "Reject reports from this instance"
rejectQuotesInstance: "Reject quote posts from this instance"
rejectQuotesUser: "Reject quote posts from this user"
rejectQuotesConfirm: "Are you sure you wish to reject quote posts?"
allowQuotesConfirm: "Are you sure you wish to allow quote posts?"
silencedInstancesDescription: "List the host names of the instances that you want to silence, separated by a new line. All accounts belonging to the listed instances will be treated as silenced, and can only make follow requests, and cannot mention local accounts if not followed. This will not affect the blocked instances."
mediaSilencedInstances: "Media-silenced instances"
mediaSilencedInstancesDescription: "List the host names of the instances that you want to media-silence, separated by a new line. All accounts belonging to the listed instances will be treated as sensitive, and can't use custom emojis. This will not affect the blocked instances."
@ -309,6 +313,8 @@ _moderationLogTypes:
unsetRemoteInstanceNSFW: "Set remote instance as NSFW"
rejectRemoteInstanceReports: "Rejected reports from remote instance"
acceptRemoteInstanceReports: "Accepted reports from remote instance"
rejectQuotesUser: "Rejected quotes from user"
allowQuotesUser: "Allowed quotes from user"
_mfm:
uncommonFeature: "This is not a widespread feature, it may not display properly on most other fedi software, including other Misskey forks"
intro: "MFM is a markup language used on Misskey, Sharkey, Firefish, Akkoma, and more that can be used in many places. Here you can view a list of all available MFM syntax."
@ -441,6 +447,7 @@ _permissions:
"write:admin:cw-user": "Apply mandatory CW on users"
"write:admin:silence-user": "Silence users"
"write:admin:unsilence-user": "Un-silence users"
"write:admin:reject-quotes": "Allow/Reject quote posts from a user"
"read:notes-schedule": "View your list of scheduled notes"
"write:notes-schedule": "Compose or delete scheduled notes"
@ -477,3 +484,6 @@ 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."
_processErrors:
quoteUnavailable: "Unable to process quote. This post may be missing context."