diff options
| author | zyoshoka <107108195+zyoshoka@users.noreply.github.com> | 2024-02-28 18:26:38 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-02-28 18:26:38 +0900 |
| commit | 29350c9f334f426567e71eed479ae60ab4dea690 (patch) | |
| tree | 1e7571a3ebeff1046fdf0984c04fc0cc373a9956 /packages | |
| parent | fix(backend): リノート時のHTLへのストリーミングの意図しな... (diff) | |
| download | misskey-29350c9f334f426567e71eed479ae60ab4dea690.tar.gz misskey-29350c9f334f426567e71eed479ae60ab4dea690.tar.bz2 misskey-29350c9f334f426567e71eed479ae60ab4dea690.zip | |
refactor(frontend): `os.ts`周りのリファクタリング (#13186)
* refactor(frontend): `os.ts`周りのリファクタリング
* refactor: apiWithDialogのdataの型付け
* refactor: 不要なas anyを除去
* refactor: 返り値の型を明記、`selectDriveFolder`は`File`のほうに合わせるよう返り値を変更
* refactor: 返り値の型を改善
* refactor: フォームの型を改善
* refactor: 良い感じのimportに修正
* refactor: フォームの返り値の型を改善
* refactor: `popup()`の`props`に`ref`な値を入れるのを許可するように
* fix: `os.input`系と`os.select`の返り値の型がおかしい問題とそれによるバグを修正
* Update CHANGELOG.md
* Update CHANGELOG.md
---------
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/frontend/src/account.ts | 2 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkDialog.vue | 29 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkDriveSelectDialog.vue | 8 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkEmojiPickerDialog.vue | 4 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkEmojiPickerWindow.vue | 49 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkFormDialog.vue | 56 | ||||
| -rw-r--r-- | packages/frontend/src/os.ts | 302 | ||||
| -rw-r--r-- | packages/frontend/src/pages/emoji-edit-dialog.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/pages/notifications.vue | 4 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/drive.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/emoji-picker.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/preferences-backups.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/form.ts | 17 | ||||
| -rw-r--r-- | packages/frontend/src/ui/deck.vue | 20 | ||||
| -rw-r--r-- | packages/frontend/src/widgets/WidgetSlideshow.vue | 4 |
15 files changed, 254 insertions, 249 deletions
diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index e606fe368c..7f20e0b1a2 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -290,7 +290,7 @@ export async function openAccountMenu(opts: { text: i18n.ts.profile, to: `/@${ $i.username }`, avatar: $i, - }, { type: 'divider' }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { + }, { type: 'divider' as const }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { type: 'parent' as const, icon: 'ti ti-plus', text: i18n.ts.addAccount, diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 4b7584faaa..4577d37c08 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -38,11 +38,6 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="select.items"> <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> </template> - <template v-else> - <optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label"> - <option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option> - </optgroup> - </template> </MkSelect> <div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> <MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> @@ -64,7 +59,7 @@ import MkSelect from '@/components/MkSelect.vue'; import { i18n } from '@/i18n.js'; type Input = { - type: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local'; + type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local'; placeholder?: string | null; autocomplete?: string; default: string | number | null; @@ -74,22 +69,17 @@ type Input = { type Select = { items: { - value: string; + value: any; text: string; }[]; - groupedItems: { - label: string; - items: { - value: string; - text: string; - }[]; - }[]; default: string | null; }; +type Result = string | number | true | null; + const props = withDefaults(defineProps<{ type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting'; - title: string; + title?: string; text?: string; input?: Input; select?: Select; @@ -113,7 +103,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done', v: { canceled: boolean; result: any }): void; + (ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void; (ev: 'closed'): void; }>(); @@ -139,8 +129,11 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character return null; }); -function done(canceled: boolean, result?) { - emit('done', { canceled, result }); +// overload function を使いたいので lint エラーを無視する +function done(canceled: true): void; +function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare +function done(canceled: boolean, result?: Result): void { // eslint-disable-line no-redeclare + emit('done', { canceled, result } as { canceled: true } | { canceled: false, result: Result }); modal.value?.close(); } diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveSelectDialog.vue index 77b5532f79..f1ecc27123 100644 --- a/packages/frontend/src/components/MkDriveSelectDialog.vue +++ b/packages/frontend/src/components/MkDriveSelectDialog.vue @@ -39,13 +39,13 @@ withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done', r?: Misskey.entities.DriveFile[]): void; + (ev: 'done', r?: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void; (ev: 'closed'): void; }>(); const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); -const selected = ref<Misskey.entities.DriveFile[]>([]); +const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]); function ok() { emit('done', selected.value); @@ -57,7 +57,7 @@ function cancel() { dialog.value?.close(); } -function onChangeSelection(files: Misskey.entities.DriveFile[]) { - selected.value = files; +function onChangeSelection(v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]) { + selected.value = v; } </script> diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue index 59f4b51522..adcea839ee 100644 --- a/packages/frontend/src/components/MkEmojiPickerDialog.vue +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -56,7 +56,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done', v: any): void; + (ev: 'done', v: string): void; (ev: 'close'): void; (ev: 'closed'): void; }>(); @@ -64,7 +64,7 @@ const emit = defineEmits<{ const modal = shallowRef<InstanceType<typeof MkModal>>(); const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>(); -function chosen(emoji: any) { +function chosen(emoji: string) { emit('done', emoji); if (props.choseAndClose) { modal.value?.close(); diff --git a/packages/frontend/src/components/MkEmojiPickerWindow.vue b/packages/frontend/src/components/MkEmojiPickerWindow.vue deleted file mode 100644 index 6952943345..0000000000 --- a/packages/frontend/src/components/MkEmojiPickerWindow.vue +++ /dev/null @@ -1,49 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<MkWindow - ref="window" - :initialWidth="300" - :initialHeight="290" - :canResize="true" - :mini="true" - :front="true" - @closed="emit('closed')" -> - <MkEmojiPicker :showPinned="showPinned" :asReactionPicker="asReactionPicker" :targetNote="targetNote" asWindow :class="$style.picker" @chosen="chosen"/> -</MkWindow> -</template> - -<script lang="ts" setup> -import { } from 'vue'; -import * as Misskey from 'misskey-js'; -import MkWindow from '@/components/MkWindow.vue'; -import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; - -withDefaults(defineProps<{ - src?: HTMLElement; - showPinned?: boolean; - asReactionPicker?: boolean; - targetNote?: Misskey.entities.Note -}>(), { - showPinned: true, -}); - -const emit = defineEmits<{ - (ev: 'chosen', v: any): void; - (ev: 'closed'): void; -}>(); - -function chosen(emoji: any) { - emit('chosen', emoji); -} -</script> - -<style lang="scss" module> -.picker { - height: 100%; -} -</style> diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 0d8734799c..deedc5badb 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -21,37 +21,37 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer :marginMin="20" :marginMax="32"> <div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m"> - <template v-for="item in Object.keys(form).filter(item => !form[item].hidden)"> - <MkInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> - <template v-if="form[item].description" #caption>{{ form[item].description }}</template> + <template v-for="(v, k) in Object.fromEntries(Object.entries(form).filter(([_, v]) => !('hidden' in v) || 'hidden' in v && !v.hidden))"> + <MkInput v-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <template v-if="v.description" #caption>{{ v.description }}</template> </MkInput> - <MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text" :mfmAutocomplete="form[item].treatAsMfm"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> - <template v-if="form[item].description" #caption>{{ form[item].description }}</template> + <MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <template v-if="v.description" #caption>{{ v.description }}</template> </MkInput> - <MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]" :mfmAutocomplete="form[item].treatAsMfm" :mfmPreview="form[item].treatAsMfm"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> - <template v-if="form[item].description" #caption>{{ form[item].description }}</template> + <MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <template v-if="v.description" #caption>{{ v.description }}</template> </MkTextarea> - <MkSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]"> - <span v-text="form[item].label || item"></span> - <template v-if="form[item].description" #caption>{{ form[item].description }}</template> + <MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]"> + <span v-text="v.label || k"></span> + <template v-if="v.description" #caption>{{ v.description }}</template> </MkSwitch> - <MkSelect v-else-if="form[item].type === 'enum'" v-model="values[item]"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> - <option v-for="option in form[item].enum" :key="option.value" :value="option.value">{{ option.label }}</option> + <MkSelect v-else-if="v.type === 'enum'" v-model="values[k]"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <option v-for="option in v.enum" :key="option.value" :value="option.value">{{ option.label }}</option> </MkSelect> - <MkRadios v-else-if="form[item].type === 'radio'" v-model="values[item]"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> - <option v-for="option in form[item].options" :key="option.value" :value="option.value">{{ option.label }}</option> + <MkRadios v-else-if="v.type === 'radio'" v-model="values[k]"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <option v-for="option in v.options" :key="option.value" :value="option.value">{{ option.label }}</option> </MkRadios> - <MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :textConverter="form[item].textConverter"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> - <template v-if="form[item].description" #caption>{{ form[item].description }}</template> + <MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <template v-if="v.description" #caption>{{ v.description }}</template> </MkRange> - <MkButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)"> - <span v-text="form[item].content || item"></span> + <MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)"> + <span v-text="v.content || k"></span> </MkButton> </template> </div> @@ -72,19 +72,21 @@ import MkSelect from './MkSelect.vue'; import MkRange from './MkRange.vue'; import MkButton from './MkButton.vue'; import MkRadios from './MkRadios.vue'; +import type { Form } from '@/scripts/form.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; const props = defineProps<{ title: string; - form: any; + form: Form; }>(); const emit = defineEmits<{ (ev: 'done', v: { - canceled?: boolean; - result?: any; + canceled: true; + } | { + result: Record<string, any>; }): void; (ev: 'closed'): void; }>(); diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index a4fde6b701..c561e84a23 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -7,9 +7,9 @@ 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 type { ComponentProps } from 'vue-component-type-helpers'; +import type { ComponentProps as CP } from 'vue-component-type-helpers'; +import type { Form, GetFormResultType } from '@/scripts/form.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import MkPostFormDialog from '@/components/MkPostFormDialog.vue'; @@ -19,7 +19,6 @@ import MkToast from '@/components/MkToast.vue'; import MkDialog from '@/components/MkDialog.vue'; import MkPasswordDialog from '@/components/MkPasswordDialog.vue'; import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue'; -import MkEmojiPickerWindow from '@/components/MkEmojiPickerWindow.vue'; import MkPopupMenu from '@/components/MkPopupMenu.vue'; import MkContextMenu from '@/components/MkContextMenu.vue'; import { MenuItem } from '@/types/menu.js'; @@ -28,15 +27,15 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; export const openingWindowsCount = ref(0); -export const apiWithDialog = (( - endpoint: string, - data: Record<string, any> = {}, +export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req']>( + endpoint: E, + data: P = {} as any, token?: string | null | undefined, ) => { const promise = misskeyApi(endpoint, data, token); promiseDialog(promise, null, async (err) => { - let title = null; - let text = err.message + '\n' + (err as any).id; + let title: string | undefined; + let text = err.message + '\n' + err.id; if (err.code === 'INTERNAL_ERROR') { title = i18n.ts.internalServerError; text = i18n.ts.internalServerErrorDescription; @@ -88,7 +87,7 @@ export const apiWithDialog = (( export function promiseDialog<T extends Promise<any>>( promise: T, onSuccess?: ((res: any) => void) | null, - onFailure?: ((err: Error) => void) | null, + onFailure?: ((err: Misskey.api.APIError) => void) | null, text?: string, ): T { const showing = ref(true); @@ -149,14 +148,30 @@ export function claimZIndex(priority: keyof typeof zIndexes = 'low'): number { // 使い物にならないので、代わりに ['$props'] から色々省くことで emit の型を生成する // FIXME: 何故か *.ts ファイルからだと型がうまく取れない?ことがあるのをなんとかしたい type ComponentEmit<T> = T extends new () => { $props: infer Props } - ? EmitsExtractor<Props> - : never; + ? [keyof Pick<T, Extract<keyof T, `on${string}`>>] extends [never] + ? Record<string, unknown> // *.ts ファイルから型がうまく取れないとき用(これがないと {} になって型エラーがうるさい) + : EmitsExtractor<Props> + : T extends (...args: any) => any + ? ReturnType<T> extends { [x: string]: any; __ctx?: { [x: string]: any; props: infer Props } } + ? [keyof Pick<T, Extract<keyof T, `on${string}`>>] extends [never] + ? Record<string, unknown> + : EmitsExtractor<Props> + : never + : never; + +// props に ref を許可するようにする +type ComponentProps<T extends Component> = { [K in keyof CP<T>]: CP<T>[K] | Ref<CP<T>[K]> }; type EmitsExtractor<T> = { [K in keyof T as K extends `onVnode${string}` ? never : K extends `on${infer E}` ? Uncapitalize<E> : K extends string ? never : K]: T[K]; }; -export async function popup<T extends Component>(component: T, props: ComponentProps<T>, events: ComponentEmit<T> = {} as ComponentEmit<T>, disposeEvent?: keyof ComponentEmit<T>) { +export async function popup<T extends Component>( + component: T, + props: ComponentProps<T>, + events: ComponentEmit<T> = {} as ComponentEmit<T>, + disposeEvent?: keyof ComponentEmit<T>, +): Promise<{ dispose: () => void }> { markRaw(component); const id = ++popupIdCount; @@ -197,12 +212,12 @@ export function toast(message: string) { export function alert(props: { type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; - title?: string | null; - text?: string | null; + title?: string; + text?: string; }): Promise<void> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, props, { - done: result => { + done: () => { resolve(); }, }, 'closed'); @@ -211,12 +226,12 @@ export function alert(props: { export function confirm(props: { type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; - title?: string | null; - text?: string | null; + title?: string; + text?: string; okText?: string; cancelText?: string; }): Promise<{ canceled: boolean }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, { ...props, showCancelButton: true, @@ -237,13 +252,15 @@ export function actions<T extends { danger?: boolean, }[]>(props: { type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; - title?: string | null; - text?: string | null; + title?: string; + text?: string; actions: T; -}): Promise<{ canceled: true; result: undefined; } | { +}): Promise<{ + canceled: true; result: undefined; +} | { canceled: false; result: T[number]['value']; }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, { ...props, actions: props.actions.map(a => ({ @@ -262,19 +279,50 @@ export function actions<T extends { }); } +// default が指定されていたら result は null になり得ないことを保証する overload function export function inputText(props: { type?: 'text' | 'email' | 'password' | 'url'; - title?: string | null; - text?: string | null; + title?: string; + text?: string; placeholder?: string | null; autocomplete?: string; - default?: string | null; + default: string; minLength?: number; maxLength?: number; -}): Promise<{ canceled: true; result: undefined; } | { +}): Promise<{ + canceled: true; result: undefined; +} | { canceled: false; result: string; +}>; +export function inputText(props: { + type?: 'text' | 'email' | 'password' | 'url'; + title?: string; + text?: string; + placeholder?: string | null; + autocomplete?: string; + default?: string | null; + minLength?: number; + maxLength?: number; +}): Promise<{ + canceled: true; result: undefined; +} | { + canceled: false; result: string | null; +}>; +export function inputText(props: { + type?: 'text' | 'email' | 'password' | 'url'; + title?: string; + text?: string; + placeholder?: string | null; + autocomplete?: string; + default?: string | null; + minLength?: number; + maxLength?: number; +}): Promise<{ + canceled: true; result: undefined; +} | { + canceled: false; result: string | null; }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, { title: props.title, text: props.text, @@ -282,7 +330,7 @@ export function inputText(props: { type: props.type, placeholder: props.placeholder, autocomplete: props.autocomplete, - default: props.default, + default: props.default ?? null, minLength: props.minLength, maxLength: props.maxLength, }, @@ -294,16 +342,41 @@ export function inputText(props: { }); } +// default が指定されていたら result は null になり得ないことを保証する overload function export function inputNumber(props: { - title?: string | null; - text?: string | null; + title?: string; + text?: string; placeholder?: string | null; autocomplete?: string; - default?: number | null; -}): Promise<{ canceled: true; result: undefined; } | { + default: number; +}): Promise<{ + canceled: true; result: undefined; +} | { canceled: false; result: number; +}>; +export function inputNumber(props: { + title?: string; + text?: string; + placeholder?: string | null; + autocomplete?: string; + default?: number | null; +}): Promise<{ + canceled: true; result: undefined; +} | { + canceled: false; result: number | null; +}>; +export function inputNumber(props: { + title?: string; + text?: string; + placeholder?: string | null; + autocomplete?: string; + default?: number | null; +}): Promise<{ + canceled: true; result: undefined; +} | { + canceled: false; result: number | null; }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, { title: props.title, text: props.text, @@ -311,7 +384,7 @@ export function inputNumber(props: { type: 'number', placeholder: props.placeholder, autocomplete: props.autocomplete, - default: props.default, + default: props.default ?? null, }, }, { done: result => { @@ -322,34 +395,38 @@ export function inputNumber(props: { } export function inputDate(props: { - title?: string | null; - text?: string | null; + title?: string; + text?: string; placeholder?: string | null; - default?: Date | null; -}): Promise<{ canceled: true; result: undefined; } | { + default?: string | null; +}): Promise<{ + canceled: true; result: undefined; +} | { canceled: false; result: Date; }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, { title: props.title, text: props.text, input: { type: 'date', placeholder: props.placeholder, - default: props.default, + default: props.default ?? null, }, }, { done: result => { - resolve(result ? { result: new Date(result.result), canceled: false } : { canceled: true }); + resolve(result ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true }); }, }, 'closed'); }); } -export function authenticateDialog(): Promise<{ canceled: true; result: undefined; } | { +export function authenticateDialog(): Promise<{ + canceled: true; result: undefined; +} | { canceled: false; result: { password: string; token: string | null; }; }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkPasswordDialog, {}, { done: result => { resolve(result ? { canceled: false, result } : { canceled: true, result: undefined }); @@ -358,34 +435,53 @@ export function authenticateDialog(): Promise<{ canceled: true; result: undefine }); } +// default が指定されていたら result は null になり得ないことを保証する overload function export function select<C = any>(props: { - title?: string | null; - text?: string | null; + title?: string; + text?: string; + default: string; + items: { + value: C; + text: string; + }[]; +}): Promise<{ + canceled: true; result: undefined; +} | { + canceled: false; result: C; +}>; +export function select<C = any>(props: { + title?: string; + text?: string; default?: string | null; -} & ({ items: { value: C; text: string; }[]; +}): Promise<{ + canceled: true; result: undefined; } | { - groupedItems: { - label: string; - items: { - value: C; - text: string; - }[]; + canceled: false; result: C | null; +}>; +export function select<C = any>(props: { + title?: string; + text?: string; + default?: string | null; + items: { + value: C; + text: string; }[]; -})): Promise<{ canceled: true; result: undefined; } | { - canceled: false; result: C; +}): Promise<{ + canceled: true; result: undefined; +} | { + canceled: false; result: C | null; }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, { title: props.title, text: props.text, select: { items: props.items, - groupedItems: props.groupedItems, - default: props.default, + default: props.default ?? null, }, }, { done: result => { @@ -396,7 +492,7 @@ export function select<C = any>(props: { } export function success(): Promise<void> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { const showing = ref(true); window.setTimeout(() => { showing.value = false; @@ -411,7 +507,7 @@ export function success(): Promise<void> { } export function waiting(): Promise<void> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { const showing = ref(true); popup(MkWaitingDialog, { success: false, @@ -422,9 +518,9 @@ export function waiting(): Promise<void> { }); } -export function form(title, form) { - return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form }, { +export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true } | { result: GetFormResultType<F> }> { + return new Promise(resolve => { + popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, { done: result => { resolve(result); }, @@ -433,7 +529,7 @@ export function form(title, form) { } export async function selectUser(opts: { includeSelf?: boolean; localOnly?: boolean; } = {}): Promise<Misskey.entities.UserDetailed> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), { includeSelf: opts.includeSelf, localOnly: opts.localOnly, @@ -446,7 +542,7 @@ export async function selectUser(opts: { includeSelf?: boolean; localOnly?: bool } export async function selectDriveFile(multiple: boolean): Promise<Misskey.entities.DriveFile[]> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { type: 'file', multiple, @@ -460,23 +556,23 @@ export async function selectDriveFile(multiple: boolean): Promise<Misskey.entiti }); } -export async function selectDriveFolder(multiple: boolean) { - return new Promise((resolve, reject) => { +export async function selectDriveFolder(multiple: boolean): Promise<Misskey.entities.DriveFolder[]> { + return new Promise(resolve => { popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { type: 'folder', multiple, }, { done: folders => { if (folders) { - resolve(multiple ? folders : folders[0]); + resolve(folders); } }, }, 'closed'); }); } -export async function pickEmoji(src: HTMLElement | null, opts) { - return new Promise((resolve, reject) => { +export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog>): Promise<string> { + return new Promise(resolve => { popup(MkEmojiPickerDialog, { src, ...opts, @@ -492,7 +588,7 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: { aspectRatio: number; uploadFolder?: string | null; }): Promise<Misskey.entities.DriveFile> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), { file: image, aspectRatio: options.aspectRatio, @@ -505,67 +601,13 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: { }); } -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 async function openEmojiPicker(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(MkEmojiPickerWindow, { - src, - ...opts, - }, { - chosen: emoji => { - insertTextAtCursor(activeTextarea, emoji); - }, - closed: () => { - openingEmojiPicker!.dispose(); - openingEmojiPicker = null; - observer.disconnect(); - }, - }); -} - -export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement | EventTarget | null, options?: { +export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | null, options?: { align?: string; width?: number; viaKeyboard?: boolean; onClosing?: () => void; }): Promise<void> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { let dispose; popup(MkPopupMenu, { items, @@ -587,9 +629,9 @@ export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement }); } -export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent): Promise<void> { +export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> { ev.preventDefault(); - return new Promise((resolve, reject) => { + return new Promise(resolve => { let dispose; popup(MkContextMenu, { items, @@ -608,7 +650,7 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) export function post(props: Record<string, any> = {}): Promise<void> { showMovedDialog(); - return new Promise((resolve, reject) => { + return new Promise(resolve => { // NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない // NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、 // Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、 diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 12e9416f72..16769ef360 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -135,7 +135,7 @@ async function addRole() { const { canceled, result: role } = await os.select({ items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })), }); - if (canceled) return; + if (canceled || role == null) return; rolesThatCanBeUsedThisEmojiAsReaction.value.push(role); } diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index 7db6fa5395..28f5838296 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -52,7 +52,7 @@ const directNotesPagination = { function setFilter(ev) { const typeItems = notificationTypes.map(t => ({ text: i18n.ts._notification._types[t], - active: includeTypes.value && includeTypes.value.includes(t), + active: (includeTypes.value && includeTypes.value.includes(t)) ?? false, action: () => { includeTypes.value = [t]; }, @@ -63,7 +63,7 @@ function setFilter(ev) { action: () => { includeTypes.value = null; }, - }, { type: 'divider' }, ...typeItems] : typeItems; + }, { type: 'divider' as const }, ...typeItems] : typeItems; os.popupMenu(items, ev.currentTarget ?? ev.target); } diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index cd38f9850f..1919f80864 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -113,7 +113,7 @@ if (defaultStore.state.uploadFolder) { function chooseUploadFolder() { os.selectDriveFolder(false).then(async folder => { - defaultStore.set('uploadFolder', folder ? folder.id : null); + defaultStore.set('uploadFolder', folder[0] ? folder[0].id : null); os.success(); if (defaultStore.state.uploadFolder) { uploadFolder.value = await misskeyApi('drive/folders/show', { diff --git a/packages/frontend/src/pages/settings/emoji-picker.vue b/packages/frontend/src/pages/settings/emoji-picker.vue index 79969427ec..ce296ec183 100644 --- a/packages/frontend/src/pages/settings/emoji-picker.vue +++ b/packages/frontend/src/pages/settings/emoji-picker.vue @@ -213,7 +213,7 @@ async function pickEmoji(itemsRef: Ref<string[]>, ev: MouseEvent) { os.pickEmoji(getHTMLElement(ev), { showPinned: false, }).then(it => { - const emoji = it as string; + const emoji = it; if (!itemsRef.value.includes(emoji)) { itemsRef.value.push(emoji); } diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue index 676159d1b5..942de19d82 100644 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -203,6 +203,7 @@ async function saveNew(): Promise<void> { const { canceled, result: name } = await os.inputText({ title: ts._preferencesBackups.inputName, + default: '', }); if (canceled) return; @@ -371,6 +372,7 @@ async function rename(id: string): Promise<void> { const { canceled: cancel1, result: name } = await os.inputText({ title: ts._preferencesBackups.inputName, + default: '', }); if (cancel1 || profiles.value[id].name === name) return; diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/scripts/form.ts index 26a027f461..b0db404f28 100644 --- a/packages/frontend/src/scripts/form.ts +++ b/packages/frontend/src/scripts/form.ts @@ -12,29 +12,37 @@ export type FormItem = { label?: string; type: 'string'; default: string | null; + description?: string; + required?: boolean; hidden?: boolean; multiline?: boolean; + treatAsMfm?: boolean; } | { label?: string; type: 'number'; default: number | null; + description?: string; + required?: boolean; hidden?: boolean; step?: number; } | { label?: string; type: 'boolean'; default: boolean | null; + description?: string; hidden?: boolean; } | { label?: string; type: 'enum'; default: string | null; + required?: boolean; hidden?: boolean; enum: EnumItem[]; } | { label?: string; type: 'radio'; default: unknown | null; + required?: boolean; hidden?: boolean; options: { label: string; @@ -44,9 +52,12 @@ export type FormItem = { label?: string; type: 'range'; default: number | null; - step: number; + description?: string; + required?: boolean; + step?: number; min: number; max: number; + textConverter?: (value: number) => string; } | { label?: string; type: 'object'; @@ -57,6 +68,10 @@ export type FormItem = { type: 'array'; default: unknown[] | null; hidden: boolean; +} | { + type: 'button'; + content?: string; + action: (ev: MouseEvent, v: any) => void; }; export type Form = Record<string, FormItem>; diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index 92d2e23d9b..bdb62dca15 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -117,6 +117,7 @@ import XMentionsColumn from '@/ui/deck/mentions-column.vue'; import XDirectColumn from '@/ui/deck/direct-column.vue'; import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue'; import { mainRouter } from '@/router/main.js'; +import { MenuItem } from '@/types/menu.js'; const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); @@ -221,21 +222,19 @@ document.documentElement.style.scrollBehavior = 'auto'; loadDeck(); function changeProfile(ev: MouseEvent) { - const items = ref([{ + let items: MenuItem[] = [{ text: deckStore.state.profile, - active: true.valueOf, - }]); + active: true, + action: () => {}, + }]; getProfiles().then(profiles => { - items.value = [{ - text: deckStore.state.profile, - active: true.valueOf, - }, ...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({ + items.push(...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({ text: k, action: () => { deckStore.set('profile', k); unisonReload(); }, - }))), { type: 'divider' }, { + }))), { type: 'divider' as const }, { text: i18n.ts._deck.newProfile, icon: 'ti ti-plus', action: async () => { @@ -248,9 +247,10 @@ function changeProfile(ev: MouseEvent) { deckStore.set('profile', name); unisonReload(); }, - }]; + }); + }).then(() => { + os.popupMenu(items, ev.currentTarget ?? ev.target); }); - os.popupMenu(items, ev.currentTarget ?? ev.target); } async function deleteProfile() { diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue index 7a3671a240..b8efd3bda9 100644 --- a/packages/frontend/src/widgets/WidgetSlideshow.vue +++ b/packages/frontend/src/widgets/WidgetSlideshow.vue @@ -93,10 +93,10 @@ const fetch = () => { const choose = () => { os.selectDriveFolder(false).then(folder => { - if (folder == null) { + if (folder[0] == null) { return; } - widgetProps.folderId = folder.id; + widgetProps.folderId = folder[0].id; save(); fetch(); }); |