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>
This commit is contained in:
zyoshoka 2024-02-28 18:26:38 +09:00 committed by GitHub
parent 664aeb3ced
commit 29350c9f33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 257 additions and 250 deletions

View File

@ -24,6 +24,8 @@
- Fix: MFMのオートコンプリートが出るべき状況で出ないことがある問題を修正 - Fix: MFMのオートコンプリートが出るべき状況で出ないことがある問題を修正
- Fix: チャートのラベルが消えている問題を修正 - Fix: チャートのラベルが消えている問題を修正
- Fix: 画面表示後最初の音声再生が爆音になることがある問題を修正 - Fix: 画面表示後最初の音声再生が爆音になることがある問題を修正
- Fix: 設定のバックアップ作成時に名前を入力しなかった場合、ローカライゼーションがおかしくなる問題を修正
- Fix: ページ`/admin/emojis`の絵文字編集ダイアログで「リアクションとして使えるロール」を追加する際に何も選択せずOKを押下すると画面が固まる問題を修正
- Fix: 絵文字サジェストの順位で、絵文字自体の名前が同じものよりもタグで一致しているものが優先されてしまう問題を修正 - Fix: 絵文字サジェストの順位で、絵文字自体の名前が同じものよりもタグで一致しているものが優先されてしまう問題を修正
### Server ### Server

View File

@ -290,7 +290,7 @@ export async function openAccountMenu(opts: {
text: i18n.ts.profile, text: i18n.ts.profile,
to: `/@${ $i.username }`, to: `/@${ $i.username }`,
avatar: $i, avatar: $i,
}, { type: 'divider' }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { }, { type: 'divider' as const }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
type: 'parent' as const, type: 'parent' as const,
icon: 'ti ti-plus', icon: 'ti ti-plus',
text: i18n.ts.addAccount, text: i18n.ts.addAccount,

View File

@ -38,11 +38,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="select.items"> <template v-if="select.items">
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option> <option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
</template> </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> </MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> <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> <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'; import { i18n } from '@/i18n.js';
type Input = { 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; placeholder?: string | null;
autocomplete?: string; autocomplete?: string;
default: string | number | null; default: string | number | null;
@ -74,22 +69,17 @@ type Input = {
type Select = { type Select = {
items: { items: {
value: string; value: any;
text: string; text: string;
}[]; }[];
groupedItems: {
label: string;
items: {
value: string;
text: string;
}[];
}[];
default: string | null; default: string | null;
}; };
type Result = string | number | true | null;
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting'; type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting';
title: string; title?: string;
text?: string; text?: string;
input?: Input; input?: Input;
select?: Select; select?: Select;
@ -113,7 +103,7 @@ const props = withDefaults(defineProps<{
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done', v: { canceled: boolean; result: any }): void; (ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void;
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();
@ -139,8 +129,11 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character
return null; return null;
}); });
function done(canceled: boolean, result?) { // overload function 使 lint
emit('done', { canceled, result }); 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(); modal.value?.close();
} }

View File

@ -39,13 +39,13 @@ withDefaults(defineProps<{
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done', r?: Misskey.entities.DriveFile[]): void; (ev: 'done', r?: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void;
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const selected = ref<Misskey.entities.DriveFile[]>([]); const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]);
function ok() { function ok() {
emit('done', selected.value); emit('done', selected.value);
@ -57,7 +57,7 @@ function cancel() {
dialog.value?.close(); dialog.value?.close();
} }
function onChangeSelection(files: Misskey.entities.DriveFile[]) { function onChangeSelection(v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]) {
selected.value = files; selected.value = v;
} }
</script> </script>

View File

@ -56,7 +56,7 @@ const props = withDefaults(defineProps<{
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done', v: any): void; (ev: 'done', v: string): void;
(ev: 'close'): void; (ev: 'close'): void;
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();
@ -64,7 +64,7 @@ const emit = defineEmits<{
const modal = shallowRef<InstanceType<typeof MkModal>>(); const modal = shallowRef<InstanceType<typeof MkModal>>();
const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>(); const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>();
function chosen(emoji: any) { function chosen(emoji: string) {
emit('done', emoji); emit('done', emoji);
if (props.choseAndClose) { if (props.choseAndClose) {
modal.value?.close(); modal.value?.close();

View File

@ -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>

View File

@ -21,37 +21,37 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :marginMin="20" :marginMax="32"> <MkSpacer :marginMin="20" :marginMax="32">
<div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m"> <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)"> <template v-for="(v, k) in Object.fromEntries(Object.entries(form).filter(([_, v]) => !('hidden' in v) || 'hidden' in v && !v.hidden))">
<MkInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"> <MkInput v-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.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 #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template> <template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput> </MkInput>
<MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text" :mfmAutocomplete="form[item].treatAsMfm"> <MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template> <template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput> </MkInput>
<MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]" :mfmAutocomplete="form[item].treatAsMfm" :mfmPreview="form[item].treatAsMfm"> <MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template> <template v-if="v.description" #caption>{{ v.description }}</template>
</MkTextarea> </MkTextarea>
<MkSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]"> <MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]">
<span v-text="form[item].label || item"></span> <span v-text="v.label || k"></span>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template> <template v-if="v.description" #caption>{{ v.description }}</template>
</MkSwitch> </MkSwitch>
<MkSelect v-else-if="form[item].type === 'enum'" v-model="values[item]"> <MkSelect v-else-if="v.type === 'enum'" v-model="values[k]">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <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 form[item].enum" :key="option.value" :value="option.value">{{ option.label }}</option> <option v-for="option in v.enum" :key="option.value" :value="option.value">{{ option.label }}</option>
</MkSelect> </MkSelect>
<MkRadios v-else-if="form[item].type === 'radio'" v-model="values[item]"> <MkRadios v-else-if="v.type === 'radio'" v-model="values[k]">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <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 form[item].options" :key="option.value" :value="option.value">{{ option.label }}</option> <option v-for="option in v.options" :key="option.value" :value="option.value">{{ option.label }}</option>
</MkRadios> </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"> <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="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template> <template v-if="v.description" #caption>{{ v.description }}</template>
</MkRange> </MkRange>
<MkButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)"> <MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)">
<span v-text="form[item].content || item"></span> <span v-text="v.content || k"></span>
</MkButton> </MkButton>
</template> </template>
</div> </div>
@ -72,19 +72,21 @@ import MkSelect from './MkSelect.vue';
import MkRange from './MkRange.vue'; import MkRange from './MkRange.vue';
import MkButton from './MkButton.vue'; import MkButton from './MkButton.vue';
import MkRadios from './MkRadios.vue'; import MkRadios from './MkRadios.vue';
import type { Form } from '@/scripts/form.js';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js'; import { infoImageUrl } from '@/instance.js';
const props = defineProps<{ const props = defineProps<{
title: string; title: string;
form: any; form: Form;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done', v: { (ev: 'done', v: {
canceled?: boolean; canceled: true;
result?: any; } | {
result: Record<string, any>;
}): void; }): void;
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();

View File

@ -7,9 +7,9 @@
import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue'; import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue';
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from 'eventemitter3';
import insertTextAtCursor from 'insert-text-at-cursor';
import * as Misskey from 'misskey-js'; 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 { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import MkPostFormDialog from '@/components/MkPostFormDialog.vue'; import MkPostFormDialog from '@/components/MkPostFormDialog.vue';
@ -19,7 +19,6 @@ import MkToast from '@/components/MkToast.vue';
import MkDialog from '@/components/MkDialog.vue'; import MkDialog from '@/components/MkDialog.vue';
import MkPasswordDialog from '@/components/MkPasswordDialog.vue'; import MkPasswordDialog from '@/components/MkPasswordDialog.vue';
import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue'; import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue';
import MkEmojiPickerWindow from '@/components/MkEmojiPickerWindow.vue';
import MkPopupMenu from '@/components/MkPopupMenu.vue'; import MkPopupMenu from '@/components/MkPopupMenu.vue';
import MkContextMenu from '@/components/MkContextMenu.vue'; import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu.js'; 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 openingWindowsCount = ref(0);
export const apiWithDialog = (( export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req']>(
endpoint: string, endpoint: E,
data: Record<string, any> = {}, data: P = {} as any,
token?: string | null | undefined, token?: string | null | undefined,
) => { ) => {
const promise = misskeyApi(endpoint, data, token); const promise = misskeyApi(endpoint, data, token);
promiseDialog(promise, null, async (err) => { promiseDialog(promise, null, async (err) => {
let title = null; let title: string | undefined;
let text = err.message + '\n' + (err as any).id; let text = err.message + '\n' + err.id;
if (err.code === 'INTERNAL_ERROR') { if (err.code === 'INTERNAL_ERROR') {
title = i18n.ts.internalServerError; title = i18n.ts.internalServerError;
text = i18n.ts.internalServerErrorDescription; text = i18n.ts.internalServerErrorDescription;
@ -88,7 +87,7 @@ export const apiWithDialog = ((
export function promiseDialog<T extends Promise<any>>( export function promiseDialog<T extends Promise<any>>(
promise: T, promise: T,
onSuccess?: ((res: any) => void) | null, onSuccess?: ((res: any) => void) | null,
onFailure?: ((err: Error) => void) | null, onFailure?: ((err: Misskey.api.APIError) => void) | null,
text?: string, text?: string,
): T { ): T {
const showing = ref(true); const showing = ref(true);
@ -149,14 +148,30 @@ export function claimZIndex(priority: keyof typeof zIndexes = 'low'): number {
// 使い物にならないので、代わりに ['$props'] から色々省くことで emit の型を生成する // 使い物にならないので、代わりに ['$props'] から色々省くことで emit の型を生成する
// FIXME: 何故か *.ts ファイルからだと型がうまく取れない?ことがあるのをなんとかしたい // FIXME: 何故か *.ts ファイルからだと型がうまく取れない?ことがあるのをなんとかしたい
type ComponentEmit<T> = T extends new () => { $props: infer Props } type ComponentEmit<T> = T extends new () => { $props: infer Props }
? EmitsExtractor<Props> ? [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; : never;
// props に ref を許可するようにする
type ComponentProps<T extends Component> = { [K in keyof CP<T>]: CP<T>[K] | Ref<CP<T>[K]> };
type EmitsExtractor<T> = { 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]; [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); markRaw(component);
const id = ++popupIdCount; const id = ++popupIdCount;
@ -197,12 +212,12 @@ export function toast(message: string) {
export function alert(props: { export function alert(props: {
type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
title?: string | null; title?: string;
text?: string | null; text?: string;
}): Promise<void> { }): Promise<void> {
return new Promise((resolve, reject) => { return new Promise(resolve => {
popup(MkDialog, props, { popup(MkDialog, props, {
done: result => { done: () => {
resolve(); resolve();
}, },
}, 'closed'); }, 'closed');
@ -211,12 +226,12 @@ export function alert(props: {
export function confirm(props: { export function confirm(props: {
type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
title?: string | null; title?: string;
text?: string | null; text?: string;
okText?: string; okText?: string;
cancelText?: string; cancelText?: string;
}): Promise<{ canceled: boolean }> { }): Promise<{ canceled: boolean }> {
return new Promise((resolve, reject) => { return new Promise(resolve => {
popup(MkDialog, { popup(MkDialog, {
...props, ...props,
showCancelButton: true, showCancelButton: true,
@ -237,13 +252,15 @@ export function actions<T extends {
danger?: boolean, danger?: boolean,
}[]>(props: { }[]>(props: {
type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
title?: string | null; title?: string;
text?: string | null; text?: string;
actions: T; actions: T;
}): Promise<{ canceled: true; result: undefined; } | { }): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: T[number]['value']; canceled: false; result: T[number]['value'];
}> { }> {
return new Promise((resolve, reject) => { return new Promise(resolve => {
popup(MkDialog, { popup(MkDialog, {
...props, ...props,
actions: props.actions.map(a => ({ actions: props.actions.map(a => ({
@ -262,19 +279,50 @@ export function actions<T extends {
}); });
} }
// default が指定されていたら result は null になり得ないことを保証する overload function
export function inputText(props: { export function inputText(props: {
type?: 'text' | 'email' | 'password' | 'url'; type?: 'text' | 'email' | 'password' | 'url';
title?: string | null; title?: string;
text?: string | null; text?: string;
placeholder?: string | null;
autocomplete?: string;
default: string;
minLength?: number;
maxLength?: number;
}): 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; placeholder?: string | null;
autocomplete?: string; autocomplete?: string;
default?: string | null; default?: string | null;
minLength?: number; minLength?: number;
maxLength?: number; maxLength?: number;
}): Promise<{ canceled: true; result: undefined; } | { }): Promise<{
canceled: false; result: string; 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, { popup(MkDialog, {
title: props.title, title: props.title,
text: props.text, text: props.text,
@ -282,7 +330,7 @@ export function inputText(props: {
type: props.type, type: props.type,
placeholder: props.placeholder, placeholder: props.placeholder,
autocomplete: props.autocomplete, autocomplete: props.autocomplete,
default: props.default, default: props.default ?? null,
minLength: props.minLength, minLength: props.minLength,
maxLength: props.maxLength, maxLength: props.maxLength,
}, },
@ -294,16 +342,41 @@ export function inputText(props: {
}); });
} }
// default が指定されていたら result は null になり得ないことを保証する overload function
export function inputNumber(props: { export function inputNumber(props: {
title?: string | null; title?: string;
text?: string | null; text?: string;
placeholder?: string | null;
autocomplete?: string;
default: number;
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: number;
}>;
export function inputNumber(props: {
title?: string;
text?: string;
placeholder?: string | null; placeholder?: string | null;
autocomplete?: string; autocomplete?: string;
default?: number | null; default?: number | null;
}): Promise<{ canceled: true; result: undefined; } | { }): Promise<{
canceled: false; result: number; 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, { popup(MkDialog, {
title: props.title, title: props.title,
text: props.text, text: props.text,
@ -311,7 +384,7 @@ export function inputNumber(props: {
type: 'number', type: 'number',
placeholder: props.placeholder, placeholder: props.placeholder,
autocomplete: props.autocomplete, autocomplete: props.autocomplete,
default: props.default, default: props.default ?? null,
}, },
}, { }, {
done: result => { done: result => {
@ -322,34 +395,38 @@ export function inputNumber(props: {
} }
export function inputDate(props: { export function inputDate(props: {
title?: string | null; title?: string;
text?: string | null; text?: string;
placeholder?: string | null; placeholder?: string | null;
default?: Date | null; default?: string | null;
}): Promise<{ canceled: true; result: undefined; } | { }): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: Date; canceled: false; result: Date;
}> { }> {
return new Promise((resolve, reject) => { return new Promise(resolve => {
popup(MkDialog, { popup(MkDialog, {
title: props.title, title: props.title,
text: props.text, text: props.text,
input: { input: {
type: 'date', type: 'date',
placeholder: props.placeholder, placeholder: props.placeholder,
default: props.default, default: props.default ?? null,
}, },
}, { }, {
done: result => { 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'); }, '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; }; canceled: false; result: { password: string; token: string | null; };
}> { }> {
return new Promise((resolve, reject) => { return new Promise(resolve => {
popup(MkPasswordDialog, {}, { popup(MkPasswordDialog, {}, {
done: result => { done: result => {
resolve(result ? { canceled: false, result } : { canceled: true, result: undefined }); 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: { export function select<C = any>(props: {
title?: string | null; title?: string;
text?: string | null; text?: string;
default?: string | null; default: string;
} & ({
items: { items: {
value: C; value: C;
text: string; text: string;
}[]; }[];
}): Promise<{
canceled: true; result: undefined;
} | { } | {
groupedItems: { canceled: false; result: C;
label: string; }>;
export function select<C = any>(props: {
title?: string;
text?: string;
default?: string | null;
items: { items: {
value: C; value: C;
text: string; text: string;
}[]; }[];
}): Promise<{
canceled: true; result: undefined;
} | {
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; } | { }): Promise<{
canceled: false; result: C; canceled: true; result: undefined;
} | {
canceled: false; result: C | null;
}> { }> {
return new Promise((resolve, reject) => { return new Promise(resolve => {
popup(MkDialog, { popup(MkDialog, {
title: props.title, title: props.title,
text: props.text, text: props.text,
select: { select: {
items: props.items, items: props.items,
groupedItems: props.groupedItems, default: props.default ?? null,
default: props.default,
}, },
}, { }, {
done: result => { done: result => {
@ -396,7 +492,7 @@ export function select<C = any>(props: {
} }
export function success(): Promise<void> { export function success(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise(resolve => {
const showing = ref(true); const showing = ref(true);
window.setTimeout(() => { window.setTimeout(() => {
showing.value = false; showing.value = false;
@ -411,7 +507,7 @@ export function success(): Promise<void> {
} }
export function waiting(): Promise<void> { export function waiting(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise(resolve => {
const showing = ref(true); const showing = ref(true);
popup(MkWaitingDialog, { popup(MkWaitingDialog, {
success: false, success: false,
@ -422,9 +518,9 @@ export function waiting(): Promise<void> {
}); });
} }
export function form(title, form) { export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true } | { result: GetFormResultType<F> }> {
return new Promise((resolve, reject) => { return new Promise(resolve => {
popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form }, { popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, {
done: result => { done: result => {
resolve(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> { 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')), { popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {
includeSelf: opts.includeSelf, includeSelf: opts.includeSelf,
localOnly: opts.localOnly, 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[]> { 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')), { popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
type: 'file', type: 'file',
multiple, multiple,
@ -460,23 +556,23 @@ export async function selectDriveFile(multiple: boolean): Promise<Misskey.entiti
}); });
} }
export async function selectDriveFolder(multiple: boolean) { export async function selectDriveFolder(multiple: boolean): Promise<Misskey.entities.DriveFolder[]> {
return new Promise((resolve, reject) => { return new Promise(resolve => {
popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
type: 'folder', type: 'folder',
multiple, multiple,
}, { }, {
done: folders => { done: folders => {
if (folders) { if (folders) {
resolve(multiple ? folders : folders[0]); resolve(folders);
} }
}, },
}, 'closed'); }, 'closed');
}); });
} }
export async function pickEmoji(src: HTMLElement | null, opts) { export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog>): Promise<string> {
return new Promise((resolve, reject) => { return new Promise(resolve => {
popup(MkEmojiPickerDialog, { popup(MkEmojiPickerDialog, {
src, src,
...opts, ...opts,
@ -492,7 +588,7 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: {
aspectRatio: number; aspectRatio: number;
uploadFolder?: string | null; uploadFolder?: string | null;
}): Promise<Misskey.entities.DriveFile> { }): Promise<Misskey.entities.DriveFile> {
return new Promise((resolve, reject) => { return new Promise(resolve => {
popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), { popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), {
file: image, file: image,
aspectRatio: options.aspectRatio, aspectRatio: options.aspectRatio,
@ -505,67 +601,13 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: {
}); });
} }
type AwaitType<T> = export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | null, options?: {
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?: {
align?: string; align?: string;
width?: number; width?: number;
viaKeyboard?: boolean; viaKeyboard?: boolean;
onClosing?: () => void; onClosing?: () => void;
}): Promise<void> { }): Promise<void> {
return new Promise((resolve, reject) => { return new Promise(resolve => {
let dispose; let dispose;
popup(MkPopupMenu, { popup(MkPopupMenu, {
items, 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(); ev.preventDefault();
return new Promise((resolve, reject) => { return new Promise(resolve => {
let dispose; let dispose;
popup(MkContextMenu, { popup(MkContextMenu, {
items, items,
@ -608,7 +650,7 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent)
export function post(props: Record<string, any> = {}): Promise<void> { export function post(props: Record<string, any> = {}): Promise<void> {
showMovedDialog(); showMovedDialog();
return new Promise((resolve, reject) => { return new Promise(resolve => {
// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない // NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない
// NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、 // NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、
// Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、 // Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、

View File

@ -135,7 +135,7 @@ async function addRole() {
const { canceled, result: role } = await os.select({ 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 })), 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); rolesThatCanBeUsedThisEmojiAsReaction.value.push(role);
} }

View File

@ -52,7 +52,7 @@ const directNotesPagination = {
function setFilter(ev) { function setFilter(ev) {
const typeItems = notificationTypes.map(t => ({ const typeItems = notificationTypes.map(t => ({
text: i18n.ts._notification._types[t], text: i18n.ts._notification._types[t],
active: includeTypes.value && includeTypes.value.includes(t), active: (includeTypes.value && includeTypes.value.includes(t)) ?? false,
action: () => { action: () => {
includeTypes.value = [t]; includeTypes.value = [t];
}, },
@ -63,7 +63,7 @@ function setFilter(ev) {
action: () => { action: () => {
includeTypes.value = null; includeTypes.value = null;
}, },
}, { type: 'divider' }, ...typeItems] : typeItems; }, { type: 'divider' as const }, ...typeItems] : typeItems;
os.popupMenu(items, ev.currentTarget ?? ev.target); os.popupMenu(items, ev.currentTarget ?? ev.target);
} }

View File

@ -113,7 +113,7 @@ if (defaultStore.state.uploadFolder) {
function chooseUploadFolder() { function chooseUploadFolder() {
os.selectDriveFolder(false).then(async folder => { os.selectDriveFolder(false).then(async folder => {
defaultStore.set('uploadFolder', folder ? folder.id : null); defaultStore.set('uploadFolder', folder[0] ? folder[0].id : null);
os.success(); os.success();
if (defaultStore.state.uploadFolder) { if (defaultStore.state.uploadFolder) {
uploadFolder.value = await misskeyApi('drive/folders/show', { uploadFolder.value = await misskeyApi('drive/folders/show', {

View File

@ -213,7 +213,7 @@ async function pickEmoji(itemsRef: Ref<string[]>, ev: MouseEvent) {
os.pickEmoji(getHTMLElement(ev), { os.pickEmoji(getHTMLElement(ev), {
showPinned: false, showPinned: false,
}).then(it => { }).then(it => {
const emoji = it as string; const emoji = it;
if (!itemsRef.value.includes(emoji)) { if (!itemsRef.value.includes(emoji)) {
itemsRef.value.push(emoji); itemsRef.value.push(emoji);
} }

View File

@ -203,6 +203,7 @@ async function saveNew(): Promise<void> {
const { canceled, result: name } = await os.inputText({ const { canceled, result: name } = await os.inputText({
title: ts._preferencesBackups.inputName, title: ts._preferencesBackups.inputName,
default: '',
}); });
if (canceled) return; if (canceled) return;
@ -371,6 +372,7 @@ async function rename(id: string): Promise<void> {
const { canceled: cancel1, result: name } = await os.inputText({ const { canceled: cancel1, result: name } = await os.inputText({
title: ts._preferencesBackups.inputName, title: ts._preferencesBackups.inputName,
default: '',
}); });
if (cancel1 || profiles.value[id].name === name) return; if (cancel1 || profiles.value[id].name === name) return;

View File

@ -12,29 +12,37 @@ export type FormItem = {
label?: string; label?: string;
type: 'string'; type: 'string';
default: string | null; default: string | null;
description?: string;
required?: boolean;
hidden?: boolean; hidden?: boolean;
multiline?: boolean; multiline?: boolean;
treatAsMfm?: boolean;
} | { } | {
label?: string; label?: string;
type: 'number'; type: 'number';
default: number | null; default: number | null;
description?: string;
required?: boolean;
hidden?: boolean; hidden?: boolean;
step?: number; step?: number;
} | { } | {
label?: string; label?: string;
type: 'boolean'; type: 'boolean';
default: boolean | null; default: boolean | null;
description?: string;
hidden?: boolean; hidden?: boolean;
} | { } | {
label?: string; label?: string;
type: 'enum'; type: 'enum';
default: string | null; default: string | null;
required?: boolean;
hidden?: boolean; hidden?: boolean;
enum: EnumItem[]; enum: EnumItem[];
} | { } | {
label?: string; label?: string;
type: 'radio'; type: 'radio';
default: unknown | null; default: unknown | null;
required?: boolean;
hidden?: boolean; hidden?: boolean;
options: { options: {
label: string; label: string;
@ -44,9 +52,12 @@ export type FormItem = {
label?: string; label?: string;
type: 'range'; type: 'range';
default: number | null; default: number | null;
step: number; description?: string;
required?: boolean;
step?: number;
min: number; min: number;
max: number; max: number;
textConverter?: (value: number) => string;
} | { } | {
label?: string; label?: string;
type: 'object'; type: 'object';
@ -57,6 +68,10 @@ export type FormItem = {
type: 'array'; type: 'array';
default: unknown[] | null; default: unknown[] | null;
hidden: boolean; hidden: boolean;
} | {
type: 'button';
content?: string;
action: (ev: MouseEvent, v: any) => void;
}; };
export type Form = Record<string, FormItem>; export type Form = Record<string, FormItem>;

View File

@ -117,6 +117,7 @@ import XMentionsColumn from '@/ui/deck/mentions-column.vue';
import XDirectColumn from '@/ui/deck/direct-column.vue'; import XDirectColumn from '@/ui/deck/direct-column.vue';
import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue'; import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
import { mainRouter } from '@/router/main.js'; import { mainRouter } from '@/router/main.js';
import { MenuItem } from '@/types/menu.js';
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue'));
@ -221,21 +222,19 @@ document.documentElement.style.scrollBehavior = 'auto';
loadDeck(); loadDeck();
function changeProfile(ev: MouseEvent) { function changeProfile(ev: MouseEvent) {
const items = ref([{ let items: MenuItem[] = [{
text: deckStore.state.profile, text: deckStore.state.profile,
active: true.valueOf, active: true,
}]); action: () => {},
}];
getProfiles().then(profiles => { getProfiles().then(profiles => {
items.value = [{ items.push(...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({
text: deckStore.state.profile,
active: true.valueOf,
}, ...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({
text: k, text: k,
action: () => { action: () => {
deckStore.set('profile', k); deckStore.set('profile', k);
unisonReload(); unisonReload();
}, },
}))), { type: 'divider' }, { }))), { type: 'divider' as const }, {
text: i18n.ts._deck.newProfile, text: i18n.ts._deck.newProfile,
icon: 'ti ti-plus', icon: 'ti ti-plus',
action: async () => { action: async () => {
@ -248,9 +247,10 @@ function changeProfile(ev: MouseEvent) {
deckStore.set('profile', name); deckStore.set('profile', name);
unisonReload(); unisonReload();
}, },
}];
}); });
}).then(() => {
os.popupMenu(items, ev.currentTarget ?? ev.target); os.popupMenu(items, ev.currentTarget ?? ev.target);
});
} }
async function deleteProfile() { async function deleteProfile() {

View File

@ -93,10 +93,10 @@ const fetch = () => {
const choose = () => { const choose = () => {
os.selectDriveFolder(false).then(folder => { os.selectDriveFolder(false).then(folder => {
if (folder == null) { if (folder[0] == null) {
return; return;
} }
widgetProps.folderId = folder.id; widgetProps.folderId = folder[0].id;
save(); save();
fetch(); fetch();
}); });