merge: Add "reject quotes" settings (!901)

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

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Hazelnoot 2025-03-01 03:33:06 +00:00
commit 14a81b4f85
49 changed files with 693 additions and 100 deletions

View file

@ -102,7 +102,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false">
@ -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

@ -117,7 +117,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
</div>
@ -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

@ -87,20 +87,22 @@ SPDX-License-Identifier: AGPL-3.0-only
import { defineAsyncComponent, onDeactivated, onUnmounted, ref, watch } from 'vue';
import { url as local } from '@@/js/config.js';
import { versatileLang } from '@@/js/intl-const.js';
import * as Misskey from 'misskey-js';
import type { summaly } from '@misskey-dev/summaly';
import type MkNoteSimple from '@/components/MkNoteSimple.vue';
import type SkNoteSimple from '@/components/SkNoteSimple.vue';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { deviceKind } from '@/scripts/device-kind.js';
import MkButton from '@/components/MkButton.vue';
import { transformPlayerUrl } from '@/scripts/player-url-transform.js';
import { defaultStore } from '@/store.js';
import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/scripts/misskey-api.js';
const XNoteSimple = defineAsyncComponent(() =>
(defaultStore.state.noteDesign === 'misskey') ? import('@/components/MkNoteSimple.vue') :
(defaultStore.state.noteDesign === 'sharkey') ? import('@/components/SkNoteSimple.vue') :
null
const XNoteSimple = defineAsyncComponent<typeof MkNoteSimple | typeof SkNoteSimple>(() =>
defaultStore.state.noteDesign === 'misskey'
? import('@/components/MkNoteSimple.vue')
: import('@/components/SkNoteSimple.vue'),
);
type SummalyResult = Awaited<ReturnType<typeof summaly>>;
@ -111,12 +113,13 @@ const props = withDefaults(defineProps<{
compact?: boolean;
showAsQuote?: boolean;
showActions?: boolean;
skipNoteIds?: string[];
skipNoteIds?: (string | undefined)[];
}>(), {
detail: false,
compact: false,
showAsQuote: false,
showActions: true,
skipNoteIds: undefined,
});
const MOBILE_THRESHOLD = 500;

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

@ -104,7 +104,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false">
@ -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

@ -122,7 +122,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
</div>
@ -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

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps" :class="$style.textRoot">
<Mfm :text="block.text ?? ''" :isBlock="true" :isNote="false"/>
<div v-if="isEnabledUrlPreview" class="_gaps_s">
<MkUrlPreview v-for="url in urls" :key="url" :url="url"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :showAsQuote="!page.user.rejectQuotes"/>
</div>
</div>
</template>

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">{{ user.host == null ? i18n.ts.rejectQuotesLocalUser : i18n.ts.rejectQuotesRemoteUser }}</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<{