merge: Fix regressions and missing parts of recent work (!1102)

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

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Hazelnoot 2025-06-08 17:42:29 +00:00
commit cb8ae13685
24 changed files with 349 additions and 203 deletions

12
locales/index.d.ts vendored
View file

@ -13245,6 +13245,18 @@ export interface Locale extends ILocale {
* Note controls
*/
"noteFooterLabel": string;
/**
* Packed user data in its raw form. Most of these fields are public and visible to all users.
*/
"rawUserDescription": string;
/**
* Extended user data in its raw form. These fields are private and can only be accessed by moderators.
*/
"rawInfoDescription": string;
/**
* ActivityPub user data in its raw form. These fields are public and accessible to other instances.
*/
"rawApDescription": string;
}
declare const locales: {
[lang: string]: Locale;

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class RemoveIDXInstanceHostFilters1749267016885 {
async up(queryRunner) {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_instance_host_filters"`);
}
async down(queryRunner) {
await queryRunner.query(`
create index "IDX_instance_host_filters"
on "instance" ("host", "isBlocked", "isSilenced", "isMediaSilenced", "isAllowListed", "isBubbled", "suspensionState")`);
await queryRunner.query(`comment on index "IDX_instance_host_filters" is 'Covering index for host filter queries'`);
}
}

View file

@ -354,7 +354,7 @@ export class Resolver {
switch (parsed.type) {
case 'notes':
return this.notesRepository.findOneByOrFail({ id: parsed.id })
return this.notesRepository.findOneByOrFail({ id: parsed.id, userHost: IsNull() })
.then(async note => {
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
if (parsed.rest === 'activity') {
@ -365,18 +365,22 @@ export class Resolver {
}
}) as Promise<IObjectWithId>;
case 'users':
return this.usersRepository.findOneByOrFail({ id: parsed.id })
return this.usersRepository.findOneByOrFail({ id: parsed.id, host: IsNull() })
.then(user => this.apRendererService.renderPerson(user as MiLocalUser));
case 'questions':
// Polls are indexed by the note they are attached to.
return Promise.all([
this.notesRepository.findOneByOrFail({ id: parsed.id }),
this.pollsRepository.findOneByOrFail({ noteId: parsed.id }),
this.notesRepository.findOneByOrFail({ id: parsed.id, userHost: IsNull() }),
this.pollsRepository.findOneByOrFail({ noteId: parsed.id, userHost: IsNull() }),
])
.then(([note, poll]) => this.apRendererService.renderQuestion({ id: note.userId }, note, poll)) as Promise<IObjectWithId>;
case 'likes':
return this.noteReactionsRepository.findOneByOrFail({ id: parsed.id }).then(async reaction =>
this.apRendererService.addContext(await this.apRendererService.renderLike(reaction, { uri: null })));
return this.noteReactionsRepository.findOneOrFail({ where: { id: parsed.id }, relations: { user: true } }).then(async reaction => {
if (reaction.user?.host == null) {
throw new IdentifiableError('02b40cd0-fa92-4b0c-acc9-fb2ada952ab8', `failed to resolve local ${url}: not a local reaction`);
}
return this.apRendererService.addContext(await this.apRendererService.renderLike(reaction, { uri: null }));
});
case 'follows':
return this.followRequestsRepository.findOneBy({ id: parsed.id })
.then(async followRequest => {

View file

@ -17,6 +17,7 @@ import { DebounceLoader } from '@/misc/loader.js';
import { IdService } from '@/core/IdService.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { isPackedPureRenote } from '@/misc/is-renote.js';
import type { Config } from '@/config.js';
import type { OnModuleInit } from '@nestjs/common';
import type { CacheService } from '../CacheService.js';
import type { CustomEmojiService } from '../CustomEmojiService.js';
@ -92,6 +93,9 @@ export class NoteEntityService implements OnModuleInit {
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
@Inject(DI.config)
private readonly config: Config
//private userEntityService: UserEntityService,
//private driveFileEntityService: DriveFileEntityService,
//private customEmojiService: CustomEmojiService,
@ -680,4 +684,9 @@ export class NoteEntityService implements OnModuleInit {
return map;
}, {} as Record<string, string | undefined>);
}
@bindThis
public genLocalNoteUri(noteId: string): string {
return `${this.config.url}/notes/${noteId}`;
}
}

View file

@ -7,7 +7,6 @@ import { Entity, PrimaryColumn, Index, Column } from 'typeorm';
import { id } from './util/id.js';
@Index('IDX_instance_host_key', { synchronize: false }) // ((lower(reverse("host"::text)) || '.'::text)
@Index('IDX_instance_host_filters', { synchronize: false }) // ("host", "isBlocked", "isSilenced", "isMediaSilenced", "isAllowListed", "isBubbled", "suspensionState")
@Entity('instance')
export class MiInstance {
@PrimaryColumn(id())

View file

@ -3,11 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ApResolverService } from '@/core/activitypub/ApResolverService.js';
import { isCollectionOrOrderedCollection, isOrderedCollection, isOrderedCollectionPage } from '@/core/activitypub/type.js';
import { ApiError } from '@/server/api/error.js';
import { CacheService } from '@/core/CacheService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js';
import type { NotesRepository } from '@/models/_.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
export const meta = {
tags: ['federation'],
@ -22,6 +28,16 @@ export const meta = {
},
errors: {
noInputSpecified: {
message: 'uri, userId, or noteId must be specified.',
code: 'NO_INPUT_SPECIFIED',
id: 'b43ff2a7-e7a2-4237-ad7f-7b079563c09e',
},
multipleInputsSpecified: {
message: 'Only one of uri, userId, or noteId can be specified',
code: 'MULTIPLE_INPUTS_SPECIFIED',
id: 'f1aa27ed-8f20-44f3-a92a-fe073c8ca52b',
},
},
res: {
@ -33,22 +49,46 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {
uri: { type: 'string' },
uri: { type: 'string', nullable: true },
userId: { type: 'string', format: 'misskey:id', nullable: true },
noteId: { type: 'string', format: 'misskey:id', nullable: true },
expandCollectionItems: { type: 'boolean' },
expandCollectionLimit: { type: 'integer', nullable: true },
allowAnonymous: { type: 'boolean' },
},
required: ['uri'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.notesRepository)
private readonly notesRepository: NotesRepository,
private readonly cacheService: CacheService,
private readonly userEntityService: UserEntityService,
private readonly noteEntityService: NoteEntityService,
private apResolverService: ApResolverService,
) {
super(meta, paramDef, async (ps, me) => {
super(meta, paramDef, async (ps) => {
if (ps.uri && ps.userId && ps.noteId) {
throw new ApiError(meta.errors.multipleInputsSpecified);
}
let uri: string;
if (ps.uri) {
uri = ps.uri;
} else if (ps.userId) {
const user = await this.cacheService.findUserById(ps.userId);
uri = user.uri ?? this.userEntityService.genLocalUserUri(ps.userId);
} else if (ps.noteId) {
const note = await this.notesRepository.findOneByOrFail({ id: ps.noteId });
uri = note.uri ?? this.noteEntityService.genLocalNoteUri(ps.noteId);
} else {
throw new ApiError(meta.errors.noInputSpecified);
}
const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(ps.uri, ps.allowAnonymous ?? false);
const object = await resolver.resolve(uri, ps.allowAnonymous ?? false);
if (ps.expandCollectionItems && isCollectionOrOrderedCollection(object)) {
const items = await resolver.resolveCollectionItems(object, ps.expandCollectionLimit, ps.allowAnonymous ?? false);

View file

@ -20,9 +20,7 @@ export const meta = {
},
},
res: {
type: 'object',
},
res: {},
// 10 calls per 5 seconds
limit: {

View file

@ -270,7 +270,7 @@ export const paramDef = {
minLength: 1,
maxLength: 128,
},
maxLength: 32,
maxItems: 32,
},
},
} as const;

View file

@ -32,6 +32,7 @@ import type { MiLocalUser } from '@/models/User.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import { isRetryableError } from '@/misc/is-retryable-error.js';
import * as Acct from '@/misc/acct.js';
import { isNote } from '@/core/activitypub/type.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
export type LocalSummalyResult = SummalyResult & {
@ -42,7 +43,7 @@ export type LocalSummalyResult = SummalyResult & {
};
// Increment this to invalidate cached previews after a major change.
const cacheFormatVersion = 3;
const cacheFormatVersion = 4;
type PreviewRoute = {
Querystring: {
@ -409,7 +410,7 @@ export class UrlPreviewService {
// Finally, attempt a signed GET in case it's a direct link to an instance with authorized fetch.
const instanceActor = await this.systemAccountService.getInstanceActor();
const remoteObject = await this.apRequestService.signedGet(summary.url, instanceActor).catch(() => null);
if (remoteObject && this.apUtilityService.haveSameAuthority(remoteObject.id, summary.url)) {
if (remoteObject && isNote(remoteObject) && this.apUtilityService.haveSameAuthority(remoteObject.id, summary.url)) {
summary.activityPub = remoteObject.id;
return;
}

View file

@ -95,7 +95,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="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :class="$style.urlPreview" @click.stop/>
<SkUrlPreviewGroup :sourceUrls="urls" :sourceNote="appearNote" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :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">
@ -237,6 +237,7 @@ import SkMutedNote from '@/components/SkMutedNote.vue';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -304,9 +305,9 @@ const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(prefer.s.uncollapseCW);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : []);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
const isLong = shouldCollapsed(appearNote.value, urls.value);
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);
const isDeleted = ref(false);
const renoted = ref(false);

View file

@ -112,7 +112,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="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/>
<SkUrlPreviewGroup :sourceNodes="nodes" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;" @click.stop/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
</div>
@ -286,7 +286,7 @@ import { DI } from '@/di.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -339,8 +339,7 @@ const isDeleted = ref(false);
const renoted = ref(false);
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : []);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm);

View file

@ -97,7 +97,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="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :class="$style.urlPreview" @click.stop/>
<SkUrlPreviewGroup :sourceUrls="urls" :sourceNote="appearNote" :compact="true" :detail="false" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" :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">
@ -237,6 +237,7 @@ import SkMutedNote from '@/components/SkMutedNote.vue';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -304,9 +305,9 @@ const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(prefer.s.uncollapseCW);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : []);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
const isLong = shouldCollapsed(appearNote.value, urls.value);
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);
const isDeleted = ref(false);
const renoted = ref(false);

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="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/>
<SkUrlPreviewGroup :sourceNodes="nodes" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;" @click.stop/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
</div>
@ -291,7 +291,7 @@ import { DI } from '@/di.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids.js';
import { extractPreviewUrls } from '@/utility/extract-preview-urls';
import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -345,8 +345,7 @@ const isDeleted = ref(false);
const renoted = ref(false);
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : []);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
const allowAnim = ref(prefer.s.advancedMfm && prefer.s.animatedMfm ? true : false);

View file

@ -40,14 +40,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-show="appearNote.cw == 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 v-if="appearNote.text" :text="appearNote.text" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
<Mfm v-if="appearNote.text" :text="appearNote.text" :parsedNodes="parsed" :isBlock="true" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList :mediaList="appearNote.files"/>
</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"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;"/>
<SkUrlPreviewGroup :sourceNodes="parsed" :sourceNote="appearNote" :compact="true" :detail="true" :showAsQuote="!appearNote.user.rejectQuotes" :skipNoteIds="selfNoteIds" style="margin-top: 6px;" @click.stop/>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
@ -83,7 +83,6 @@ import MkMediaList from '@/components/MkMediaList.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import MkWindow from '@/components/MkWindow.vue';
import MkPoll from '@/components/MkPoll.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { userPage } from '@/filters/user.js';
import { i18n } from '@/i18n.js';
@ -93,7 +92,7 @@ import { prefer } from '@/preferences';
import { getPluginHandlers } from '@/plugin.js';
import SkNoteTranslation from '@/components/SkNoteTranslation.vue';
import { getSelfNoteIds } from '@/utility/get-self-note-ids';
import { extractPreviewUrls } from '@/utility/extract-preview-urls.js';
import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
const props = defineProps<{
note: Misskey.entities.Note;
@ -143,12 +142,11 @@ const isRenote = (
const el = shallowRef<HTMLElement>();
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : []);
const showContent = ref(false);
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
const translating = ref(false);
const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : null);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);

View file

@ -7,29 +7,20 @@ 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" :showAsQuote="!page.user.rejectQuotes"/>
<SkUrlPreviewGroup :sourceText="block.text" :showAsQuote="!page.user.rejectQuotes" @click.stop/>
</div>
</div>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, computed } from 'vue';
import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { isEnabledUrlPreview } from '@/instance.js';
import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue'));
const props = defineProps<{
defineProps<{
block: Misskey.entities.PageBlock,
page: Misskey.entities.Page,
}>();
const urls = computed(() => {
if (!props.block.text) return [];
return extractUrlFromMfm(mfm.parse(props.block.text));
});
</script>
<style lang="scss" module>

View file

@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :spacer="true" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<div>
<FormSuspense :p="init">
<FormSuspense v-if="init" :p="init">
<div v-if="user && info">
<div v-if="tab === 'overview'" class="_gaps">
<div v-if="user" class="aeakzknw">
<div class="aeakzknw">
<MkAvatar class="avatar" :user="user" indicator link preview/>
<div class="body">
<span class="name"><MkUserName class="name" :user="user"/></span>
@ -228,14 +228,46 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-else-if="tab === 'raw'" class="_gaps_m">
<MkObjectView v-if="info && $i.isAdmin" tall :value="info">
</MkObjectView>
<MkFolder :sticky="false" :defaultOpen="true">
<template #icon><i class="ph-user-circle ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.user }}</template>
<template #header>
<div :class="$style.rawFolderHeader">
<span>{{ i18n.ts.rawUserDescription }}</span>
<button class="_textButton" @click="copyToClipboard(JSON.stringify(user, null, 4))"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</button>
</div>
</template>
<MkObjectView tall :value="user">
</MkObjectView>
<MkObjectView tall :value="user"/>
</MkFolder>
<MkFolder :sticky="false">
<template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.details }}</template>
<template #header>
<div :class="$style.rawFolderHeader">
<span>{{ i18n.ts.rawInfoDescription }}</span>
<button class="_textButton" @click="copyToClipboard(JSON.stringify(info, null, 4))"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</button>
</div>
</template>
<MkObjectView tall :value="info"/>
</MkFolder>
<MkFolder v-if="ap" :sticky="false">
<template #icon><i class="ph-globe ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.activityPub }}</template>
<template #header>
<div :class="$style.rawFolderHeader">
<span>{{ i18n.ts.rawApDescription }}</span>
<button class="_textButton" @click="copyToClipboard(JSON.stringify(ap, null, 4))"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</button>
</div>
</template>
<MkObjectView tall :value="ap"/>
</MkFolder>
</div>
</FormSuspense>
</div>
</div>
</FormSuspense>
</PageWithHeader>
</template>
@ -244,6 +276,7 @@ import { computed, defineAsyncComponent, watch, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { url } from '@@/js/config.js';
import type { Badge } from '@/components/SkBadgeStrip.vue';
import type { ChartSrc } from '@/components/MkChart.vue';
import MkChart from '@/components/MkChart.vue';
import MkObjectView from '@/components/MkObjectView.vue';
import MkTextarea from '@/components/MkTextarea.vue';
@ -276,15 +309,17 @@ const props = withDefaults(defineProps<{
userHint?: Misskey.entities.UserDetailed;
infoHint?: Misskey.entities.AdminShowUserResponse;
ipsHint?: Misskey.entities.AdminGetUserIpsResponse;
apHint?: Misskey.entities.ApGetResponse;
}>(), {
initialTab: 'overview',
userHint: undefined,
infoHint: undefined,
ipsHint: undefined,
apHint: undefined,
});
const tab = ref(props.initialTab);
const chartSrc = ref('per-user-notes');
const chartSrc = ref<ChartSrc>('per-user-notes');
const user = ref<null | Misskey.entities.UserDetailed>();
const init = ref<ReturnType<typeof createFetcher>>();
const info = ref<Misskey.entities.AdminShowUserResponse | null>(null);
@ -409,7 +444,7 @@ const announcementsPagination = {
status: announcementsStatus.value,
})),
};
const expandedRoles = ref([]);
const expandedRoles = ref<string[]>([]);
function createFetcher(withHint = true) {
return () => Promise.all([
@ -424,22 +459,23 @@ function createFetcher(withHint = true) {
userId: props.userId,
})
: null,
iAmAdmin ? misskeyApi('ap/get', {
uri: `${url}/users/${props.userId}`,
}).catch(() => null) : null],
).then(([_user, _info, _ips, _ap]) => {
iAmAdmin
? (withHint && props.apHint) ? props.apHint : misskeyApi('ap/get', {
userId: props.userId,
}).catch(() => null) : null],
).then(async ([_user, _info, _ips, _ap]) => {
user.value = _user;
info.value = _info;
ips.value = _ips;
ap.value = _ap;
moderator.value = info.value.isModerator;
silenced.value = info.value.isSilenced;
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;
moderator.value = _info.isModerator;
silenced.value = _info.isSilenced;
approved.value = _info.approved;
markedAsNSFW.value = _info.alwaysMarkNsfw;
suspended.value = _info.isSuspended;
rejectQuotes.value = _user.rejectQuotes ?? false;
moderationNote.value = _info.moderationNote;
mandatoryCW.value = _user.mandatoryCW;
});
}
@ -448,9 +484,9 @@ async function refreshUser() {
await createFetcher(false)();
}
async function onMandatoryCWChanged(value: string) {
async function onMandatoryCWChanged(value: string | number) {
await os.promiseDialog(async () => {
await misskeyApi('admin/cw-user', { userId: props.userId, cw: value });
await misskeyApi('admin/cw-user', { userId: props.userId, cw: String(value) });
await refreshUser();
});
}
@ -458,14 +494,14 @@ async function onMandatoryCWChanged(value: string) {
async function onModerationNoteChanged(value: string) {
await os.promiseDialog(async () => {
await misskeyApi('admin/update-user-note', { userId: props.userId, text: value });
refreshUser();
await refreshUser();
});
}
async function updateRemoteUser() {
await os.promiseDialog(async () => {
await misskeyApi('federation/update-remote-user', { userId: props.userId });
refreshUser();
await refreshUser();
});
}
@ -478,7 +514,7 @@ async function resetPassword() {
return;
} else {
const { password } = await misskeyApi('admin/reset-password', {
userId: user.value.id,
userId: props.userId,
});
await os.alert({
type: 'success',
@ -590,15 +626,16 @@ async function deleteAccount() {
text: i18n.ts.deleteThisAccountConfirm,
});
if (confirm.canceled) return;
if (!user.value) return;
const typed = await os.inputText({
text: i18n.tsx.typeToConfirm({ x: user.value?.username }),
text: i18n.tsx.typeToConfirm({ x: user.value.username }),
});
if (typed.canceled) return;
if (typed.result === user.value?.username) {
if (typed.result === user.value.username) {
await os.apiWithDialog('admin/delete-account', {
userId: user.value.id,
userId: props.userId,
});
} else {
await os.alert({
@ -661,7 +698,7 @@ async function unassignRole(role, ev) {
}], ev.currentTarget ?? ev.target);
}
function toggleRoleItem(role) {
function toggleRoleItem(role: Misskey.entities.Role) {
if (expandedRoles.value.includes(role.id)) {
expandedRoles.value = expandedRoles.value.filter(x => x !== role.id);
} else {
@ -670,6 +707,7 @@ function toggleRoleItem(role) {
}
function createAnnouncement() {
if (!user.value) return;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
user: user.value,
}, {
@ -678,6 +716,7 @@ function createAnnouncement() {
}
function editAnnouncement(announcement) {
if (!user.value) return;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
user: user.value,
announcement,
@ -883,4 +922,13 @@ definePage(() => ({
margin: calc(var(--MI-margin) / 2);
}
}
.rawFolderHeader {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
padding: var(--MI-marginHalf);
gap: var(--MI-marginHalf);
}
</style>

View file

@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="text"
class="_selectable"
:text="message.text"
:parsedNotes="parsed"
:i="$i"
:nyaize="'respect'"
:enableEmojiMenu="true"
@ -21,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
/>
<MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/>
</MkFukidashi>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :showAsQuote="!message.fromUser.rejectQuotes" style="margin: 8px 0;"/>
<SkUrlPreviewGroup :sourceNodes="parsed" :showAsQuote="!message.fromUser.rejectQuotes" style="margin: 8px 0;"/>
<div :class="$style.footer">
<button class="_textButton" style="color: currentColor;" @click="showMenu"><i class="ti ti-dots-circle-horizontal"></i></button>
<MkTime :class="$style.time" :time="message.createdAt"/>
@ -58,8 +59,6 @@ import { url } from '@@/js/config.js';
import { isLink } from '@@/js/is-link.js';
import type { MenuItem } from '@/types/menu.js';
import type { NormalizedChatMessage } from './room.vue';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import { ensureSignin } from '@/i.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
@ -74,6 +73,7 @@ import { prefer } from '@/preferences.js';
import { DI } from '@/di.js';
import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js';
import SkTransitionGroup from '@/components/SkTransitionGroup.vue';
import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
const $i = ensureSignin();
@ -83,7 +83,7 @@ const props = defineProps<{
}>();
const isMe = computed(() => props.message.fromUserId === $i.id);
const urls = computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
const parsed = computed(() => props.message.text ? mfm.parse(props.message.text) : []);
provide(DI.mfmEmojiReactCallback, (reaction) => {
if ($i.policies.chatAvailability !== 'available') return;

View file

@ -23,24 +23,8 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { miLocalStorage } from '@/local-storage.js';
import { prefer } from '@/preferences.js';
import { reloadAsk } from '@/utility/reload-ask';
const customCssModel = prefer.model('customCss');
const localCustomCss = computed<string>({
get() {
return customCssModel.value ?? miLocalStorage.getItem('customCss') ?? '';
},
set(newCustomCss) {
customCssModel.value = newCustomCss;
if (newCustomCss) {
miLocalStorage.setItem('customCss', newCustomCss);
} else {
miLocalStorage.removeItem('customCss');
}
reloadAsk(true);
},
});
const localCustomCss = prefer.model('customCss');
const headerActions = computed(() => []);

View file

@ -1059,68 +1059,13 @@ const clickToOpen = prefer.model('clickToOpen');
const useCustomSearchEngine = computed(() => !Object.keys(searchEngineMap).includes(searchEngine.value));
const defaultCW = ref($i.defaultCW);
const defaultCWPriority = ref($i.defaultCWPriority);
const langModel = prefer.model('lang');
const lang = computed<string>({
get() {
return langModel.value ?? miLocalStorage.getItem('lang') ?? 'en-US';
},
set(newLang) {
langModel.value = newLang;
miLocalStorage.setItem('lang', newLang);
miLocalStorage.removeItem('locale');
miLocalStorage.removeItem('localeVersion');
},
});
const fontSizeModel = prefer.model('fontSize');
const fontSize = computed<'0' | '1' | '2' | '3'>({
get() {
return fontSizeModel.value ?? miLocalStorage.getItem('fontSize') ?? '0';
},
set(newFontSize) {
fontSizeModel.value = newFontSize;
if (newFontSize !== '0') {
miLocalStorage.setItem('fontSize', newFontSize);
} else {
miLocalStorage.removeItem('fontSize');
}
},
});
const useSystemFontModel = prefer.model('useSystemFont');
const useSystemFont = computed<boolean>({
get() {
return useSystemFontModel.value ?? (miLocalStorage.getItem('useSystemFont') != null);
},
set(newUseSystemFont) {
useSystemFontModel.value = newUseSystemFont;
if (newUseSystemFont) {
miLocalStorage.setItem('useSystemFont', 't');
} else {
miLocalStorage.removeItem('useSystemFont');
}
},
});
const cornerRadiusModel = prefer.model('cornerRadius');
const cornerRadius = computed<'misskey' | 'sharkey'>({
get() {
return cornerRadiusModel.value ?? miLocalStorage.getItem('cornerRadius') ?? 'sharkey';
},
set(newCornerRadius) {
cornerRadiusModel.value = newCornerRadius;
if (newCornerRadius === 'sharkey') {
miLocalStorage.removeItem('cornerRadius');
} else {
miLocalStorage.setItem('cornerRadius', newCornerRadius);
}
},
});
const lang = prefer.model('lang');
const fontSize = prefer.model('fontSize');
const useSystemFont = prefer.model('useSystemFont');
const cornerRadius = prefer.model('cornerRadius');
watch([
hemisphere,
lang,
enableInfiniteScroll,
showNoteActionsOnlyHover,
overridedDeviceKind,
@ -1142,9 +1087,6 @@ watch([
useStickyIcons,
keepScreenOn,
contextMenu,
fontSize,
useSystemFont,
cornerRadius,
makeEveryTextElementsSelectable,
noteDesign,
], async () => {

View file

@ -13,15 +13,18 @@ import { deckStore } from '@/ui/deck/deck-store.js';
import { unisonReload } from '@/utility/unison-reload.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage';
// TODO: そのうち消す
export function migrateOldSettings() {
os.waiting(i18n.ts.settingsMigrating);
store.loaded.then(async () => {
misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }).catch(() => []).then((themes: Theme[]) => {
if (themes.length > 0) {
prefer.commit('themes', themes);
prefer.suppressReload();
await misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }).catch(() => []).then(themes => {
if (Array.isArray(themes) && themes.length > 0) {
prefer.commit('themes', themes as Theme[]);
}
});
@ -33,7 +36,7 @@ export function migrateOldSettings() {
})));
prefer.commit('deck.profile', deckStore.s.profile);
misskeyApi('i/registry/keys', {
await misskeyApi('i/registry/keys', {
scope: ['client', 'deck', 'profiles'],
}).then(async keys => {
const profiles: DeckProfile[] = [];
@ -41,16 +44,18 @@ export function migrateOldSettings() {
const deck = await misskeyApi('i/registry/get', {
scope: ['client', 'deck', 'profiles'],
key: key,
});
profiles.push({
id: uuid(),
name: key,
columns: deck.columns,
layout: deck.layout,
});
}).catch(() => null);
if (deck) {
profiles.push({
id: uuid(),
name: key,
columns: (deck as DeckProfile).columns,
layout: (deck as DeckProfile).layout,
});
}
}
prefer.commit('deck.profiles', profiles);
});
}).catch(() => null);
prefer.commit('lightTheme', ColdDeviceStorage.get('lightTheme'));
prefer.commit('darkTheme', ColdDeviceStorage.get('darkTheme'));
@ -164,8 +169,17 @@ export function migrateOldSettings() {
prefer.commit('warnMissingAltText', store.s.warnMissingAltText);
//#endregion
window.setTimeout(() => {
unisonReload();
}, 10000);
//#region Hybrid migrations
prefer.commit('fontSize', miLocalStorage.getItem('fontSize') ?? '0');
prefer.commit('useSystemFont', miLocalStorage.getItem('useSystemFont') != null);
prefer.commit('cornerRadius', miLocalStorage.getItem('cornerRadius') ?? 'sharkey');
prefer.commit('lang', miLocalStorage.getItem('lang') ?? 'en-US');
prefer.commit('customCss', miLocalStorage.getItem('customCss') ?? '');
prefer.commit('neverShowDonationInfo', miLocalStorage.getItem('neverShowDonationInfo') != null);
prefer.commit('neverShowLocalOnlyInfo', miLocalStorage.getItem('neverShowLocalOnlyInfo') != null);
//#endregion
prefer.allowReload();
unisonReload();
});
}

View file

@ -10,11 +10,12 @@ import type { SoundType } from '@/utility/sound.js';
import type { Plugin } from '@/plugin.js';
import type { DeviceKind } from '@/utility/device-kind.js';
import type { DeckProfile } from '@/deck.js';
import type { PreferencesDefinition } from './manager.js';
import type { Pref, PreferencesDefinition } from './manager.js';
import type { FollowingFeedState } from '@/types/following-feed.js';
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
import { searchEngineMap } from '@/utility/search-engine-map.js';
import { defaultFollowingFeedState } from '@/types/following-feed.js';
import { miLocalStorage } from '@/local-storage';
/** サウンド設定 */
export type SoundStore = {
@ -484,25 +485,77 @@ export const PREF_DEF = {
// Null means "fall back to existing value from localStorage"
// For all of these preferences, "null" means fall back to existing value in localStorage.
fontSize: {
default: null as null | '0' | '1' | '2' | '3',
},
default: '0',
needsReload: true,
onSet: fontSize => {
if (fontSize !== '0') {
miLocalStorage.setItem('fontSize', fontSize);
} else {
miLocalStorage.removeItem('fontSize');
}
},
} as Pref<'0' | '1' | '2' | '3'>,
useSystemFont: {
default: null as null | boolean,
},
default: false,
needsReload: true,
onSet: useSystemFont => {
if (useSystemFont) {
miLocalStorage.setItem('useSystemFont', 't');
} else {
miLocalStorage.removeItem('useSystemFont');
}
},
} as Pref<boolean>,
cornerRadius: {
default: null as null | 'misskey' | 'sharkey',
},
default: 'sharkey',
needsReload: true,
onSet: cornerRadius => {
if (cornerRadius === 'sharkey') {
miLocalStorage.removeItem('cornerRadius');
} else {
miLocalStorage.setItem('cornerRadius', cornerRadius);
}
},
} as Pref<'misskey' | 'sharkey'>,
lang: {
default: null as null | string,
},
default: 'en-US',
needsReload: true,
onSet: lang => {
miLocalStorage.setItem('lang', lang);
miLocalStorage.removeItem('locale');
miLocalStorage.removeItem('localeVersion');
},
} as Pref<string>,
customCss: {
default: null as null | string,
},
default: '',
needsReload: true,
onSet: customCss => {
if (customCss) {
miLocalStorage.setItem('customCss', customCss);
} else {
miLocalStorage.removeItem('customCss');
}
},
} as Pref<string>,
neverShowDonationInfo: {
default: null as null | 'true',
},
default: false,
onSet: neverShowDonationInfo => {
if (neverShowDonationInfo) {
miLocalStorage.setItem('neverShowDonationInfo', 'true');
} else {
miLocalStorage.removeItem('neverShowDonationInfo');
}
},
} as Pref<boolean>,
neverShowLocalOnlyInfo: {
default: null as null | 'true',
},
default: false,
onSet: neverShowLocalOnlyInfo => {
if (neverShowLocalOnlyInfo) {
miLocalStorage.setItem('neverShowLocalOnlyInfo', 'true');
} else {
miLocalStorage.removeItem('neverShowLocalOnlyInfo');
}
},
} as Pref<boolean>,
//#endregion
} satisfies PreferencesDefinition;

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { computed, onUnmounted, ref, watch } from 'vue';
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
import { v4 as uuid } from 'uuid';
import { host, version } from '@@/js/config.js';
import { PREF_DEF } from './def.js';
@ -14,6 +14,7 @@ import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { deepEqual } from '@/utility/deep-equal.js';
import { reloadAsk } from '@/utility/reload-ask';
// NOTE: 明示的な設定値のひとつとして null もあり得るため、設定が存在しないかどうかを判定する目的で null で比較したり ?? を使ってはいけない
@ -84,16 +85,29 @@ export type StorageProvider = {
cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; }) => Promise<void>;
};
export type PreferencesDefinition = Record<string, {
default: any;
export type Pref<T> = {
default: T;
accountDependent?: boolean;
serverDependent?: boolean;
}>;
needsReload?: boolean;
onSet?: (value: T) => void;
};
export type PreferencesDefinition = Record<string, Pref<any> | undefined>;
export class PreferencesManager {
private storageProvider: StorageProvider;
public profile: PreferencesProfile;
public cloudReady: Promise<void>;
private enableReload = true;
public suppressReload() {
this.enableReload = false;
}
public allowReload() {
this.enableReload = true;
}
/**
* static / state (static )
@ -126,11 +140,11 @@ export class PreferencesManager {
}
private isAccountDependentKey<K extends keyof PREF>(key: K): boolean {
return (PREF_DEF as PreferencesDefinition)[key].accountDependent === true;
return (PREF_DEF as PreferencesDefinition)[key]?.accountDependent === true;
}
private isServerDependentKey<K extends keyof PREF>(key: K): boolean {
return (PREF_DEF as PreferencesDefinition)[key].serverDependent === true;
return (PREF_DEF as PreferencesDefinition)[key]?.serverDependent === true;
}
private rewriteRawState<K extends keyof PREF>(key: K, value: ValueOf<K>) {
@ -150,6 +164,20 @@ export class PreferencesManager {
this.rewriteRawState(key, v);
const pref = (PREF_DEF as PreferencesDefinition)[key];
if (pref) {
// Call custom setter
if (pref.onSet) {
pref.onSet(v);
}
// Prompt to reload the frontend
if (pref.needsReload && this.enableReload) {
// noinspection JSIgnoredPromiseFromCall
nextTick(() => reloadAsk({ unison: true }));
}
}
const record = this.getMatchedRecordOf(key);
if (parseScope(record[0]).account == null && this.isAccountDependentKey(key)) {

View file

@ -12922,7 +12922,11 @@ export type operations = {
requestBody: {
content: {
'application/json': {
uri: string;
uri?: string | null;
/** Format: misskey:id */
userId?: string | null;
/** Format: misskey:id */
noteId?: string | null;
expandCollectionItems?: boolean;
expandCollectionLimit?: number | null;
allowAnonymous?: boolean;
@ -24263,7 +24267,7 @@ export type operations = {
/** @description OK (with results) */
200: {
content: {
'application/json': Record<string, never>;
'application/json': unknown;
};
};
/** @description Client error */

View file

@ -624,3 +624,7 @@ keepCwEnabled: "Enabled (copy CWs verbatim)"
keepCwPrependRe: "Enabled (copy CW and prepend \"RE:\")"
noteFooterLabel: "Note controls"
rawUserDescription: "Packed user data in its raw form. Most of these fields are public and visible to all users."
rawInfoDescription: "Extended user data in its raw form. These fields are private and can only be accessed by moderators."
rawApDescription: "ActivityPub user data in its raw form. These fields are public and accessible to other instances."