Files
misskey/packages/frontend/src/os.ts
2023-01-10 11:58:44 +00:00

517 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する
import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue';
import { EventEmitter } from 'eventemitter3';
import insertTextAtCursor from 'insert-text-at-cursor';
import * as Misskey from 'misskey-js';
import MkPostFormDialog from '@/components/MkPostFormDialog.vue';
import MkWaitingDialog from '@/components/MkWaitingDialog.vue';
import { MenuItem } from '@/types/menu';
import { pendingApiRequestsCount, api, apiGet } from '@/scripts/api';
export { pendingApiRequestsCount, api, apiGet };
export const apiWithDialog = ((
endpoint: string,
data: Record<string, any> = {},
token?: string | null | undefined,
) => {
const promise = api(endpoint, data, token);
promiseDialog(promise, null, (err) => {
alert({
type: 'error',
text: err.message + '\n' + (err as any).id,
});
});
return promise;
}) as typeof api;
export const promiseDialog = <T extends Promise<any>>(
promise: T,
onSuccess?: ((res: any) => void) | null,
onFailure?: ((err: Error) => void) | null,
text?: string,
): T => {
const showing = ref(true);
const success = ref(false);
promise.then(res => {
if (onSuccess) {
showing.value = false;
onSuccess(res);
} else {
success.value = true;
window.setTimeout(() => {
showing.value = false;
}, 1000);
}
}).catch(err => {
showing.value = false;
if (onFailure) {
onFailure(err);
} else {
alert({
type: 'error',
text: err,
});
}
});
// NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない)
popup(MkWaitingDialog, {
success: success,
showing: showing,
text: text,
}, {}, 'closed');
return promise;
}
let popupIdCount = 0;
export const popups = ref([]) as Ref<{
id: any;
component: any;
props: Record<string, any>;
}[]>;
const zIndexes = {
veryLow: 500000,
low: 1000000,
middle: 2000000,
high: 3000000,
};
export const claimZIndex = (priority: keyof typeof zIndexes = 'low'): number => {
zIndexes[priority] += 100;
return zIndexes[priority];
}
export const popup = async (component: Component, props: Record<string, any>, events = {}, disposeEvent?: string) => {
markRaw(component);
const id = ++popupIdCount;
const dispose = () => {
// このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ
window.setTimeout(() => {
popups.value = popups.value.filter(popup => popup.id !== id);
}, 0);
};
const state = {
component,
props,
events: disposeEvent ? {
...events,
[disposeEvent]: dispose,
} : events,
id,
};
popups.value.push(state);
return {
dispose,
};
}
export const pageWindow = (path: string) => {
popup(defineAsyncComponent(() => import('@/components/MkPageWindow.vue')), {
initialPath: path,
}, {}, 'closed');
}
export const modalPageWindow = (path: string) => {
popup(defineAsyncComponent(() => import('@/components/MkModalPageWindow.vue')), {
initialPath: path,
}, {}, 'closed');
}
export const toast = (message: string) => {
popup(defineAsyncComponent(() => import('@/components/MkToast.vue')), {
message,
}, {}, 'closed');
}
export const alert = (props: {
type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
title?: string | null;
text?: string | null;
}): Promise<void> => {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), props, {
done: result => {
resolve();
},
}, 'closed');
});
}
export const confirm = (props: {
type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
title?: string | null;
text?: string | null;
}): Promise<{ canceled: boolean }> => {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
...props,
showCancelButton: true,
}, {
done: result => {
resolve(result ? result : { canceled: true });
},
}, 'closed');
});
}
export const inputText = (props: {
type?: 'text' | 'email' | 'password' | 'url';
title?: string | null;
text?: string | null;
placeholder?: string | null;
default?: string | null;
}): Promise<{ canceled: true; result: undefined; } | {
canceled: false; result: string;
}> => {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
title: props.title,
text: props.text,
input: {
type: props.type,
placeholder: props.placeholder,
default: props.default,
},
}, {
done: result => {
resolve(result ? result : { canceled: true });
},
}, 'closed');
});
}
export const inputNumber = (props: {
title?: string | null;
text?: string | null;
placeholder?: string | null;
default?: number | null;
}): Promise<{ canceled: true; result: undefined; } | {
canceled: false; result: number;
}> => {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
title: props.title,
text: props.text,
input: {
type: 'number',
placeholder: props.placeholder,
default: props.default,
},
}, {
done: result => {
resolve(result ? result : { canceled: true });
},
}, 'closed');
});
}
export const inputDate = (props: {
title?: string | null;
text?: string | null;
placeholder?: string | null;
default?: Date | null;
}): Promise<{ canceled: true; result: undefined; } | {
canceled: false; result: Date;
}> => {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
title: props.title,
text: props.text,
input: {
type: 'date',
placeholder: props.placeholder,
default: props.default,
},
}, {
done: result => {
resolve(result ? { result: new Date(result.result), canceled: false } : { canceled: true });
},
}, 'closed');
});
}
export const select = <C = any>(props: {
title?: string | null;
text?: string | null;
default?: string | null;
} & ({
items: {
value: C;
text: string;
}[];
} | {
groupedItems: {
label: string;
items: {
value: C;
text: string;
}[];
}[];
})): Promise<{ canceled: true; result: undefined; } | {
canceled: false; result: C;
}> => {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), {
title: props.title,
text: props.text,
select: {
items: props.items,
groupedItems: props.groupedItems,
default: props.default,
},
}, {
done: result => {
resolve(result ? result : { canceled: true });
},
}, 'closed');
});
}
export const success = () => {
return new Promise((resolve, reject) => {
const showing = ref(true);
window.setTimeout(() => {
showing.value = false;
}, 1000);
popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
success: true,
showing: showing,
}, {
done: () => resolve(),
}, 'closed');
});
}
export const waiting = () => {
return new Promise((resolve, reject) => {
const showing = ref(true);
popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), {
success: false,
showing: showing,
}, {
done: () => resolve(),
}, 'closed');
});
}
export const form = (title, form) => {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form }, {
done: result => {
resolve(result);
},
}, 'closed');
});
}
export const selectUser = () => {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {}, {
ok: user => {
resolve(user);
},
}, 'closed');
});
}
export const selectDriveFile = (multiple: boolean) => {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
type: 'file',
multiple,
}, {
done: files => {
if (files) {
resolve(multiple ? files : files[0]);
}
},
}, 'closed');
});
}
export const selectDriveFolder = (multiple: boolean) => {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
type: 'folder',
multiple,
}, {
done: folders => {
if (folders) {
resolve(multiple ? folders : folders[0]);
}
},
}, 'closed');
});
}
export const pickEmoji = (src: HTMLElement | null, opts) => {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
src,
...opts,
}, {
done: emoji => {
resolve(emoji);
},
}, 'closed');
});
}
export const cropImage = (image: Misskey.entities.DriveFile, options: {
aspectRatio: number;
}): Promise<Misskey.entities.DriveFile> => {
return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), {
file: image,
aspectRatio: options.aspectRatio,
}, {
ok: x => {
resolve(x);
},
}, 'closed');
});
}
type AwaitType<T> =
T extends Promise<infer U> ? U :
T extends (...args: any[]) => Promise<infer V> ? V :
T;
let openingEmojiPicker: AwaitType<ReturnType<typeof popup>> | null = null;
let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null;
export const openEmojiPicker = async (src?: HTMLElement, opts, initialTextarea: typeof activeTextarea) => {
if (openingEmojiPicker) return;
activeTextarea = initialTextarea;
const textareas = document.querySelectorAll('textarea, input');
for (const textarea of Array.from(textareas)) {
textarea.addEventListener('focus', () => {
activeTextarea = textarea;
});
}
const observer = new MutationObserver(records => {
for (const record of records) {
for (const node of Array.from(record.addedNodes).filter(node => node instanceof HTMLElement) as HTMLElement[]) {
const textareas = node.querySelectorAll('textarea, input') as NodeListOf<NonNullable<typeof activeTextarea>>;
for (const textarea of Array.from(textareas).filter(textarea => textarea.dataset.preventEmojiInsert == null)) {
if (document.activeElement === textarea) activeTextarea = textarea;
textarea.addEventListener('focus', () => {
activeTextarea = textarea;
});
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: false,
characterData: false,
});
openingEmojiPicker = await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerWindow.vue')), {
src,
...opts,
}, {
chosen: emoji => {
insertTextAtCursor(activeTextarea, emoji);
},
closed: () => {
openingEmojiPicker!.dispose();
openingEmojiPicker = null;
observer.disconnect();
},
});
}
export const popupMenu = (items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement, options?: {
align?: string;
width?: number;
viaKeyboard?: boolean;
onClosing?: () => void;
}) => {
return new Promise((resolve, reject) => {
let dispose;
popup(defineAsyncComponent(() => import('@/components/MkPopupMenu.vue')), {
items,
src,
width: options?.width,
align: options?.align,
viaKeyboard: options?.viaKeyboard,
}, {
closed: () => {
resolve();
dispose();
},
closing: () => {
if (options?.onClosing) options.onClosing();
},
}).then(res => {
dispose = res.dispose;
});
});
}
export const contextMenu = (items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) => {
ev.preventDefault();
return new Promise((resolve, reject) => {
let dispose;
popup(defineAsyncComponent(() => import('@/components/MkContextMenu.vue')), {
items,
ev,
}, {
closed: () => {
resolve();
dispose();
},
}).then(res => {
dispose = res.dispose;
});
});
}
export const post = (props: Record<string, any> = {}) => {
return new Promise((resolve, reject) => {
// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない
// NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、
// Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、
// 複数のpost formを開いたときに場合によってはエラーになる
// もちろん複数のpost formを開けること自体Misskeyサイドのバグなのだが
let dispose;
popup(MkPostFormDialog, props, {
closed: () => {
resolve();
dispose();
},
}).then(res => {
dispose = res.dispose;
});
});
}
export const deckGlobalEvents = new EventEmitter();
/*
export const checkExistence = (fileData: ArrayBuffer): Promise<any> => {
return new Promise((resolve, reject) => {
const data = new FormData();
data.append('md5', getMD5(fileData));
os.api('drive/files/find-by-hash', {
md5: getMD5(fileData)
}).then(resp => {
resolve(resp.length > 0 ? resp[0] : null);
});
});
}*/