Merge upstream

This commit is contained in:
무라쿠모 2024-09-18 00:11:23 +09:00
commit 4b6cbdab39
No known key found for this signature in database
GPG key ID: 139D6573F92DA9F7
73 changed files with 1199 additions and 356 deletions

View file

@ -6,7 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div v-if="user" :class="$style.root">
<i class="ti ti-plane-departure" style="margin-right: 8px;"></i>
{{ i18n.ts.accountMoved }}
<span v-if="movedTo">{{ i18n.ts.accountMoved }}</span>
<span v-if="movedFrom">{{ i18n.ts.accountMovedFrom }}</span>
<MkMention :class="$style.link" :username="user.username" :host="user.host ?? localHost"/>
</div>
</template>
@ -22,10 +23,11 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
const user = ref<Misskey.entities.UserLite>();
const props = defineProps<{
movedTo: string; // user id
movedTo?: string; // user id
movedFrom?: string; // user id
}>();
misskeyApi('users/show', { userId: props.movedTo }).then(u => user.value = u);
misskeyApi('users/show', { userId: props.movedTo ?? props.movedFrom }).then(u => user.value = u);
</script>
<style lang="scss" module>

View file

@ -46,6 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts">
import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import sanitizeHtml from 'sanitize-html';
import { debounce } from 'throttle-debounce';
import contains from '@/scripts/contains.js';
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js';
import { acct } from '@/filters/user.js';
@ -189,6 +190,7 @@ const mfmTags = ref<string[]>([]);
const mfmParams = ref<string[]>([]);
const select = ref(-1);
const zIndex = os.claimZIndex('high');
const abortController = ref<AbortController>();
function complete<T extends keyof CompleteInfo>(type: T, value: CompleteInfo[T]['payload']) {
emit('done', { type, value });
@ -217,6 +219,39 @@ function setPosition() {
}
}
const searchUsers = debounce(1000, (query: string, cacheKey: string) => {
if (abortController.value) {
abortController.value.abort();
}
abortController.value = new AbortController();
misskeyApi('users/search-by-username-and-host', {
username: query,
limit: 10,
detail: false,
}, undefined, abortController.value.signal).then(searchedUsers => {
users.value = searchedUsers as any[];
fetching.value = false;
//
sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
});
});
const searchHashtags = debounce(1000, (query: string, cacheKey: string) => {
if (abortController.value) {
abortController.value.abort();
}
abortController.value = new AbortController();
misskeyApi('hashtags/search', {
query,
limit: 30,
}, undefined, abortController.value.signal).then(searchedHashtags => {
hashtags.value = searchedHashtags as any[];
fetching.value = false;
//
sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
});
});
function exec() {
select.value = -1;
if (suggests.value) {
@ -238,16 +273,7 @@ function exec() {
users.value = JSON.parse(cache);
fetching.value = false;
} else {
misskeyApi('users/search-by-username-and-host', {
username: props.q,
limit: 10,
detail: false,
}).then(searchedUsers => {
users.value = searchedUsers as any[];
fetching.value = false;
//
sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
});
searchUsers(props.q, cacheKey);
}
} else if (props.type === 'hashtag') {
if (!props.q || props.q === '') {
@ -261,15 +287,7 @@ function exec() {
hashtags.value = hashtags;
fetching.value = false;
} else {
misskeyApi('hashtags/search', {
query: props.q,
limit: 30,
}).then(searchedHashtags => {
hashtags.value = searchedHashtags as any[];
fetching.value = false;
//
sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
});
searchHashtags(props.q, cacheKey);
}
}
} else if (props.type === 'emoji') {

View file

@ -594,6 +594,7 @@ defineExpose({
width: auto;
height: auto;
min-width: 0;
padding: 0;
&:disabled {
cursor: not-allowed;
@ -700,7 +701,7 @@ defineExpose({
> .item {
position: relative;
padding: 0;
padding: 0 3px;
width: var(--eachSize);
height: var(--eachSize);
contain: strict;

View file

@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:withTooltip="true"
:reaction="notification.reaction.replace(/^:(\w+):$/, ':$1@.:')"
:noStyle="true"
style="width: 100%; height: 100%;"
style="width: 100%; height: 100% !important; object-fit: contain;"
/>
</div>
</div>
@ -123,7 +123,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:withTooltip="true"
:reaction="reaction.reaction.replace(/^:(\w+):$/, ':$1@.:')"
:noStyle="true"
style="width: 100%; height: 100%;"
style="width: 100%; height: 100% !important; object-fit: contain;"
/>
</div>
</div>

View file

@ -36,6 +36,7 @@ const emit = defineEmits<{
.icon {
display: block;
width: 60px;
max-height: 60px;
font-size: 60px; // unicodewidth
margin: 0 auto;
object-fit: contain;

View file

@ -63,6 +63,7 @@ function getReactionName(reaction: string): string {
.reactionIcon {
display: block;
width: 60px;
max-height: 60px;
font-size: 60px; // unicodewidth
object-fit: contain;
margin: 0 auto;

View file

@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ message }}
</MkInfo>
<div v-if="!totpLogin" class="normal-signin _gaps_m">
<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
<MkInput v-model="username" :debounce="true" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
</MkInput>
@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
</MkInput>
<MkButton type="submit" large primary rounded :disabled="signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
<MkButton type="submit" large primary rounded :disabled="!user || signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
</div>
<div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }">
<div v-if="user && user.securityKeys" class="twofa-group tap-group">
@ -64,6 +64,7 @@ import { login } from '@/account.js';
import { i18n } from '@/i18n.js';
const signing = ref(false);
const userAbortController = ref<AbortController>();
const user = ref<Misskey.entities.UserDetailed | null>(null);
const username = ref('');
const password = ref('');
@ -97,9 +98,13 @@ const props = defineProps({
});
function onUsernameChange(): void {
if (userAbortController.value) {
userAbortController.value.abort();
}
userAbortController.value = new AbortController();
misskeyApi('users/show', {
username: username.value,
}).then(userResponse => {
}, undefined, userAbortController.value.signal).then(userResponse => {
user.value = userResponse;
}, () => {
user.value = null;

View file

@ -189,7 +189,7 @@ async function onSubmit(): Promise<void> {
submitting.value = true;
try {
await misskeyApi('signup', {
await os.apiWithDialog('signup', {
username: username.value,
password: password.value.password,
emailAddress: email.value,
@ -198,35 +198,27 @@ async function onSubmit(): Promise<void> {
'm-captcha-response': mCaptchaResponse.value,
'g-recaptcha-response': reCaptchaResponse.value,
'turnstile-response': turnstileResponse.value,
});
if (instance.emailRequiredForSignup) {
os.alert({
type: 'success',
title: i18n.ts._signup.almostThere,
text: i18n.tsx._signup.emailSent({ email: email.value }),
});
emit('signupEmailPending');
} else {
const res = await misskeyApi('signin', {
username: username.value,
password: password.value.password,
});
emit('signup', res);
}, undefined, (res) => {
if (instance.emailRequiredForSignup) {
os.alert({
type: 'success',
title: i18n.ts._signup.almostThere,
text: i18n.tsx._signup.emailSent({ email: email.value }),
});
emit('signupEmailPending');
} else {
emit('signup', { id: res.id, i: res.token });
if (props.autoSet) {
return login(res.i, '/onboarding');
if (props.autoSet) {
login(res.i, '/onboarding');
}
}
}
});
} catch {
submitting.value = false;
hcaptcha.value?.reset?.();
recaptcha.value?.reset?.();
turnstile.value?.reset?.();
os.alert({
type: 'error',
text: i18n.ts.somethingHappened,
});
}
}
</script>

View file

@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts._announcement.silence }}
<template #caption>{{ i18n.ts._announcement.silenceDescription }}</template>
</MkSwitch>
<p v-if="reads">{{ i18n.tsx.nUsersRead({ n: reads }) }}</p>
<p v-if="reads">{{ i18n.tsx.nUsersRead({ n: reads }) }} <span v-if="lastReadAt">(<MkTime :time="lastReadAt" mode="absolute"/>)</span></p>
<MkUserCardMini v-if="props.user.id" :user="props.user"></MkUserCardMini>
<MkButton v-if="announcement" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
@ -94,6 +94,7 @@ const closeDuration = ref<number>(props.announcement ? props.announcement.closeD
const displayOrder = ref<number>(props.announcement ? props.announcement.displayOrder : 0);
const silence = ref<boolean>(props.announcement ? props.announcement.silence : false);
const reads = ref<number>(props.announcement ? props.announcement.reads : 0);
const lastReadAt = ref<string | null>(props.announcement ? props.announcement.lastReadAt : null);
const emit = defineEmits<{
(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,

View file

@ -16,16 +16,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>{{ i18n.ts.selectUser }}</template>
<div>
<div :class="$style.form">
<MkInput v-if="localOnly" v-model="username" :autofocus="true" @update:modelValue="search">
<MkInput v-if="localOnly" v-model="username" :debounce="true" :autofocus="true" @update:modelValue="search">
<template #label>{{ i18n.ts.username }}</template>
<template #prefix>@</template>
</MkInput>
<FormSplit v-else :minWidth="170">
<MkInput v-model="username" :autofocus="true" @update:modelValue="search">
<MkInput v-model="username" :debounce="true" :autofocus="true" @update:modelValue="search">
<template #label>{{ i18n.ts.username }}</template>
<template #prefix>@</template>
</MkInput>
<MkInput v-model="host" :datalist="[hostname]" @update:modelValue="search">
<MkInput v-model="host" :debounce="true" :datalist="[hostname]" @update:modelValue="search">
<template #label>{{ i18n.ts.host }}</template>
<template #prefix>@</template>
</MkInput>
@ -92,18 +92,23 @@ const users = ref<Misskey.entities.UserLite[]>([]);
const recentUsers = ref<Misskey.entities.UserDetailed[]>([]);
const selected = ref<Misskey.entities.UserLite | null>(null);
const dialogEl = ref();
const abortController = ref<AbortController>();
function search() {
if (abortController.value) {
abortController.value.abort();
}
if (username.value === '' && host.value === '') {
users.value = [];
return;
}
abortController.value = new AbortController();
misskeyApi('users/search-by-username-and-host', {
username: username.value,
host: props.localOnly ? '.' : host.value,
limit: 10,
detail: false,
}).then(_users => {
}, undefined, abortController.value.signal).then(_users => {
users.value = _users.filter((u) => {
if (props.includeSelf) {
return true;

View file

@ -34,60 +34,77 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey
endpoint: E,
data: P = {} as P,
token?: string | null | undefined,
) => {
onSuccess?: ((res: Misskey.api.SwitchCaseResponseType<E, P>) => void) | null | undefined,
onFailure?: ((err: Misskey.api.APIError) => void) | null,
): Promise<Misskey.api.SwitchCaseResponseType<E, P>> => {
const promise = misskeyApi(endpoint, data, token);
promiseDialog(promise, null, async (err) => {
let title: string | undefined;
let text = err.message + '\n' + err.id;
if (err.code === 'INTERNAL_ERROR') {
title = i18n.ts.internalServerError;
text = i18n.ts.internalServerErrorDescription;
const date = new Date().toISOString();
const { result } = await actions({
type: 'error',
title,
text,
details: err.info,
actions: [{
value: 'ok',
text: i18n.ts.gotIt,
primary: true,
}, {
value: 'copy',
text: i18n.ts.copyErrorInfo,
}],
});
if (result === 'copy') {
copyToClipboard(`Endpoint: ${endpoint}\nInfo: ${JSON.stringify(err.info)}\nDate: ${date}`);
success();
}
return;
} else if (err.code === 'RATE_LIMIT_EXCEEDED') {
title = i18n.ts.cannotPerformTemporary;
text = i18n.ts.cannotPerformTemporaryDescription;
} else if (err.code === 'INVALID_PARAM') {
title = i18n.ts.invalidParamError;
text = i18n.ts.invalidParamErrorDescription;
} else if (err.code === 'ROLE_PERMISSION_DENIED') {
title = i18n.ts.permissionDeniedError;
text = i18n.ts.permissionDeniedErrorDescription;
} else if (err.code.startsWith('TOO_MANY')) {
title = i18n.ts.youCannotCreateAnymore;
text = `${i18n.ts.error}: ${err.id}`;
} else if (err.message.startsWith('Unexpected token')) {
title = i18n.ts.gotInvalidResponseError;
text = i18n.ts.gotInvalidResponseErrorDescription;
}
alert({
promiseDialog(promise, onSuccess, onFailure ?? (err => apiErrorHandler(err, endpoint)));
return promise;
});
export async function apiErrorHandler(err: Misskey.api.APIError, endpoint?: string): Promise<void> {
let title: string | undefined;
let text = err.message + '\n' + err.id;
if (err.code === 'INTERNAL_ERROR') {
title = i18n.ts.internalServerError;
text = i18n.ts.internalServerErrorDescription;
const date = new Date().toISOString();
const { result } = await actions({
type: 'error',
title,
text,
details: err.info,
actions: [{
value: 'ok',
text: i18n.ts.gotIt,
primary: true,
}, {
value: 'copy',
text: i18n.ts.copyErrorInfo,
}],
});
});
if (result === 'copy') {
copyToClipboard(`Endpoint: ${endpoint}\nInfo: ${JSON.stringify(err.info)}\nDate: ${date}`);
success();
}
return;
} else if (err.code === 'RATE_LIMIT_EXCEEDED') {
title = i18n.ts.cannotPerformTemporary;
text = i18n.ts.cannotPerformTemporaryDescription;
} else if (err.code === 'INVALID_PARAM') {
title = i18n.ts.invalidParamError;
text = i18n.ts.invalidParamErrorDescription;
} else if (err.code === 'ROLE_PERMISSION_DENIED') {
title = i18n.ts.permissionDeniedError;
text = i18n.ts.permissionDeniedErrorDescription;
} else if (err.code?.startsWith('TOO_MANY')) {
title = i18n.ts.youCannotCreateAnymore;
text = `${i18n.ts.error}: ${err.id}`;
}
return promise;
}) as typeof misskeyApi;
// @ts-expect-error Misskey内部で定義されていない不明なエラー
if (!err.id && (err.statusCode ?? 0) > 499) {
title = i18n.ts.gotInvalidResponseError;
text = i18n.ts.gotInvalidResponseErrorDescription;
}
if (err.id && !title) {
title = i18n.ts.somethingHappened;
} else if (!title) {
title = i18n.ts.somethingHappened;
text = err.message;
}
alert({
type: 'error',
title,
text,
// @ts-expect-error Misskeyのエラーならinfoを、そうでなければそのまま表示
details: err.id ? err.info : err as unknown,
});
}
export function promiseDialog<T>(
promise: Promise<T>,

View file

@ -149,7 +149,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
</span>
<span>{{ announcement.title }}</span>
<span v-if="announcement.reads > 0" style="margin-left: auto; opacity: 0.7;">{{ i18n.ts.messageRead }}</span>
<span v-if="announcement.reads > 0" style="margin-left: auto; opacity: 0.7;">{{ i18n.ts.messageRead }} <span v-if="announcement.lastReadAt">(<MkTime :time="announcement.lastReadAt" mode="absolute"/>)</span></span>
</div>
</div>
</template>

View file

@ -76,7 +76,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="announcement.silence" :helpText="i18n.ts._announcement.silenceDescription">
{{ i18n.ts._announcement.silence }}
</MkSwitch>
<p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p>
<p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }} <span v-if="announcement.lastReadAt">(<MkTime :time="announcement.lastReadAt" mode="absolute"/>)</span></p>
<MkUserCardMini v-if="announcement.userId" :user="announcement.user" @click="editUser(announcement)"></MkUserCardMini>
<MkButton v-else class="button" inline primary @click="editUser(announcement)">{{ i18n.ts.specifyUser }}</MkButton>
<div class="buttons _buttons">

View file

@ -156,6 +156,11 @@ const menuDef = computed(() => [{
text: i18n.ts.moderationLogs,
to: '/admin/modlog',
active: currentPage.value?.route.name === 'modlog',
}, {
icon: 'ti ti-list-search',
text: i18n.ts.userAccountMoveLogs,
to: '/admin/useraccountmovelog',
active: currentPage.value?.route.name === 'useraccountmovelog',
}],
}, {
title: i18n.ts.settings,

View file

@ -0,0 +1,98 @@
<template>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900">
<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
<MkInput v-model="movedFromId" style="margin: 0; flex: 1;">
<template #label> {{ i18n.ts.moveFromId }}</template>
</MkInput>
<MkInput v-model="movedToId" style="margin: 0; flex: 1;">
<template #label> {{ i18n.ts.movedToId }}</template>
</MkInput>
</div>
<MkPagination v-slot="{items}" ref="logs" :pagination="pagination" style="margin-top: var(--margin);">
<div class="_gaps_s">
<MkFolder v-for="item in items" :key="item.id">
<template #label>
{{ i18n.tsx.userAccountMoveLogsTitle({
from: '@' + item.movedFrom.username + (item.movedFrom.host ? `@${item.movedFrom.host}` : ''),
to: '@' + item.movedTo.username + (item.movedTo.host ? `@${item.movedTo.host}` : '')
})
}}
</template>
<div :class="$style.card">
<MkA :to="userPage(item.movedFrom)" :class="$style.cardContent">
<MkAvatar :user="item.movedFrom" :class="$style.avatar" link/>
<MkAcct :user="item.movedFrom"/>
</MkA>
<MkA :to="userPage(item.movedTo)" :class="$style.cardContent">
<MkAvatar :user="item.movedTo" :class="$style.avatar"/>
<MkAcct :user="item.movedTo"/>
</MkA>
</div>
</MkFolder>
</div>
</MkPagination>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, shallowRef, ref } from 'vue';
import XHeader from './_header_.vue';
import MkInput from '@/components/MkInput.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { userPage } from '@/filters/user.js';
import MkFolder from '@/components/MkFolder.vue';
const logs = shallowRef<InstanceType<typeof MkPagination>>();
const movedToId = ref('');
const movedFromId = ref('');
const pagination = {
endpoint: 'admin/show-user-account-move-logs' as const,
limit: 30,
params: computed(() => ({
movedFromId: movedFromId.value === '' ? null : movedFromId.value,
movedToId: movedToId.value === '' ? null : movedToId.value,
})),
};
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
title: i18n.ts.userAccountMoveLogs,
icon: 'ti ti-list-search',
}));
</script>
<style lang="scss" module>
.card {
display: flex;
gap: var(--margin);
border-radius: var(--radius);
padding: var(--margin);
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
.avatar {
width: 48px;
height: 48px;
}
.cardContent{
display: flex;
gap: var(--margin);
align-items: center;
flex-direction: column;
}
</style>

View file

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="700">
<div class="_gaps_m">
<div class="_gaps_m">
<MkInput v-model="endpoint" :datalist="endpoints" @update:modelValue="onEndpointChange()">
<MkInput v-model="endpoint" :debounce="true" :datalist="endpoints" @update:modelValue="onEndpointChange()">
<template #label>Endpoint</template>
</MkInput>
<MkTextarea v-model="body" code>
@ -50,6 +50,7 @@ const endpoints = ref<string[]>([]);
const sending = ref(false);
const res = ref('');
const withCredential = ref(true);
const endpointAbortController = ref<AbortController>();
misskeyApi('endpoints').then(endpointResponse => {
endpoints.value = endpointResponse;
@ -68,7 +69,11 @@ function send() {
}
function onEndpointChange() {
misskeyApi('endpoint', { endpoint: endpoint.value }, withCredential.value ? undefined : null).then(resp => {
if (endpointAbortController.value) {
endpointAbortController.value.abort();
}
endpointAbortController.value = new AbortController();
misskeyApi('endpoint', { endpoint: endpoint.value }, withCredential.value ? undefined : null, endpointAbortController.value.signal).then(resp => {
const endpointBody = {};
for (const p of resp.params) {
endpointBody[p.name] =

View file

@ -349,6 +349,7 @@ definePageMetadata(() => ({
> .img {
width: 42px;
height: 42px;
object-fit: contain;
}
> .body {
@ -395,6 +396,7 @@ definePageMetadata(() => ({
> .img {
width: 32px;
height: 32px;
object-fit: contain;
}
> .body {

View file

@ -132,7 +132,9 @@ async function deleteAccount() {
{
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.deleteAccountConfirm,
text: i18n.ts.deleteAccountConfirmAndWarn,
okWaitInitiate: 'dialog',
okWaitDuration: 5,
});
if (canceled) return;
}
@ -147,6 +149,7 @@ async function deleteAccount() {
await os.alert({
title: i18n.ts._accountDelete.started,
text: i18n.ts._accountDelete.dontLogin,
});
await signout();

View file

@ -13,6 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="profile _gaps">
<MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/>
<MkAccountMoved v-if="movedFromLog" :movedFrom="movedFromLog[0]?.movedFromId"/>
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/>
<div :key="user.id" class="main _panel">
@ -281,6 +282,7 @@ const memoDraft = ref(props.user.memo);
const isEditingMemo = ref(false);
const moderationNote = ref(props.user.moderationNote);
const editModerationNote = ref(false);
const movedFromLog = ref<null | {movedFromId:string;}[]>(null);
const hideModerationNote = !iAmModerator || (defaultStore.state.privateMode && defaultStore.state.hideModerationLog);
const hideRoleList = defaultStore.state.privateMode && defaultStore.state.hideRoleList;
@ -307,6 +309,15 @@ function menu(ev: MouseEvent) {
os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup);
}
async function fetchMovedFromLog() {
if (!props.user.id) {
movedFromLog.value = null;
return;
}
movedFromLog.value = await misskeyApi('admin/show-user-account-move-logs', { movedToId: props.user.id });
}
function parallaxLoop() {
parallaxAnimationId.value = window.requestAnimationFrame(parallaxLoop);
parallax();
@ -383,6 +394,9 @@ function buildSkebStatus(): string {
watch([props.user], () => {
memoDraft.value = props.user.memo;
fetchSkebStatus();
if ($i?.isModerator) {
fetchMovedFromLog();
}
});
onMounted(() => {
@ -401,6 +415,9 @@ onMounted(() => {
}
}
fetchSkebStatus();
if ($i?.isModerator) {
fetchMovedFromLog();
}
nextTick(() => {
adjustMemoTextarea();
});

View file

@ -436,6 +436,10 @@ const routes: RouteDef[] = [{
path: '/modlog',
name: 'modlog',
component: page(() => import('@/pages/admin/modlog.vue')),
}, {
path: '/useraccountmovelog',
name: 'useraccountmovelog',
component: page(() => import('@/pages/admin/useraccountmovelog.vue')),
}, {
path: '/settings',
name: 'settings',

View file

@ -44,14 +44,15 @@ export function misskeyApi<
},
signal,
}).then(async (res) => {
const body = res.status === 204 ? null : await res.json();
if (res.status === 200) {
if (res.ok && res.status !== 204) {
const body = await res.json();
resolve(body);
} else if (res.status === 204) {
resolve(undefined as _ResT); // void -> undefined
} else {
reject(body.error);
// エラー応答で JSON.parse に失敗した場合は HTTP ステータスコードとメッセージを返す
const body = await res.json().catch(() => ({ statusCode: res.status, message: res.statusText }));
reject(typeof body.error === 'object' ? body.error : body);
}
}).catch(reject);
});