enhance(frontend): 非同期的なコンポーネントの読み込み時のハンドリングを強化

This commit is contained in:
syuilo 2025-06-01 10:44:45 +09:00
parent 63db879bcc
commit f4167ae7f1
33 changed files with 120 additions and 72 deletions

View File

@ -4,6 +4,7 @@
-
### Client
- Enhance: 非同期的なコンポーネントの読み込み時のハンドリングを強化
- Fix: リアクションの一部の絵文字が重複して表示されることがある問題を修正
### Server

View File

@ -282,8 +282,8 @@ function onContextmenu(ev: MouseEvent) {
menu = [{
text: i18n.ts.openInWindow,
icon: 'ti ti-app-window',
action: () => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), {
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkDriveWindow.vue').then(x => x.default), {
initialFolder: props.folder,
}, {
closed: () => dispose(),

View File

@ -126,7 +126,7 @@ async function rename(file) {
async function describe(file: Misskey.entities.DriveFile) {
if (mock) return;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkFileCaptionEditWindow.vue').then(x => x.default), {
default: file.comment !== null ? file.comment : '',
file: file,
}, {
@ -168,9 +168,11 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
menuItems.push({
text: i18n.ts.preview,
icon: 'ti ti-photo-search',
action: () => {
os.popup(defineAsyncComponent(() => import('@/components/MkImgPreviewDialog.vue')), {
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImgPreviewDialog.vue').then(x => x.default), {
file: file,
}, {
closed: () => dispose(),
});
},
});

View File

@ -174,8 +174,8 @@ function setupComplete() {
function launchTutorial() {
setupComplete();
nextTick(() => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {
nextTick(async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkTutorialDialog.vue').then(x => x.default), {
initialPage: 1,
}, {
closed: () => dispose(),

View File

@ -185,7 +185,7 @@ async function edit(name: string) {
const emoji = await misskeyApi('emoji', {
name: name,
});
const { dispose } = os.popup(defineAsyncComponent(() => import('@/pages/emoji-edit-dialog.vue')), {
const { dispose } = await os.popupAsyncWithDialog(import('@/pages/emoji-edit-dialog.vue').then(x => x.default), {
emoji: emoji,
}, {
closed: () => dispose(),

View File

@ -206,6 +206,52 @@ export function popup<T extends Component>(
};
}
export async function popupAsyncWithDialog<T extends Component>(
componentFetching: Promise<T>,
props: ComponentProps<T>,
events: Partial<ComponentEmit<T>> = {},
): Promise<{ dispose: () => void }> {
const closeWaiting = waiting();
let component: T;
try {
component = await componentFetching;
} catch (err) {
closeWaiting();
alert({
type: 'error',
title: i18n.ts.somethingHappened,
text: 'CODE: ASYNC_COMP_LOAD_FAIL',
});
throw err;
}
closeWaiting();
markRaw(component);
const id = ++popupIdCount;
const dispose = () => {
// このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ
window.setTimeout(() => {
popups.value = popups.value.filter(p => p.id !== id);
}, 0);
};
const state = {
component,
props,
events,
id,
};
popups.value.push(state);
return {
dispose,
};
}
export function pageWindow(path: string) {
const { dispose } = popup(MkPageWindow, {
initialPath: path,
@ -787,9 +833,9 @@ export function launchUploader(
multiple?: boolean;
},
): Promise<Misskey.entities.DriveFile[]> {
return new Promise((res, rej) => {
return new Promise(async (res, rej) => {
if (files.length === 0) return rej();
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUploaderDialog.vue')), {
const { dispose } = await popupAsyncWithDialog(import('@/components/MkUploaderDialog.vue').then(x => x.default), {
files: markRaw(files),
folderId: options?.folderId,
multiple: options?.multiple,

View File

@ -477,16 +477,16 @@ function toggleRoleItem(role) {
}
}
function createAnnouncement() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
async function createAnnouncement() {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkUserAnnouncementEditDialog.vue').then(x => x.default), {
user: user.value,
}, {
closed: () => dispose(),
});
}
function editAnnouncement(announcement) {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
async function editAnnouncement(announcement) {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkUserAnnouncementEditDialog.vue').then(x => x.default), {
user: user.value,
announcement,
}, {

View File

@ -525,10 +525,10 @@ const headerPageMetadata = computed(() => ({
const headerActions = computed(() => [{
icon: 'ti ti-search',
text: i18n.ts.search,
handler: () => {
handler: async () => {
if (searchWindowOpening) return;
searchWindowOpening = true;
const { dispose } = os.popup(defineAsyncComponent(() => import('./custom-emojis-manager.local.list.search.vue')), {
const { dispose } = await os.popupAsyncWithDialog(import('./custom-emojis-manager.local.list.search.vue').then(x => x.default), {
query: searchQuery.value,
}, {
queryUpdated: (query: EmojiSearchQuery) => {
@ -584,8 +584,8 @@ const headerActions = computed(() => [{
}, {
icon: 'ti ti-notes',
text: i18n.ts._customEmojisManager._gridCommon.registrationLogs,
handler: () => {
const { dispose } = os.popup(defineAsyncComponent(() => import('./custom-emojis-manager.local.list.logs.vue')), {
handler: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('./custom-emojis-manager.local.list.logs.vue').then(x => x.default), {
logs: requestLogs.value,
}, {
closed: () => {

View File

@ -46,7 +46,7 @@ function load() {
load();
async function add(ev: MouseEvent) {
const { dispose } = os.popup(defineAsyncComponent(() => import('./avatar-decoration-edit-dialog.vue')), {
const { dispose } = await os.popupAsyncWithDialog(import('./avatar-decoration-edit-dialog.vue').then(x => x.default), {
}, {
done: result => {
if (result.created) {
@ -57,8 +57,8 @@ async function add(ev: MouseEvent) {
});
}
function edit(avatarDecoration) {
const { dispose } = os.popup(defineAsyncComponent(() => import('./avatar-decoration-edit-dialog.vue')), {
async function edit(avatarDecoration) {
const { dispose } = await os.popupAsyncWithDialog(import('./avatar-decoration-edit-dialog.vue').then(x => x.default), {
avatarDecoration: avatarDecoration,
}, {
done: result => {

View File

@ -181,9 +181,9 @@ function showMenu(ev: MouseEvent, contextmenu = false) {
menu.push({
text: i18n.ts.reportAbuse,
icon: 'ti ti-exclamation-circle',
action: () => {
action: async () => {
const localUrl = `${url}/chat/messages/${props.message.id}`;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAbuseReportWindow.vue').then(x => x.default), {
user: props.message.fromUser!,
initialComment: `${localUrl}\n-----\n`,
}, {

View File

@ -128,7 +128,7 @@ const toggleSelect = (emoji) => {
};
const add = async (ev: MouseEvent) => {
const { dispose } = os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
const { dispose } = await os.popupAsyncWithDialog(import('./emoji-edit-dialog.vue').then(x => x.default), {
}, {
done: result => {
if (result.created) {
@ -139,8 +139,8 @@ const add = async (ev: MouseEvent) => {
});
};
const edit = (emoji) => {
const { dispose } = os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
const edit = async (emoji) => {
const { dispose } = await os.popupAsyncWithDialog(import('./emoji-edit-dialog.vue').then(x => x.default), {
emoji: emoji,
}, {
done: result => {

View File

@ -174,10 +174,10 @@ function rename() {
});
}
function describe() {
async function describe() {
if (!file.value) return;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkFileCaptionEditWindow.vue').then(x => x.default), {
default: file.value.comment ?? '',
file: file.value,
}, {

View File

@ -67,7 +67,7 @@ function menu(ev) {
}
const edit = async (emoji) => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/pages/emoji-edit-dialog.vue')), {
const { dispose } = await os.popupAsyncWithDialog(import('@/pages/emoji-edit-dialog.vue').then(x => x.default), {
emoji: emoji,
}, {
closed: () => dispose(),

View File

@ -238,12 +238,12 @@ async function run() {
}
}
function reportAbuse() {
async function reportAbuse() {
if (!flash.value) return;
const pageUrl = `${url}/play/${flash.value.id}`;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAbuseReportWindow.vue').then(x => x.default), {
user: flash.value.user,
initialComment: `Play: ${pageUrl}\n-----\n`,
}, {

View File

@ -153,12 +153,12 @@ function edit() {
router.push(`/gallery/${post.value.id}/edit`);
}
function reportAbuse() {
async function reportAbuse() {
if (!post.value) return;
const pageUrl = `${url}/gallery/${post.value.id}`;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAbuseReportWindow.vue').then(x => x.default), {
user: post.value.user,
initialComment: `Post: ${pageUrl}\n-----\n`,
}, {

View File

@ -245,12 +245,12 @@ function pin(pin) {
});
}
function reportAbuse() {
async function reportAbuse() {
if (!page.value) return;
const pageUrl = `${url}/@${props.username}/pages/${props.pageName}`;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAbuseReportWindow.vue').then(x => x.default), {
user: page.value.user,
initialComment: `Page: ${pageUrl}\n-----\n`,
}, {

View File

@ -41,9 +41,9 @@ async function save() {
mainRouter.push('/');
}
onMounted(() => {
onMounted(async () => {
if (props.token == null) {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkForgotPassword.vue').then(x => x.default), {}, {
closed: () => dispose(),
});
mainRouter.push('/');

View File

@ -117,7 +117,7 @@ async function registerTOTP(): Promise<void> {
token: auth.result.token,
});
const { dispose } = os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), {
const { dispose } = await os.popupAsyncWithDialog(import('./2fa.qrdialog.vue').then(x => x.default), {
twoFactorData,
}, {
closed: () => dispose(),

View File

@ -68,8 +68,8 @@ misskeyApi('get-avatar-decorations').then(_avatarDecorations => {
loading.value = false;
});
function openDecoration(avatarDecoration, index?: number) {
const { dispose } = os.popup(defineAsyncComponent(() => import('./avatar-decoration.dialog.vue')), {
async function openDecoration(avatarDecoration, index?: number) {
const { dispose } = await os.popupAsyncWithDialog(import('./avatar-decoration.dialog.vue').then(x => x.default), {
decoration: avatarDecoration,
usingIndex: index,
}, {

View File

@ -81,8 +81,8 @@ const pagination = {
noPaging: true,
};
function generateToken() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, {
async function generateToken() {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkTokenGenerateWindow.vue').then(x => x.default), {}, {
done: async result => {
const { name, permissions } = result;
const { token } = await misskeyApi('miauth/gen-token', {

View File

@ -95,8 +95,8 @@ export async function authorizePlugin(plugin: Plugin) {
if (plugin.permissions == null || plugin.permissions.length === 0) return;
if (Object.hasOwn(store.s.pluginTokens, plugin.installId)) return;
const token = await new Promise<string>((res, rej) => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {
const token = await new Promise<string>(async (res, rej) => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkTokenGenerateWindow.vue').then(x => x.default), {
title: i18n.ts.tokenRequested,
information: i18n.ts.pluginTokenRequestedDescription,
initialName: plugin.name,

View File

@ -4,10 +4,10 @@
*/
import { defineAsyncComponent } from 'vue';
import { host } from '@@/js/config.js';
import type { MenuItem } from '@/types/menu.js';
import * as os from '@/os.js';
import { instance } from '@/instance.js';
import { host } from '@@/js/config.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';
@ -146,8 +146,8 @@ export function openInstanceMenu(ev: MouseEvent) {
menuItems.push({
text: i18n.ts._initialTutorial.launchTutorial,
icon: 'ti ti-presentation',
action: () => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {}, {
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkTutorialDialog.vue').then(x => x.default), {}, {
closed: () => dispose(),
});
},

View File

@ -71,8 +71,8 @@ const otherNavItemIndicated = computed<boolean>(() => {
return false;
});
function more(ev: MouseEvent) {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
async function more(ev: MouseEvent) {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLaunchPad.vue').then(x => x.default), {
anchorElement: ev.currentTarget ?? ev.target,
anchor: { x: 'center', y: 'bottom' },
}, {

View File

@ -176,10 +176,10 @@ function openAccountMenu(ev: MouseEvent) {
}, ev);
}
function more(ev: MouseEvent) {
async function more(ev: MouseEvent) {
const target = getHTMLElementOrNull(ev.currentTarget ?? ev.target);
if (!target) return;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLaunchPad.vue').then(x => x.default), {
anchorElement: target,
}, {
closed: () => dispose(),

View File

@ -72,7 +72,7 @@ async function setAntenna() {
if (canceled || antenna == null) return;
if (antenna === '_CREATE_') {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAntennaEditorDialog.vue')), {}, {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAntennaEditorDialog.vue').then(x => x.default), {}, {
created: (newAntenna: MisskeyEntities.Antenna) => {
antennasCache.delete();
updateColumn(props.column.id, {

View File

@ -27,8 +27,8 @@ const props = defineProps<{
const notificationsComponent = useTemplateRef('notificationsComponent');
function func() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSelectWindow.vue')), {
async function func() {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkNotificationSelectWindow.vue').then(x => x.default), {
excludeTypes: props.column.excludeTypes,
}, {
done: async (res) => {

View File

@ -172,8 +172,8 @@ export function chooseFileFromPcAndUpload(
export function chooseDriveFile(options: {
multiple?: boolean;
} = {}): Promise<Misskey.entities.DriveFile[]> {
return new Promise(resolve => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFileSelectDialog.vue')), {
return new Promise(async resolve => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkDriveFileSelectDialog.vue').then(x => x.default), {
multiple: options.multiple ?? false,
}, {
done: files => {
@ -286,8 +286,8 @@ export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFi
}
export async function selectDriveFolder(initialFolder: Misskey.entities.DriveFolder['id'] | null): Promise<Misskey.entities.DriveFolder[]> {
return new Promise(resolve => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFolderSelectDialog.vue')), {
return new Promise(async resolve => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkDriveFolderSelectDialog.vue').then(x => x.default), {
initialFolder,
}, {
done: folders => {

View File

@ -28,8 +28,8 @@ function rename(file: Misskey.entities.DriveFile) {
});
}
function describe(file: Misskey.entities.DriveFile) {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
async function describe(file: Misskey.entities.DriveFile) {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkFileCaptionEditWindow.vue').then(x => x.default), {
default: file.comment ?? '',
file: file,
}, {

View File

@ -64,7 +64,7 @@ export function getEmbedCode(path: string, params?: EmbedParams): string {
*
* getEmbedCode 使
*/
export function genEmbedCode(entity: EmbeddableEntity, id: string, params?: EmbedParams) {
export async function genEmbedCode(entity: EmbeddableEntity, id: string, params?: EmbedParams) {
const _params = { ...params };
if (embedRouteWithScrollbar.includes(entity) && _params.maxHeight == null) {
@ -75,7 +75,7 @@ export function genEmbedCode(entity: EmbeddableEntity, id: string, params?: Embe
if (window.innerWidth < MOBILE_THRESHOLD) {
copyToClipboard(getEmbedCode(`/embed/${entity}/${id}`, _params));
} else {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkEmbedCodeGenDialog.vue')), {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkEmbedCodeGenDialog.vue').then(x => x.default), {
entity,
id,
params: _params,

View File

@ -135,12 +135,12 @@ export function getAbuseNoteMenu(note: Misskey.entities.Note, text: string): Men
return {
icon: 'ti ti-exclamation-circle',
text,
action: (): void => {
action: async (): Promise<void> => {
const localUrl = `${url}/notes/${note.id}`;
let noteInfo = '';
if (note.url ?? note.uri != null) noteInfo = `Note: ${note.url ?? note.uri}\n`;
noteInfo += `Local Note: ${localUrl}\n`;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAbuseReportWindow.vue').then(x => x.default), {
user: note.user,
initialComment: `${noteInfo}-----\n`,
}, {

View File

@ -94,8 +94,8 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
});
}
function reportAbuse() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
async function reportAbuse() {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAbuseReportWindow.vue').then(x => x.default), {
user: user,
}, {
closed: () => dispose(),

View File

@ -3,11 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent } from 'vue';
import { $i } from '@/i.js';
import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { popup } from '@/os.js';
import { popupAsyncWithDialog } from '@/os.js';
export type OpenOnRemoteOptions = {
/**
@ -45,7 +44,7 @@ export type OpenOnRemoteOptions = {
params: Record<string, string>;
};
export function pleaseLogin(opts: {
export async function pleaseLogin(opts: {
path?: string;
message?: string;
openOnRemote?: OpenOnRemoteOptions;
@ -59,7 +58,7 @@ export function pleaseLogin(opts: {
_openOnRemote = opts.openOnRemote;
}
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
const { dispose } = await popupAsyncWithDialog(import('@/components/MkSigninDialog.vue').then(x => x.default), {
autoSet: true,
message: opts.message ?? (_openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired),
openOnRemote: _openOnRemote,

View File

@ -54,8 +54,8 @@ const { widgetProps, configure, save } = useWidgetPropsManager(name,
emit,
);
const configureNotification = () => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSelectWindow.vue')), {
const configureNotification = async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkNotificationSelectWindow.vue').then(x => x.default), {
excludeTypes: widgetProps.excludeTypes,
}, {
done: async (res) => {