From 55b3ae22ee81774b5641f3a42216327b9277f6e5 Mon Sep 17 00:00:00 2001
From: tamaina <tamaina@hotmail.co.jp>
Date: Sun, 30 Jan 2022 14:11:52 +0900
Subject: [PATCH] =?UTF-8?q?enhance:=20=E3=83=A1=E3=83=8B=E3=83=A5=E3=83=BC?=
 =?UTF-8?q?=E9=96=A2=E9=80=A3=E3=82=92Composition=20API=E5=8C=96=E3=80=81s?=
 =?UTF-8?q?witch=E3=82=A2=E3=82=A4=E3=83=86=E3=83=A0=E8=BF=BD=E5=8A=A0=20(?=
 =?UTF-8?q?#8215)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* メニューをComposition API化、switchアイテム追加
クライアントサイド画像圧縮の準備

* メニュー型定義を分離 (TypeScriptの型支援が効かないので)

* disabled

* make keepOriginal to follow setting value

* fix

* fix

* Fix

* clean up
---
 locales/ja-JP.yml                             |   2 +
 .../client/src/components/form/switch.vue     |  54 +++---
 .../client/src/components/ui/context-menu.vue | 113 +++++-------
 packages/client/src/components/ui/menu.vue    | 167 +++++++-----------
 .../client/src/components/ui/popup-menu.vue   |  48 ++---
 packages/client/src/os.ts                     |  10 +-
 packages/client/src/pages/settings/drive.vue  |   7 +-
 packages/client/src/scripts/select-file.ts    |   9 +-
 packages/client/src/store.ts                  |   4 +
 packages/client/src/types/menu.ts             |  20 +++
 10 files changed, 199 insertions(+), 235 deletions(-)
 create mode 100644 packages/client/src/types/menu.ts

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index b3279d78b..8fd41e533 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -235,6 +235,8 @@ resetAreYouSure: "リセットしますか?"
 saved: "保存しました"
 messaging: "チャット"
 upload: "アップロード"
+keepOriginalUploading: "オリジナル画像を保持"
+keepOriginalUploadingDescription: "画像をアップロードする時にオリジナル版を保持します。オフにするとアップロード時にブラウザでWeb公開用画像を生成します。"
 fromDrive: "ドライブから"
 fromUrl: "URLから"
 uploadFromUrl: "URLアップロード"
diff --git a/packages/client/src/components/form/switch.vue b/packages/client/src/components/form/switch.vue
index f8a07b4ca..b5a30d635 100644
--- a/packages/client/src/components/form/switch.vue
+++ b/packages/client/src/components/form/switch.vue
@@ -20,45 +20,33 @@
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, ref, toRefs } from 'vue';
+<script lang="ts" setup>
+import { toRefs, Ref } from 'vue';
 import * as os from '@/os';
 import Ripple from '@/components/ripple.vue';
 
-export default defineComponent({
-	props: {
-		modelValue: {
-			type: Boolean,
-			default: false
-		},
-		disabled: {
-			type: Boolean,
-			default: false
-		}
-	},
+const props = defineProps<{
+	modelValue: boolean | Ref<boolean>;
+	disabled?: boolean;
+}>();
 
-	setup(props, context) {
-		const button = ref<HTMLElement>();
-		const checked = toRefs(props).modelValue;
-		const toggle = () => {
-			if (props.disabled) return;
-			context.emit('update:modelValue', !checked.value);
+const emit = defineEmits<{
+	(e: 'update:modelValue', v: boolean): void;
+}>();
 
-			if (!checked.value) {
-				const rect = button.value.getBoundingClientRect();
-				const x = rect.left + (button.value.offsetWidth / 2);
-				const y = rect.top + (button.value.offsetHeight / 2);
-				os.popup(Ripple, { x, y, particle: false }, {}, 'end');
-			}
-		};
+let button = $ref<HTMLElement>();
+const checked = toRefs(props).modelValue;
+const toggle = () => {
+	if (props.disabled) return;
+	emit('update:modelValue', !checked.value);
 
-		return {
-			button,
-			checked,
-			toggle,
-		};
-	},
-});
+	if (!checked.value) {
+		const rect = button.getBoundingClientRect();
+		const x = rect.left + (button.offsetWidth / 2);
+		const y = rect.top + (button.offsetHeight / 2);
+		os.popup(Ripple, { x, y, particle: false }, {}, 'end');
+	}
+};
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/components/ui/context-menu.vue b/packages/client/src/components/ui/context-menu.vue
index 85606bf6d..f491b43b4 100644
--- a/packages/client/src/components/ui/context-menu.vue
+++ b/packages/client/src/components/ui/context-menu.vue
@@ -1,88 +1,71 @@
 <template>
 <transition :name="$store.state.animation ? 'fade' : ''" appear>
-	<div class="nvlagfpb" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
+	<div ref="rootEl" class="nvlagfpb" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
 		<MkMenu :items="items" class="_popup _shadow" :align="'left'" @close="$emit('closed')"/>
 	</div>
 </transition>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { onMounted, onBeforeUnmount } from 'vue';
 import contains from '@/scripts/contains';
 import MkMenu from './menu.vue';
+import { MenuItem } from './types/menu.vue';
 import * as os from '@/os';
 
-export default defineComponent({
-	components: {
-		MkMenu,
-	},
-	props: {
-		items: {
-			type: Array,
-			required: true
-		},
-		ev: {
-			required: true
-		},
-		viaKeyboard: {
-			type: Boolean,
-			required: false
-		},
-	},
-	emits: ['closed'],
-	data() {
-		return {
-			zIndex: os.claimZIndex('high'),
-		};
-	},
-	computed: {
-		keymap(): any {
-			return {
-				'esc': () => this.$emit('closed'),
-			};
-		},
-	},
-	mounted() {
-		let left = this.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
-		let top = this.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
+const props = defineProps<{
+	items: MenuItem[];
+	ev: MouseEvent;
+}>();
 
-		const width = this.$el.offsetWidth;
-		const height = this.$el.offsetHeight;
+const emit = defineEmits<{
+	(e: 'closed'): void;
+}>();
 
-		if (left + width - window.pageXOffset > window.innerWidth) {
-			left = window.innerWidth - width + window.pageXOffset;
-		}
+let rootEl = $ref<HTMLDivElement>();
 
-		if (top + height - window.pageYOffset > window.innerHeight) {
-			top = window.innerHeight - height + window.pageYOffset;
-		}
+let zIndex = $ref<number>(os.claimZIndex('high'));
 
-		if (top < 0) {
-			top = 0;
-		}
+onMounted(() => {
+	let left = props.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
+	let top = props.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
 
-		if (left < 0) {
-			left = 0;
-		}
+	const width = rootEl.offsetWidth;
+	const height = rootEl.offsetHeight;
 
-		this.$el.style.top = top + 'px';
-		this.$el.style.left = left + 'px';
+	if (left + width - window.pageXOffset > window.innerWidth) {
+		left = window.innerWidth - width + window.pageXOffset;
+	}
 
-		for (const el of Array.from(document.querySelectorAll('body *'))) {
-			el.addEventListener('mousedown', this.onMousedown);
-		}
-	},
-	beforeUnmount() {
-		for (const el of Array.from(document.querySelectorAll('body *'))) {
-			el.removeEventListener('mousedown', this.onMousedown);
-		}
-	},
-	methods: {
-		onMousedown(e) {
-			if (!contains(this.$el, e.target) && (this.$el != e.target)) this.$emit('closed');
-		},
+	if (top + height - window.pageYOffset > window.innerHeight) {
+		top = window.innerHeight - height + window.pageYOffset;
+	}
+
+	if (top < 0) {
+		top = 0;
+	}
+
+	if (left < 0) {
+		left = 0;
+	}
+
+	rootEl.style.top = `${top}px`;
+	rootEl.style.left = `${left}px`;
+
+	for (const el of Array.from(document.querySelectorAll('body *'))) {
+		el.addEventListener('mousedown', onMousedown);
 	}
 });
+
+onBeforeUnmount(() => {
+	for (const el of Array.from(document.querySelectorAll('body *'))) {
+		el.removeEventListener('mousedown', onMousedown);
+	}
+});
+
+function onMousedown(e: Event) {
+	if (!contains(rootEl, e.target) && (rootEl != e.target)) emit('closed');
+}
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/components/ui/menu.vue b/packages/client/src/components/ui/menu.vue
index 41165c8d3..a93cc8cda 100644
--- a/packages/client/src/components/ui/menu.vue
+++ b/packages/client/src/components/ui/menu.vue
@@ -1,8 +1,8 @@
 <template>
-<div ref="items" v-hotkey="keymap"
+<div ref="itemsEl" v-hotkey="keymap"
 	class="rrevdjwt"
 	:class="{ center: align === 'center', asDrawer }"
-	:style="{ width: (width && !asDrawer) ? width + 'px' : null, maxHeight: maxHeight ? maxHeight + 'px' : null }"
+	:style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }"
 	@contextmenu.self="e => e.preventDefault()"
 >
 	<template v-for="(item, i) in items2">
@@ -28,6 +28,9 @@
 			<MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/>
 			<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
 		</button>
+		<span v-else-if="item.type === 'switch'" :tabindex="i" class="item">
+			<FormSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</FormSwitch>
+		</span>
 		<button v-else :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)">
 			<i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
 			<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
@@ -41,114 +44,78 @@
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, ref, unref } from 'vue';
+<script lang="ts" setup>
+import { nextTick, onMounted, watch } from 'vue';
 import { focusPrev, focusNext } from '@/scripts/focus';
-import contains from '@/scripts/contains';
+import FormSwitch from '@/components/form/switch.vue';
+import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from '@/types/menu';
 
-export default defineComponent({
-	props: {
-		items: {
-			type: Array,
-			required: true
-		},
-		viaKeyboard: {
-			type: Boolean,
-			required: false
-		},
-		asDrawer: {
-			type: Boolean,
-			required: false
-		},
-		align: {
-			type: String,
-			requried: false
-		},
-		width: {
-			type: Number,
-			required: false
-		},
-		maxHeight: {
-			type: Number,
-			required: false
-		},
-	},
-	emits: ['close'],
-	data() {
-		return {
-			items2: [],
-		};
-	},
-	computed: {
-		keymap(): any {
-			return {
-				'up|k|shift+tab': this.focusUp,
-				'down|j|tab': this.focusDown,
-				'esc': this.close,
-			};
-		},
-	},
-	watch: {
-		items: {
-			handler() {
-				const items = ref(unref(this.items).filter(item => item !== undefined));
+const props = defineProps<{
+	items: MenuItem[];
+	viaKeyboard?: boolean;
+	asDrawer?: boolean;
+	align?: 'center' | string;
+	width?: number;
+	maxHeight?: number;
+}>();
 
-				for (let i = 0; i < items.value.length; i++) {
-					const item = items.value[i];
-					
-					if (item && item.then) { // if item is Promise
-						items.value[i] = { type: 'pending' };
-						item.then(actualItem => {
-							items.value[i] = actualItem;
-						});
-					}
-				}
+const emit = defineEmits<{
+	(e: 'close'): void;
+}>();
 
-				this.items2 = items;
-			},
-			immediate: true
-		}
-	},
-	mounted() {
-		if (this.viaKeyboard) {
-			this.$nextTick(() => {
-				focusNext(this.$refs.items.children[0], true, false);
+let itemsEl = $ref<HTMLDivElement>();
+
+let items2: InnerMenuItem[] = $ref([]);
+
+let keymap = $computed(() => ({
+	'up|k|shift+tab': focusUp,
+	'down|j|tab': focusDown,
+	'esc': close,
+}));
+
+watch(() => props.items, () => {
+	const items: (MenuItem | MenuPending)[] = [...props.items].filter(item => item !== undefined);
+
+	for (let i = 0; i < items.length; i++) {
+		const item = items[i];
+
+		if (item && 'then' in item) { // if item is Promise
+			items[i] = { type: 'pending' };
+			item.then(actualItem => {
+				items2[i] = actualItem;
 			});
 		}
+	}
 
-		if (this.contextmenuEvent) {
-			this.$el.style.top = this.contextmenuEvent.pageY + 'px';
-			this.$el.style.left = this.contextmenuEvent.pageX + 'px';
+	items2 = items as InnerMenuItem[];
+}, {
+	immediate: true,
+});
 
-			for (const el of Array.from(document.querySelectorAll('body *'))) {
-				el.addEventListener('mousedown', this.onMousedown);
-			}
-		}
-	},
-	beforeUnmount() {
-		for (const el of Array.from(document.querySelectorAll('body *'))) {
-			el.removeEventListener('mousedown', this.onMousedown);
-		}
-	},
-	methods: {
-		clicked(fn, ev) {
-			fn(ev);
-			this.close();
-		},
-		close() {
-			this.$emit('close');
-		},
-		focusUp() {
-			focusPrev(document.activeElement);
-		},
-		focusDown() {
-			focusNext(document.activeElement);
-		},
-		onMousedown(e) {
-			if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close();
-		},
+onMounted(() => {
+	if (props.viaKeyboard) {
+		nextTick(() => {
+			focusNext(itemsEl.children[0], true, false);
+		});
 	}
 });
+
+function clicked(fn: MenuAction, ev: MouseEvent) {
+	fn(ev);
+	close();
+}
+
+function close() {
+	emit('close');
+}
+
+function focusUp() {
+	focusPrev(document.activeElement);
+}
+
+function focusDown() {
+	focusNext(document.activeElement);
+}
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/components/ui/popup-menu.vue b/packages/client/src/components/ui/popup-menu.vue
index 8ffc4ad19..8d6c1b569 100644
--- a/packages/client/src/components/ui/popup-menu.vue
+++ b/packages/client/src/components/ui/popup-menu.vue
@@ -1,44 +1,28 @@
 <template>
-<MkModal ref="modal" v-slot="{ type, maxHeight }" :z-priority="'high'" :src="src" :transparent-bg="true" @click="$refs.modal.close()" @closed="$emit('closed')">
-	<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" class="sfhdhdhq _popup _shadow" :class="{ drawer: type === 'drawer' }" @close="$refs.modal.close()"/>
+<MkModal ref="modal" v-slot="{ type, maxHeight }" :z-priority="'high'" :src="src" :transparent-bg="true" @click="modal.close()" @closed="emit('closed')">
+	<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" class="sfhdhdhq _popup _shadow" :class="{ drawer: type === 'drawer' }" @close="modal.close()"/>
 </MkModal>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
 import MkModal from './modal.vue';
 import MkMenu from './menu.vue';
+import { MenuItem } from '@/types/menu';
 
-export default defineComponent({
-	components: {
-		MkModal,
-		MkMenu,
-	},
+defineProps<{
+	items: MenuItem[];
+	align?: 'center' | string;
+	width?: number;
+	viaKeyboard?: boolean;
+	src?: any;
+}>();
 
-	props: {
-		items: {
-			type: Array,
-			required: true
-		},
-		align: {
-			type: String,
-			required: false
-		},
-		width: {
-			type: Number,
-			required: false
-		},
-		viaKeyboard: {
-			type: Boolean,
-			required: false
-		},
-		src: {
-			required: false
-		},
-	},
+const emit = defineEmits<{
+	(e: 'closed'): void;
+}>();
 
-	emits: ['close', 'closed'],
-});
+let modal = $ref<InstanceType<typeof MkModal>>();
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/os.ts b/packages/client/src/os.ts
index f3be5c68f..95b4e87a1 100644
--- a/packages/client/src/os.ts
+++ b/packages/client/src/os.ts
@@ -7,8 +7,10 @@ import * as Misskey from 'misskey-js';
 import { apiUrl, url } from '@/config';
 import MkPostFormDialog from '@/components/post-form-dialog.vue';
 import MkWaitingDialog from '@/components/waiting-dialog.vue';
+import { MenuItem } from '@/types/menu';
 import { resolve } from '@/router';
 import { $i } from '@/account';
+import { defaultStore } from '@/store';
 
 export const pendingApiRequestsCount = ref(0);
 
@@ -470,7 +472,7 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea:
 	});
 }
 
-export function popupMenu(items: any[] | Ref<any[]>, src?: HTMLElement, options?: {
+export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement, options?: {
 	align?: string;
 	width?: number;
 	viaKeyboard?: boolean;
@@ -494,7 +496,7 @@ export function popupMenu(items: any[] | Ref<any[]>, src?: HTMLElement, options?
 	});
 }
 
-export function contextMenu(items: any[], ev: MouseEvent) {
+export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) {
 	ev.preventDefault();
 	return new Promise((resolve, reject) => {
 		let dispose;
@@ -541,7 +543,7 @@ export const uploads = ref<{
 	img: string;
 }[]>([]);
 
-export function upload(file: File, folder?: any, name?: string): Promise<Misskey.entities.DriveFile> {
+export function upload(file: File, folder?: any, name?: string, keepOriginal: boolean = defaultStore.state.keepOriginalUploading): Promise<Misskey.entities.DriveFile> {
 	if (folder && typeof folder == 'object') folder = folder.id;
 
 	return new Promise((resolve, reject) => {
@@ -559,6 +561,8 @@ export function upload(file: File, folder?: any, name?: string): Promise<Misskey
 
 			uploads.value.push(ctx);
 
+			console.log(keepOriginal);
+
 			const data = new FormData();
 			data.append('i', $i.token);
 			data.append('force', 'true');
diff --git a/packages/client/src/pages/settings/drive.vue b/packages/client/src/pages/settings/drive.vue
index f1016ebd8..134fa6330 100644
--- a/packages/client/src/pages/settings/drive.vue
+++ b/packages/client/src/pages/settings/drive.vue
@@ -28,6 +28,7 @@
 			<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template>
 			<template #suffixIcon><i class="fas fa-folder-open"></i></template>
 		</FormLink>
+		<FormSwitch v-model="keepOriginalUploading" class="_formBlock">{{ $ts.keepOriginalUploading }}<template #caption>{{ $ts.keepOriginalUploadingDescription }}</template></FormSwitch>
 	</FormSection>
 </div>
 </template>
@@ -36,18 +37,21 @@
 import { defineComponent } from 'vue';
 import * as tinycolor from 'tinycolor2';
 import FormLink from '@/components/form/link.vue';
+import FormSwitch from '@/components/form/switch.vue';
 import FormSection from '@/components/form/section.vue';
 import MkKeyValue from '@/components/key-value.vue';
 import FormSplit from '@/components/form/split.vue';
 import * as os from '@/os';
 import bytes from '@/filters/bytes';
 import * as symbols from '@/symbols';
+import { defaultStore } from '@/store';
 
 // TODO: render chart
 
 export default defineComponent({
 	components: {
 		FormLink,
+		FormSwitch,
 		FormSection,
 		MkKeyValue,
 		FormSplit,
@@ -79,7 +83,8 @@ export default defineComponent({
 					l: 0.5
 				})
 			};
-		}
+		},
+		keepOriginalUploading: defaultStore.makeGetterSetter('keepOriginalUploading'),
 	},
 
 	async created() {
diff --git a/packages/client/src/scripts/select-file.ts b/packages/client/src/scripts/select-file.ts
index 56e0b564f..23df4edf5 100644
--- a/packages/client/src/scripts/select-file.ts
+++ b/packages/client/src/scripts/select-file.ts
@@ -1,3 +1,4 @@
+import { ref } from 'vue';
 import * as os from '@/os';
 import { stream } from '@/stream';
 import { i18n } from '@/i18n';
@@ -6,12 +7,14 @@ import { DriveFile } from 'misskey-js/built/entities';
 
 function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile | DriveFile[]> {
 	return new Promise((res, rej) => {
+		const keepOriginal = ref(defaultStore.state.keepOriginalUploading);
+
 		const chooseFileFromPc = () => {
 			const input = document.createElement('input');
 			input.type = 'file';
 			input.multiple = multiple;
 			input.onchange = () => {
-				const promises = Array.from(input.files).map(file => os.upload(file, defaultStore.state.uploadFolder));
+				const promises = Array.from(input.files).map(file => os.upload(file, defaultStore.state.uploadFolder, undefined, keepOriginal.value));
 
 				Promise.all(promises).then(driveFiles => {
 					res(multiple ? driveFiles : driveFiles[0]);
@@ -74,6 +77,10 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv
 			text: label,
 			type: 'label'
 		} : undefined, {
+			type: 'switch',
+			text: i18n.ts.keepOriginalUploading,
+			ref: keepOriginal
+		}, {
 			text: i18n.ts.upload,
 			icon: 'fas fa-upload',
 			action: chooseFileFromPc
diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts
index cd358d29d..b80fc8bbe 100644
--- a/packages/client/src/store.ts
+++ b/packages/client/src/store.ts
@@ -43,6 +43,10 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'account',
 		default: 'yyyy-MM-dd HH-mm-ss [{{number}}]'
 	},
+	keepOriginalUploading: {
+		where: 'account',
+		default: false
+	},
 	memo: {
 		where: 'account',
 		default: null
diff --git a/packages/client/src/types/menu.ts b/packages/client/src/types/menu.ts
new file mode 100644
index 000000000..ed67e6ab8
--- /dev/null
+++ b/packages/client/src/types/menu.ts
@@ -0,0 +1,20 @@
+import * as Misskey from 'misskey-js';
+import { Ref } from 'vue';
+
+export type MenuAction = (ev: MouseEvent) => void;
+
+export type MenuDivider = null;
+export type MenuNull = undefined;
+export type MenuLabel = { type: 'label', text: string };
+export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User };
+export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean };
+export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction };
+export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, disabled?: boolean };
+export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean, avatar?: Misskey.entities.User; action: MenuAction };
+
+export type MenuPending = { type: 'pending' };
+
+type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton;
+type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton>;
+export type MenuItem = OuterMenuItem | OuterPromiseMenuItem;
+export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton;