From 5b4115e21a6822a434a9bfbbd53f22b3ca961239 Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sat, 13 Sep 2025 08:33:14 +0900
Subject: refactor(frontend): フロントエンドの型エラー解消（途中まで） (#16539)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* fix(frontend): FormLinkをボタンとして使用した際にエラーが出る問題を修正

* refactor(frontend): フロントエンドの型エラー解消

* remove unused ts-expect-error

* migrate

* remove unrelated changes

* fix lint

* more type fixes
---
 packages/frontend/src/pages/admin/roles.editor.vue | 1 -
 1 file changed, 1 deletion(-)

(limited to 'packages/frontend/src/pages/admin/roles.editor.vue')

diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index e98b4f0129..66004a44bb 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -830,7 +830,6 @@ import { watch, ref, computed } from 'vue';
 import { throttle } from 'throttle-debounce';
 import * as Misskey from 'misskey-js';
 import RolesEditorFormula from './RolesEditorFormula.vue';
-import type { GetMkSelectValueTypesFromDef, MkSelectItem } from '@/components/MkSelect.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkColorInput from '@/components/MkColorInput.vue';
 import MkSelect from '@/components/MkSelect.vue';
-- 
cgit v1.3.1-freya


From d4654dd7bd5bf1c7faa74ed89f592448c0076be8 Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sat, 13 Sep 2025 21:00:33 +0900
Subject: refactor(frontend): os.select,
 MkSelectのitem指定をオブジェクトによる定義に統一し、型を狭める (#16475)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* refactor(frontend): MkSelectのitem指定をオブジェクトによる定義に統一

* fix

* spdx

* fix

* fix os.select

* fix lint

* add comment

* fix

* fix: os.select対応漏れを修正

* fix

* fix

* fix: MkSelectのmodelに対する型チェックを厳格化

* fix

* fix

* fix

* Update packages/frontend/src/components/MkEmbedCodeGenDialog.vue

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>

* fix

* fix types

* fix

* fix

* Update packages/frontend/src/pages/admin/roles.editor.vue

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>

* fix: MkSelectに直接配列を指定している場合に正常に型が解決されるように

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
 .../frontend/src/components/MkAntennaEditor.vue    |  43 +++--
 packages/frontend/src/components/MkAsUi.vue        |  18 ++-
 packages/frontend/src/components/MkDialog.vue      |  34 ++--
 .../src/components/MkEmbedCodeGenDialog.vue        |  19 ++-
 packages/frontend/src/components/MkFormDialog.vue  |  24 ++-
 .../frontend/src/components/MkInstanceStats.vue    | 126 ++++++++++-----
 .../src/components/MkPaginationControl.vue         |  14 +-
 packages/frontend/src/components/MkPollEditor.vue  |  38 +++--
 packages/frontend/src/components/MkPostForm.vue    |  10 +-
 .../frontend/src/components/MkRoleSelectDialog.vue |   8 +-
 packages/frontend/src/components/MkSelect.vue      | 176 +++++++--------------
 .../src/components/MkWatermarkEditorDialog.vue     |  16 +-
 packages/frontend/src/components/MkWidgets.vue     |  14 +-
 packages/frontend/src/composables/use-mkselect.ts  |  38 +++++
 packages/frontend/src/os.ts                        |  44 +-----
 packages/frontend/src/pages/about.federation.vue   |  89 +++++------
 packages/frontend/src/pages/admin-user.vue         |  43 +++--
 .../src/pages/admin/RolesEditorFormula.vue         |  51 +++---
 .../abuse-report/notification-recipient.editor.vue |  42 +++--
 .../admin/abuse-report/notification-recipient.vue  |  18 ++-
 packages/frontend/src/pages/admin/abuses.vue       |  52 ++++--
 packages/frontend/src/pages/admin/ads.vue          |  26 +--
 .../frontend/src/pages/admin/announcements.vue     |  18 ++-
 .../custom-emojis-manager.local.list.search.vue    |  16 +-
 .../pages/admin/custom-emojis-manager.register.vue |  16 +-
 packages/frontend/src/pages/admin/federation.vue   |  63 +++++---
 packages/frontend/src/pages/admin/files.vue        |  18 ++-
 packages/frontend/src/pages/admin/invites.vue      |  39 +++--
 packages/frontend/src/pages/admin/moderation.vue   |  28 ++--
 packages/frontend/src/pages/admin/modlog.vue       |  16 +-
 .../frontend/src/pages/admin/overview.heatmap.vue  |  24 +--
 packages/frontend/src/pages/admin/roles.editor.vue |  36 +++--
 packages/frontend/src/pages/admin/roles.role.vue   |  10 +-
 packages/frontend/src/pages/admin/roles.vue        |  12 +-
 packages/frontend/src/pages/admin/users.vue        |  64 +++++---
 .../src/pages/avatar-decoration-edit-dialog.vue    |   8 +-
 packages/frontend/src/pages/debug.vue              |  43 +++--
 packages/frontend/src/pages/drop-and-fusion.vue    |  22 ++-
 packages/frontend/src/pages/emoji-edit-dialog.vue  |   8 +-
 packages/frontend/src/pages/flash/flash-edit.vue   |  16 +-
 packages/frontend/src/pages/instance-info.vue      |  34 ++--
 packages/frontend/src/pages/page-editor/common.ts  |  11 +-
 .../frontend/src/pages/page-editor/page-editor.vue |  16 +-
 .../frontend/src/pages/settings/drive-cleaner.vue  |  20 ++-
 .../frontend/src/pages/settings/emoji-palette.vue  |  32 ++--
 packages/frontend/src/pages/settings/navbar.vue    |   4 +-
 .../settings/notifications.notification-config.vue |  31 +++-
 .../frontend/src/pages/settings/preferences.vue    |  87 ++++++----
 packages/frontend/src/pages/settings/privacy.vue   | 110 ++++++++-----
 packages/frontend/src/pages/settings/profile.vue   |  19 ++-
 .../frontend/src/pages/settings/sounds.sound.vue   |  15 +-
 .../src/pages/settings/statusbar.statusbar.vue     |  31 +++-
 .../frontend/src/pages/settings/theme.manage.vue   |  28 ++--
 packages/frontend/src/preferences/manager.ts       |  14 +-
 packages/frontend/src/preferences/utility.ts       |   2 +-
 packages/frontend/src/ui/deck.vue                  |   2 +-
 packages/frontend/src/ui/deck/antenna-column.vue   |  16 +-
 packages/frontend/src/ui/deck/channel-column.vue   |   7 +-
 packages/frontend/src/ui/deck/list-column.vue      |  15 +-
 .../frontend/src/ui/deck/role-timeline-column.vue  |   7 +-
 packages/frontend/src/ui/deck/tl-column.vue        |   8 +-
 packages/frontend/src/utility/form.ts              |   3 +-
 packages/frontend/src/utility/get-user-menu.ts     |  20 +--
 packages/frontend/src/widgets/WidgetUserList.vue   |   8 +-
 64 files changed, 1173 insertions(+), 767 deletions(-)
 create mode 100644 packages/frontend/src/composables/use-mkselect.ts

(limited to 'packages/frontend/src/pages/admin/roles.editor.vue')

diff --git a/packages/frontend/src/components/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue
index e2febf7225..a41fdbc45d 100644
--- a/packages/frontend/src/components/MkAntennaEditor.vue
+++ b/packages/frontend/src/components/MkAntennaEditor.vue
@@ -10,17 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<MkInput v-model="name">
 				<template #label>{{ i18n.ts.name }}</template>
 			</MkInput>
-			<MkSelect v-model="src">
+			<MkSelect v-model="src" :items="antennaSourcesSelectDef">
 				<template #label>{{ i18n.ts.antennaSource }}</template>
-				<option value="all">{{ i18n.ts._antennaSources.all }}</option>
-				<!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>-->
-				<option value="users">{{ i18n.ts._antennaSources.users }}</option>
-				<!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>-->
-				<option value="users_blacklist">{{ i18n.ts._antennaSources.userBlacklist }}</option>
 			</MkSelect>
-			<MkSelect v-if="src === 'list'" v-model="userListId">
+			<MkSelect v-if="src === 'list'" v-model="userListId" :items="userListsSelectDef">
 				<template #label>{{ i18n.ts.userList }}</template>
-				<option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option>
 			</MkSelect>
 			<MkTextarea v-else-if="src === 'users' || src === 'users_blacklist'" v-model="users">
 				<template #label>{{ i18n.ts.users }}</template>
@@ -52,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { watch, ref } from 'vue';
+import { watch, ref, computed } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { DeepPartial } from '@/utility/merge.js';
 import MkButton from '@/components/MkButton.vue';
@@ -64,6 +58,7 @@ import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { deepMerge } from '@/utility/merge.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 
 type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & {
 	id?: string;
@@ -99,9 +94,35 @@ const emit = defineEmits<{
 	(ev: 'deleted'): void,
 }>();
 
+const {
+	model: src,
+	def: antennaSourcesSelectDef,
+} = useMkSelect({
+	items: [
+		{ value: 'all', label: i18n.ts._antennaSources.all },
+		//{ value: 'home', label: i18n.ts._antennaSources.homeTimeline },
+		{ value: 'users', label: i18n.ts._antennaSources.users },
+		//{ value: 'list', label: i18n.ts._antennaSources.userList },
+		{ value: 'users_blacklist', label: i18n.ts._antennaSources.userBlacklist },
+	],
+	initialValue: initialAntenna.src,
+});
+
+const {
+	model: userListId,
+	def: userListsSelectDef,
+} = useMkSelect({
+	items: computed(() => {
+		if (userLists.value == null) return [];
+		return userLists.value.map(list => ({
+			value: list.id,
+			label: list.name,
+		}));
+	}),
+	initialValue: initialAntenna.userListId,
+});
+
 const name = ref<string>(initialAntenna.name);
-const src = ref<Misskey.entities.AntennasCreateRequest['src']>(initialAntenna.src);
-const userListId = ref<string | null>(initialAntenna.userListId);
 const users = ref<string>(initialAntenna.users.join('\n'));
 const keywords = ref<string>(initialAntenna.keywords.map(x => x.join(' ')).join('\n'));
 const excludeKeywords = ref<string>(initialAntenna.excludeKeywords.map(x => x.join(' ')).join('\n'));
diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue
index 20a953c72c..a3b6112629 100644
--- a/packages/frontend/src/components/MkAsUi.vue
+++ b/packages/frontend/src/components/MkAsUi.vue
@@ -32,10 +32,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<template v-if="c.label" #label>{{ c.label }}</template>
 		<template v-if="c.caption" #caption>{{ c.caption }}</template>
 	</MkInput>
-	<MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" @update:modelValue="onSelectUpdate">
+	<MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" :items="selectDef" @update:modelValue="onSelectUpdate">
 		<template v-if="c.label" #label>{{ c.label }}</template>
 		<template v-if="c.caption" #caption>{{ c.caption }}</template>
-		<option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option>
 	</MkSelect>
 	<MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" inline @click="openPostForm">{{ c.text }}</MkButton>
 	<div v-else-if="c.type === 'postForm'" :class="$style.postForm">
@@ -74,6 +73,7 @@ import MkSelect from '@/components/MkSelect.vue';
 import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js';
 import MkFolder from '@/components/MkFolder.vue';
 import MkPostForm from '@/components/MkPostForm.vue';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 
 const props = withDefaults(defineProps<{
 	component: AsUiComponent;
@@ -130,7 +130,19 @@ function onSwitchUpdate(v: boolean) {
 	}
 }
 
-const valueForSelect = ref('default' in c && typeof c.default !== 'boolean' ? c.default ?? null : null);
+const {
+	model: valueForSelect,
+	def: selectDef,
+} = useMkSelect({
+	items: computed(() => {
+		if (c.type !== 'select') return [];
+		return (c.items ?? []).map(item => ({
+			value: item.value,
+			label: item.text,
+		}));
+	}),
+	initialValue: (c.type === 'select' && 'default' in c && typeof c.default !== 'boolean') ? c.default ?? null : null,
+});
 
 function onSelectUpdate(v) {
 	valueForSelect.value = v;
diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue
index 3f7519a43f..705301a6a6 100644
--- a/packages/frontend/src/components/MkDialog.vue
+++ b/packages/frontend/src/components/MkDialog.vue
@@ -29,16 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"/>
 			</template>
 		</MkInput>
-		<MkSelect v-if="select" v-model="selectedValue" autofocus>
-			<template v-if="select.items">
-				<template v-for="item in select.items">
-					<optgroup v-if="'sectionTitle' in item" :label="item.sectionTitle">
-						<option v-for="subItem in item.items" :value="subItem.value">{{ subItem.text }}</option>
-					</optgroup>
-					<option v-else :value="item.value">{{ item.text }}</option>
-				</template>
-			</template>
-		</MkSelect>
+		<MkSelect v-if="select" v-model="selectedValue" :items="selectDef" autofocus></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 != null" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
 			<MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
@@ -56,6 +47,8 @@ import MkModal from '@/components/MkModal.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkSelect from '@/components/MkSelect.vue';
+import type { MkSelectItem, OptionValue } from '@/components/MkSelect.vue';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import { i18n } from '@/i18n.js';
 
 type Input = {
@@ -67,17 +60,9 @@ type Input = {
 	maxLength?: number;
 };
 
-type SelectItem = {
-	value: any;
-	text: string;
-};
-
 type Select = {
-	items: (SelectItem | {
-		sectionTitle: string;
-		items: SelectItem[];
-	})[];
-	default: string | null;
+	items: MkSelectItem[];
+	default: OptionValue | null;
 };
 
 type Result = string | number | true | null;
@@ -115,7 +100,6 @@ const emit = defineEmits<{
 const modal = useTemplateRef('modal');
 
 const inputValue = ref<string | number | null>(props.input?.default ?? null);
-const selectedValue = ref(props.select?.default ?? null);
 
 const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'charactersBelow'>(() => {
 	if (props.input) {
@@ -134,6 +118,14 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character
 	return null;
 });
 
+const {
+	def: selectDef,
+	model: selectedValue,
+} = useMkSelect({
+	items: computed(() => props.select?.items ?? []),
+	initialValue: props.select?.default ?? null,
+});
+
 // overload function を使いたいので lint エラーを無視する
 function done(canceled: true): void;
 function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare
diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
index 17823deb85..0cb8499699 100644
--- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
+++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
@@ -52,11 +52,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 						<template #suffix>px</template>
 						<template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template>
 					</MkInput>
-					<MkSelect v-model="colorMode">
+					<MkSelect v-model="colorMode" :items="colorModeDef">
 						<template #label>{{ i18n.ts.theme }}</template>
-						<option value="auto">{{ i18n.ts.syncDeviceDarkMode }}</option>
-						<option value="light">{{ i18n.ts.light }}</option>
-						<option value="dark">{{ i18n.ts.dark }}</option>
 					</MkSelect>
 					<MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch>
 					<MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch>
@@ -105,6 +102,7 @@ import MkInfo from '@/components/MkInfo.vue';
 
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js';
 
@@ -162,7 +160,18 @@ const isEmbedWithScrollbar = computed(() => embedRouteWithScrollbar.includes(pro
 const header = ref(props.params?.header ?? true);
 const maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? null : 500);
 
-const colorMode = ref<'light' | 'dark' | 'auto'>(props.params?.colorMode ?? 'auto');
+const {
+	model: colorMode,
+	def: colorModeDef,
+} = useMkSelect({
+	items: [
+		{ value: 'auto', label: i18n.ts.syncDeviceDarkMode },
+		{ value: 'light', label: i18n.ts.light },
+		{ value: 'dark', label: i18n.ts.dark },
+	],
+	initialValue: props.params?.colorMode ?? 'auto',
+});
+
 const rounded = ref(props.params?.rounded ?? true);
 const border = ref(props.params?.border ?? true);
 
diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue
index 8d697499a5..142ccb12a3 100644
--- a/packages/frontend/src/components/MkFormDialog.vue
+++ b/packages/frontend/src/components/MkFormDialog.vue
@@ -39,9 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<span v-text="v.label || k"></span>
 					<template v-if="v.description" #caption>{{ v.description }}</template>
 				</MkSwitch>
-				<MkSelect v-else-if="v.type === 'enum'" v-model="values[k]">
+				<MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)">
 					<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="getEnumKey(option)" :value="getEnumValue(option)">{{ getEnumLabel(option) }}</option>
 				</MkSelect>
 				<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>
@@ -77,7 +76,8 @@ import MkRange from './MkRange.vue';
 import MkButton from './MkButton.vue';
 import MkRadios from './MkRadios.vue';
 import XFile from './MkFormDialog.file.vue';
-import type { EnumItem, Form, RadioFormItem } from '@/utility/form.js';
+import type { MkSelectItem } from '@/components/MkSelect.vue';
+import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import { i18n } from '@/i18n.js';
 
@@ -120,16 +120,14 @@ function cancel() {
 	dialog.value?.close();
 }
 
-function getEnumLabel(e: EnumItem) {
-	return typeof e === 'string' ? e : e.label;
-}
-
-function getEnumValue(e: EnumItem) {
-	return typeof e === 'string' ? e : e.value;
-}
-
-function getEnumKey(e: EnumItem) {
-	return typeof e === 'string' ? e : typeof e.value === 'string' ? e.value : JSON.stringify(e.value);
+function getMkSelectDef(def: EnumFormItem): MkSelectItem[] {
+	return def.enum.map((v) => {
+		if (typeof v === 'string') {
+			return { value: v, label: v };
+		} else {
+			return { value: v.value, label: v.label };
+		}
+	});
 }
 
 function getRadioKey(e: RadioFormItem['options'][number]) {
diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue
index 15578ca1c9..13048a2e1b 100644
--- a/packages/frontend/src/components/MkInstanceStats.vue
+++ b/packages/frontend/src/components/MkInstanceStats.vue
@@ -9,31 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<template #header>Chart</template>
 		<div :class="$style.chart">
 			<div class="selects">
-				<MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
-					<optgroup v-if="shouldShowFederation" :label="i18n.ts.federation">
-						<option value="federation">{{ i18n.ts._charts.federation }}</option>
-						<option value="ap-request">{{ i18n.ts._charts.apRequest }}</option>
-					</optgroup>
-					<optgroup :label="i18n.ts.users">
-						<option value="users">{{ i18n.ts._charts.usersIncDec }}</option>
-						<option value="users-total">{{ i18n.ts._charts.usersTotal }}</option>
-						<option value="active-users">{{ i18n.ts._charts.activeUsers }}</option>
-					</optgroup>
-					<optgroup :label="i18n.ts.notes">
-						<option value="notes">{{ i18n.ts._charts.notesIncDec }}</option>
-						<option value="local-notes">{{ i18n.ts._charts.localNotesIncDec }}</option>
-						<option v-if="shouldShowFederation" value="remote-notes">{{ i18n.ts._charts.remoteNotesIncDec }}</option>
-						<option value="notes-total">{{ i18n.ts._charts.notesTotal }}</option>
-					</optgroup>
-					<optgroup :label="i18n.ts.drive">
-						<option value="drive-files">{{ i18n.ts._charts.filesIncDec }}</option>
-						<option value="drive">{{ i18n.ts._charts.storageUsageIncDec }}</option>
-					</optgroup>
-				</MkSelect>
-				<MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;">
-					<option value="hour">{{ i18n.ts.perHour }}</option>
-					<option value="day">{{ i18n.ts.perDay }}</option>
-				</MkSelect>
+				<MkSelect v-model="chartSrc" :items="chartSrcDef" style="margin: 0; flex: 1;"></MkSelect>
+				<MkSelect v-model="chartSpan" :items="chartSpanDef" style="margin: 0 0 0 10px;"></MkSelect>
 			</div>
 			<div class="chart _panel">
 				<MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="true"></MkChart>
@@ -43,13 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 	<MkFoldableSection class="item">
 		<template #header>Active users heatmap</template>
-		<MkSelect v-model="heatmapSrc" style="margin: 0 0 12px 0;">
-			<option value="active-users">Active users</option>
-			<option value="notes">Notes</option>
-			<option v-if="shouldShowFederation" value="ap-requests-inbox-received">AP Requests: inboxReceived</option>
-			<option v-if="shouldShowFederation" value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option>
-			<option v-if="shouldShowFederation" value="ap-requests-deliver-failed">AP Requests: deliverFailed</option>
-		</MkSelect>
+		<MkSelect v-model="heatmapSrc" :items="heatmapSrcDef" style="margin: 0 0 12px 0;"></MkSelect>
 		<div class="_panel" :class="$style.heatmap">
 			<MkHeatmap :src="heatmapSrc" :label="'Read & Write'"/>
 		</div>
@@ -84,10 +55,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, ref, computed, useTemplateRef } from 'vue';
+import { onMounted, computed, useTemplateRef } from 'vue';
 import { Chart } from 'chart.js';
-import type { HeatmapSource } from '@/components/MkHeatmap.vue';
 import MkSelect from '@/components/MkSelect.vue';
+import type { MkSelectItem, ItemOption } from '@/components/MkSelect.vue';
 import MkChart from '@/components/MkChart.vue';
 import type { ChartSrc } from '@/components/MkChart.vue';
 import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
@@ -101,15 +72,96 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue';
 import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue';
 import { initChart } from '@/utility/init-chart.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 
 initChart();
 
 const shouldShowFederation = computed(() => instance.federation !== 'none' || $i?.isModerator);
 
 const chartLimit = 500;
-const chartSpan = ref<'hour' | 'day'>('hour');
-const chartSrc = ref<ChartSrc>('active-users');
-const heatmapSrc = ref<HeatmapSource>('active-users');
+const {
+	model: chartSpan,
+	def: chartSpanDef,
+} = useMkSelect({
+	items: [
+		{ value: 'hour', label: i18n.ts.perHour },
+		{ value: 'day', label: i18n.ts.perDay },
+	],
+	initialValue: 'hour',
+});
+const {
+	model: chartSrc,
+	def: chartSrcDef,
+} = useMkSelect({
+	items: computed<MkSelectItem<ChartSrc>[]>(() => {
+		const items: MkSelectItem<ChartSrc>[] = [];
+
+		if (shouldShowFederation.value) {
+			items.push({
+				type: 'group',
+				label: i18n.ts.federation,
+				items: [
+					{ value: 'federation', label: i18n.ts._charts.federation },
+					{ value: 'ap-request', label: i18n.ts._charts.apRequest },
+				],
+			});
+		}
+
+		items.push({
+			type: 'group',
+			label: i18n.ts.users,
+			items: [
+				{ value: 'users', label: i18n.ts._charts.usersIncDec },
+				{ value: 'users-total', label: i18n.ts._charts.usersTotal },
+				{ value: 'active-users', label: i18n.ts._charts.activeUsers },
+			],
+		});
+
+		const notesItems: ItemOption<ChartSrc>[] = [
+			{ value: 'notes', label: i18n.ts._charts.notesIncDec },
+			{ value: 'local-notes', label: i18n.ts._charts.localNotesIncDec },
+		];
+
+		if (shouldShowFederation.value) notesItems.push({ value: 'remote-notes', label: i18n.ts._charts.remoteNotesIncDec });
+
+		notesItems.push(
+			{ value: 'notes-total', label: i18n.ts._charts.notesTotal },
+		);
+
+		items.push({
+			type: 'group',
+			label: i18n.ts.notes,
+			items: notesItems,
+		});
+
+		items.push({
+			type: 'group',
+			label: i18n.ts.drive,
+			items: [
+				{ value: 'drive-files', label: i18n.ts._charts.filesIncDec },
+				{ value: 'drive', label: i18n.ts._charts.storageUsageIncDec },
+			],
+		});
+
+		return items;
+	}),
+	initialValue: 'active-users',
+});
+const {
+	model: heatmapSrc,
+	def: heatmapSrcDef,
+} = useMkSelect({
+	items: computed(() => [
+		{ value: 'active-users' as const, label: 'Active Users' },
+		{ value: 'notes' as const, label: 'Notes' },
+		...(shouldShowFederation.value ? [
+			{ value: 'ap-requests-inbox-received' as const, label: 'AP Requests: inboxReceived' },
+			{ value: 'ap-requests-deliver-succeeded' as const, label: 'AP Requests: deliverSucceeded' },
+			{ value: 'ap-requests-deliver-failed' as const, label: 'AP Requests: deliverFailed' },
+		] : []),
+	]),
+	initialValue: 'active-users',
+});
 const subDoughnutEl = useTemplateRef('subDoughnutEl');
 const pubDoughnutEl = useTemplateRef('pubDoughnutEl');
 
diff --git a/packages/frontend/src/components/MkPaginationControl.vue b/packages/frontend/src/components/MkPaginationControl.vue
index 10bed575a4..55aa3f2dc2 100644
--- a/packages/frontend/src/components/MkPaginationControl.vue
+++ b/packages/frontend/src/components/MkPaginationControl.vue
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <div :class="$style.root">
 	<div :class="$style.control">
-		<MkSelect v-model="order" :class="$style.order" :items="[{ label: i18n.ts._order.newest, value: 'newest' }, { label: i18n.ts._order.oldest, value: 'oldest' }]">
+		<MkSelect v-model="order" :class="$style.order" :items="orderDef">
 			<template #prefix><i class="ti ti-arrows-sort"></i></template>
 		</MkSelect>
 		<MkButton v-if="paginator.canSearch" v-tooltip="i18n.ts.search" iconOnly transparent rounded :active="searchOpened" @click="searchOpened = !searchOpened"><i class="ti ti-search"></i></MkButton>
@@ -45,6 +45,7 @@ import { i18n } from '@/i18n.js';
 import MkSelect from '@/components/MkSelect.vue';
 import MkInput from '@/components/MkInput.vue';
 import { formatDateTimeString } from '@/utility/format-time-string.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 
 const props = withDefaults(defineProps<{
 	paginator: T;
@@ -58,7 +59,16 @@ const props = withDefaults(defineProps<{
 const searchOpened = ref(false);
 const filterOpened = ref(props.filterOpened);
 
-const order = ref<'newest' | 'oldest'>('newest');
+const {
+	model: order,
+	def: orderDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts._order.newest, value: 'newest' },
+		{ label: i18n.ts._order.oldest, value: 'oldest' },
+	],
+	initialValue: 'newest',
+});
 const date = ref<number | null>(null);
 const q = ref<string | null>(null);
 
diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue
index 174c923bcf..b7c3d1f42d 100644
--- a/packages/frontend/src/components/MkPollEditor.vue
+++ b/packages/frontend/src/components/MkPollEditor.vue
@@ -22,11 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<MkSwitch v-model="multiple">{{ i18n.ts._poll.canMultipleVote }}</MkSwitch>
 	<section>
 		<div>
-			<MkSelect v-model="expiration" small>
+			<MkSelect v-model="expiration" :items="expirationDef" small>
 				<template #label>{{ i18n.ts._poll.expiration }}</template>
-				<option value="infinite">{{ i18n.ts._poll.infinite }}</option>
-				<option value="at">{{ i18n.ts._poll.at }}</option>
-				<option value="after">{{ i18n.ts._poll.after }}</option>
 			</MkSelect>
 			<section v-if="expiration === 'at'">
 				<MkInput v-model="atDate" small type="date" class="input">
@@ -40,12 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<MkInput v-model="after" small type="number" :min="1" class="input">
 					<template #label>{{ i18n.ts._poll.duration }}</template>
 				</MkInput>
-				<MkSelect v-model="unit" small>
-					<option value="second">{{ i18n.ts._time.second }}</option>
-					<option value="minute">{{ i18n.ts._time.minute }}</option>
-					<option value="hour">{{ i18n.ts._time.hour }}</option>
-					<option value="day">{{ i18n.ts._time.day }}</option>
-				</MkSelect>
+				<MkSelect v-model="unit" :items="unitDef" small></MkSelect>
 			</section>
 		</div>
 	</section>
@@ -61,6 +53,7 @@ import MkButton from './MkButton.vue';
 import { formatDateTimeString } from '@/utility/format-time-string.js';
 import { addTime } from '@/utility/time.js';
 import { i18n } from '@/i18n.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 
 export type PollEditorModelValue = {
 	expiresAt: number | null;
@@ -78,11 +71,32 @@ const emit = defineEmits<{
 
 const choices = ref(props.modelValue.choices);
 const multiple = ref(props.modelValue.multiple);
-const expiration = ref('infinite');
+const {
+	model: expiration,
+	def: expirationDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts._poll.infinite, value: 'infinite' },
+		{ label: i18n.ts._poll.at, value: 'at' },
+		{ label: i18n.ts._poll.after, value: 'after' },
+	],
+	initialValue: 'infinite',
+});
 const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'));
 const atTime = ref('00:00');
 const after = ref(0);
-const unit = ref('second');
+const {
+	model: unit,
+	def: unitDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts._time.second, value: 'second' },
+		{ label: i18n.ts._time.minute, value: 'minute' },
+		{ label: i18n.ts._time.hour, value: 'hour' },
+		{ label: i18n.ts._time.day, value: 'day' },
+	],
+	initialValue: 'second',
+});
 
 if (props.modelValue.expiresAt) {
 	expiration.value = 'at';
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index a3ff89fc4d..9fec7ea4da 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -567,11 +567,11 @@ async function toggleReactionAcceptance() {
 	const select = await os.select({
 		title: i18n.ts.reactionAcceptance,
 		items: [
-			{ value: null, text: i18n.ts.all },
-			{ value: 'likeOnlyForRemote' as const, text: i18n.ts.likeOnlyForRemote },
-			{ value: 'nonSensitiveOnly' as const, text: i18n.ts.nonSensitiveOnly },
-			{ value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' as const, text: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote },
-			{ value: 'likeOnly' as const, text: i18n.ts.likeOnly },
+			{ value: null, label: i18n.ts.all },
+			{ value: 'likeOnlyForRemote' as const, label: i18n.ts.likeOnlyForRemote },
+			{ value: 'nonSensitiveOnly' as const, label: i18n.ts.nonSensitiveOnly },
+			{ value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' as const, label: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote },
+			{ value: 'likeOnly' as const, label: i18n.ts.likeOnly },
 		],
 		default: reactionAcceptance.value,
 	});
diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue
index f1cc98def4..937804703d 100644
--- a/packages/frontend/src/components/MkRoleSelectDialog.vue
+++ b/packages/frontend/src/components/MkRoleSelectDialog.vue
@@ -102,12 +102,12 @@ async function addRole() {
 	const items = roles.value
 		.filter(r => r.isPublic)
 		.filter(r => !selectedRoleIds.value.includes(r.id))
-		.map(r => ({ text: r.name, value: r }));
+		.map(r => ({ label: r.name, value: r.id }));
 
-	const { canceled, result: role } = await os.select({ items });
-	if (canceled || role == null) return;
+	const { canceled, result: roleId } = await os.select({ items });
+	if (canceled || roleId == null) return;
 
-	selectedRoleIds.value.push(role.id);
+	selectedRoleIds.value.push(roleId);
 }
 
 async function removeRole(roleId: string) {
diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue
index 9cbaf676c7..e79236fe54 100644
--- a/packages/frontend/src/components/MkSelect.vue
+++ b/packages/frontend/src/components/MkSelect.vue
@@ -40,46 +40,41 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts">
-type ItemOption = {
+export type OptionValue = string | number | null;
+
+export type ItemOption<T extends OptionValue = OptionValue> = {
 	type?: 'option';
-	value: string | number | null;
+	value: T;
 	label: string;
 };
 
-type ItemGroup = {
+export type ItemGroup<T extends OptionValue = OptionValue> = {
 	type: 'group';
-	label: string;
-	items: ItemOption[];
+	label?: string;
+	items: ItemOption<T>[];
 };
 
-export type MkSelectItem = ItemOption | ItemGroup;
-
-type ValuesOfItems<T> = T extends (infer U)[]
-	? U extends { type: 'group'; items: infer V }
-		? V extends (infer W)[]
-			? W extends { value: infer X }
-				? X
-				: never
-			: never
-		: U extends { value: infer Y }
-			? Y
-			: never
+export type MkSelectItem<T extends OptionValue = OptionValue> = ItemOption<T> | ItemGroup<T>;
+
+export type GetMkSelectValueType<T extends MkSelectItem> = T extends ItemGroup
+	? T['items'][number]['value']
+	: T extends ItemOption
+		? T['value']
+		: never;
+
+export type GetMkSelectValueTypesFromDef<T extends MkSelectItem[]> = T[number] extends MkSelectItem
+	? GetMkSelectValueType<T[number]>
 	: never;
 </script>
 
-<script lang="ts" setup generic="T extends MkSelectItem[]">
-import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } from 'vue';
+<script lang="ts" setup generic="const ITEMS extends MkSelectItem[], MODELT extends OptionValue">
+import { onMounted, nextTick, ref, watch, computed, toRefs } from 'vue';
 import { useInterval } from '@@/js/use-interval.js';
-import type { VNode, VNodeChild } from 'vue';
 import type { MenuItem } from '@/types/menu.js';
 import * as os from '@/os.js';
 
-// TODO: itemsをslot内のoptionで指定する用法は廃止する(props.itemsを必須化する)
-// see: https://github.com/misskey-dev/misskey/issues/15558
-// あと型推論と相性が良くない
-
 const props = defineProps<{
-	modelValue: ValuesOfItems<T>;
+	items: ITEMS;
 	required?: boolean;
 	readonly?: boolean;
 	disabled?: boolean;
@@ -88,16 +83,17 @@ const props = defineProps<{
 	inline?: boolean;
 	small?: boolean;
 	large?: boolean;
-	items?: T;
 }>();
 
-const emit = defineEmits<{
-	(ev: 'update:modelValue', value: ValuesOfItems<T>): void;
-}>();
+type ModelTChecked = MODELT & (
+	MODELT extends GetMkSelectValueTypesFromDef<ITEMS>
+	? unknown
+	: 'Error: The type of model does not match the type of items.'
+);
 
-const slots = useSlots();
+const model = defineModel<ModelTChecked>({ required: true });
 
-const { modelValue, autofocus } = toRefs(props);
+const { autofocus } = toRefs(props);
 const focused = ref(false);
 const opening = ref(false);
 const currentValueText = ref<string | null>(null);
@@ -140,52 +136,26 @@ onMounted(() => {
 	});
 });
 
-watch([modelValue, () => props.items], () => {
-	if (props.items) {
-		let found: ItemOption | null = null;
-		for (const item of props.items) {
-			if (item.type === 'group') {
-				for (const option of item.items) {
-					if (option.value === modelValue.value) {
-						found = option;
-						break;
-					}
-				}
-			} else {
-				if (item.value === modelValue.value) {
-					found = item;
+watch([model, () => props.items], () => {
+	let found: ItemOption | null = null;
+	for (const item of props.items) {
+		if (item.type === 'group') {
+			for (const option of item.items) {
+				if (option.value === model.value) {
+					found = option;
 					break;
 				}
 			}
-		}
-		if (found) {
-			currentValueText.value = found.label;
-		}
-		return;
-	}
-
-	const scanOptions = (options: VNodeChild[]) => {
-		for (const vnode of options) {
-			if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue;
-			if (vnode.type === 'optgroup') {
-				const optgroup = vnode;
-				if (Array.isArray(optgroup.children)) scanOptions(optgroup.children);
-			} else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある
-				const fragment = vnode;
-				if (Array.isArray(fragment.children)) scanOptions(fragment.children);
-			} else if (vnode.props == null) { // v-if で条件が false のときにこうなる
-				// nop?
-			} else {
-				const option = vnode;
-				if (option.props?.value === modelValue.value) {
-					currentValueText.value = option.children as string;
-					break;
-				}
+		} else {
+			if (item.value === model.value) {
+				found = item;
+				break;
 			}
 		}
-	};
-
-	scanOptions(slots.default!());
+	}
+	if (found) {
+		currentValueText.value = found.label;
+	}
 }, { immediate: true, deep: true });
 
 function show() {
@@ -196,68 +166,32 @@ function show() {
 
 	const menu: MenuItem[] = [];
 
-	if (props.items) {
-		for (const item of props.items) {
-			if (item.type === 'group') {
+	for (const item of props.items) {
+		if (item.type === 'group') {
+			if (item.label != null) {
 				menu.push({
 					type: 'label',
 					text: item.label,
 				});
-				for (const option of item.items) {
-					menu.push({
-						text: option.label,
-						active: computed(() => modelValue.value === option.value),
-						action: () => {
-							emit('update:modelValue', option.value);
-						},
-					});
-				}
-			} else {
+			}
+			for (const option of item.items) {
 				menu.push({
-					text: item.label,
-					active: computed(() => modelValue.value === item.value),
+					text: option.label,
+					active: computed(() => model.value === option.value),
 					action: () => {
-						emit('update:modelValue', item.value);
+						model.value = option.value as ModelTChecked;
 					},
 				});
 			}
-		}
-	} else {
-		let options = slots.default!();
-
-		const pushOption = (option: VNode) => {
+		} else {
 			menu.push({
-				text: option.children as string,
-				active: computed(() => modelValue.value === option.props?.value),
+				text: item.label,
+				active: computed(() => model.value === item.value),
 				action: () => {
-					emit('update:modelValue', option.props?.value);
+					model.value = item.value as ModelTChecked;
 				},
 			});
-		};
-
-		const scanOptions = (options: VNodeChild[]) => {
-			for (const vnode of options) {
-				if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue;
-				if (vnode.type === 'optgroup') {
-					const optgroup = vnode;
-					menu.push({
-						type: 'label',
-						text: optgroup.props?.label,
-					});
-					if (Array.isArray(optgroup.children)) scanOptions(optgroup.children);
-				} else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある
-					const fragment = vnode;
-					if (Array.isArray(fragment.children)) scanOptions(fragment.children);
-				} else if (vnode.props == null) { // v-if で条件が false のときにこうなる
-					// nop?
-				} else {
-					const option = vnode;
-					pushOption(option);
-				}
-			}
-		};
-
-		scanOptions(options);
+		}
 	}
 
 	os.popupMenu(menu, container.value, {
diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue
index 206298b194..75a45548fd 100644
--- a/packages/frontend/src/components/MkWatermarkEditorDialog.vue
+++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue
@@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</div>
 			<div :class="$style.controls">
 				<div class="_spacer _gaps">
-					<MkSelect v-model="type" :items="[{ label: i18n.ts._watermarkEditor.text, value: 'text' }, { label: i18n.ts._watermarkEditor.image, value: 'image' }, { label: i18n.ts._watermarkEditor.advanced, value: 'advanced' }]">
+					<MkSelect v-model="type" :items="typeDef">
 						<template #label>{{ i18n.ts._watermarkEditor.type }}</template>
 					</MkSelect>
 
@@ -86,6 +86,7 @@ import * as os from '@/os.js';
 import { deepClone } from '@/utility/clone.js';
 import { ensureSignin } from '@/i.js';
 import { genId } from '@/utility/id.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 
 const $i = ensureSignin();
 
@@ -186,7 +187,18 @@ async function cancel() {
 	dialog.value?.close();
 }
 
-const type = ref(preset.layers.length > 1 ? 'advanced' : preset.layers[0].type);
+const {
+	model: type,
+	def: typeDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts._watermarkEditor.text, value: 'text' },
+		{ label: i18n.ts._watermarkEditor.image, value: 'image' },
+		{ label: i18n.ts._watermarkEditor.advanced, value: 'advanced' },
+	],
+	initialValue: preset.layers.length > 1 ? 'advanced' : preset.layers[0].type,
+});
+
 watch(type, () => {
 	if (type.value === 'text') {
 		preset.layers = [createTextLayer()];
diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue
index 08a018ea9b..cf7c2cda80 100644
--- a/packages/frontend/src/components/MkWidgets.vue
+++ b/packages/frontend/src/components/MkWidgets.vue
@@ -7,9 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 <div :class="$style.root">
 	<template v-if="edit">
 		<header :class="$style.editHeader">
-			<MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--MI-margin)" data-cy-widget-select>
+			<MkSelect v-model="widgetAdderSelected" :items="widgetAdderSelectedDef" style="margin-bottom: var(--MI-margin)" data-cy-widget-select>
 				<template #label>{{ i18n.ts.selectWidget }}</template>
-				<option v-for="widget in _widgetDefs" :key="widget" :value="widget">{{ i18n.ts._widgets[widget] }}</option>
 			</MkSelect>
 			<MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
 			<MkButton inline @click="emit('exit')">{{ i18n.ts.close }}</MkButton>
@@ -59,6 +58,7 @@ import { widgets as widgetDefs, federationWidgets } from '@/widgets/index.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 
 const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
 
@@ -89,7 +89,15 @@ const widgetRefs = {};
 const configWidget = (id: string) => {
 	widgetRefs[id].configure();
 };
-const widgetAdderSelected = ref<string | null>(null);
+
+const {
+	model: widgetAdderSelected,
+	def: widgetAdderSelectedDef,
+} = useMkSelect({
+	items: computed(() => [{ label: i18n.ts.none, value: null }, ..._widgetDefs.value.map(x => ({ label: i18n.ts._widgets[x], value: x }))]),
+	initialValue: null,
+});
+
 const addWidget = () => {
 	if (widgetAdderSelected.value == null) return;
 
diff --git a/packages/frontend/src/composables/use-mkselect.ts b/packages/frontend/src/composables/use-mkselect.ts
new file mode 100644
index 0000000000..7cb470d169
--- /dev/null
+++ b/packages/frontend/src/composables/use-mkselect.ts
@@ -0,0 +1,38 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { ref } from 'vue';
+import type { Ref, MaybeRefOrGetter } from 'vue';
+import type { MkSelectItem, OptionValue, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue';
+
+type UnwrapReadonlyItems<T> = T extends readonly (infer U)[] ? U[] : T;
+
+/** 指定したオプション定義をもとに型を狭めたrefを生成するコンポーサブル */
+export function useMkSelect<
+	const TItemsInput extends MaybeRefOrGetter<MkSelectItem[]>,
+	const TItems extends TItemsInput extends MaybeRefOrGetter<infer U> ? U : never,
+	TInitialValue extends OptionValue | void = void,
+	TItemsValue = GetMkSelectValueTypesFromDef<UnwrapReadonlyItems<TItems>>,
+	ModelType = TInitialValue extends void
+		? TItemsValue
+		: (TItemsValue | TInitialValue)
+>(opts: {
+	items: TItemsInput;
+	initialValue?: (TInitialValue | (OptionValue extends TItemsValue ? OptionValue : TInitialValue)) & (
+		TItemsValue extends TInitialValue
+			? unknown
+			: { 'Error: Type of initialValue must include all types of items': TItemsValue }
+	);
+}): {
+	def: TItemsInput;
+	model: Ref<ModelType>;
+} {
+	const model = ref(opts.initialValue ?? null);
+
+	return {
+		def: opts.items,
+		model: model as Ref<ModelType>,
+	};
+}
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index 504ae19f1d..6c5f04c6b5 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -14,6 +14,7 @@ import type { Form, GetFormResultType } from '@/utility/form.js';
 import type { MenuItem } from '@/types/menu.js';
 import type { PostFormProps } from '@/types/post-form.js';
 import type { UploaderFeatures } from '@/composables/use-uploader.js';
+import type { MkSelectItem, OptionValue } from '@/components/MkSelect.vue';
 import type MkRoleSelectDialog_TypeReferenceOnly from '@/components/MkRoleSelectDialog.vue';
 import type MkEmojiPickerDialog_TypeReferenceOnly from '@/components/MkEmojiPickerDialog.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
@@ -502,50 +503,15 @@ export function authenticateDialog(): Promise<{
 	});
 }
 
-type SelectItem<C> = {
-	value: C;
-	text: string;
-};
-
-// default が指定されていたら result は null になり得ないことを保証する overload function
-export function select<C = unknown>(props: {
+export function select<C extends OptionValue, D extends C | null = null>(props: {
 	title?: string;
 	text?: string;
-	default: string;
-	items: (SelectItem<C> | {
-		sectionTitle: string;
-		items: SelectItem<C>[];
-	} | undefined)[];
-}): Promise<{
-	canceled: true; result: undefined;
-} | {
-	canceled: false; result: C;
-}>;
-export function select<C = unknown>(props: {
-	title?: string;
-	text?: string;
-	default?: string | null;
-	items: (SelectItem<C> | {
-		sectionTitle: string;
-		items: SelectItem<C>[];
-	} | undefined)[];
-}): Promise<{
-	canceled: true; result: undefined;
-} | {
-	canceled: false; result: C | null;
-}>;
-export function select<C = unknown>(props: {
-	title?: string;
-	text?: string;
-	default?: string | null;
-	items: (SelectItem<C> | {
-		sectionTitle: string;
-		items: SelectItem<C>[];
-	} | undefined)[];
+	default?: D;
+	items: (MkSelectItem<C> | undefined)[];
 }): Promise<{
 	canceled: true; result: undefined;
 } | {
-	canceled: false; result: C | null;
+	canceled: false; result: Exclude<D, undefined> extends null ? C | null : C;
 }> {
 	return new Promise(resolve => {
 		const { dispose } = popup(MkDialog, {
diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue
index 568bf9fd1a..bbfb9a3b7c 100644
--- a/packages/frontend/src/pages/about.federation.vue
+++ b/packages/frontend/src/pages/about.federation.vue
@@ -11,56 +11,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<template #label>{{ i18n.ts.host }}</template>
 		</MkInput>
 		<FormSplit style="margin-top: var(--MI-margin);">
-			<MkSelect v-model="state">
+			<MkSelect v-model="state" :items="stateDef">
 				<template #label>{{ i18n.ts.state }}</template>
-				<option value="all">{{ i18n.ts.all }}</option>
-				<option value="federating">{{ i18n.ts.federating }}</option>
-				<option value="subscribing">{{ i18n.ts.subscribing }}</option>
-				<option value="publishing">{{ i18n.ts.publishing }}</option>
-				<option value="suspended">{{ i18n.ts.suspended }}</option>
-				<option value="silenced">{{ i18n.ts.silence }}</option>
-				<option value="blocked">{{ i18n.ts.blocked }}</option>
-				<option value="notResponding">{{ i18n.ts.notResponding }}</option>
 			</MkSelect>
-			<MkSelect
-				v-model="sort" :items="[{
-					label: `${i18n.ts.pubSub} (${i18n.ts.descendingOrder})`,
-					value: '+pubSub',
-				}, {
-					label: `${i18n.ts.pubSub} (${i18n.ts.ascendingOrder})`,
-					value: '-pubSub',
-				}, {
-					label: `${i18n.ts.notes} (${i18n.ts.descendingOrder})`,
-					value: '+notes',
-				}, {
-					label: `${i18n.ts.notes} (${i18n.ts.ascendingOrder})`,
-					value: '-notes',
-				}, {
-					label: `${i18n.ts.users} (${i18n.ts.descendingOrder})`,
-					value: '+users',
-				}, {
-					label: `${i18n.ts.users} (${i18n.ts.ascendingOrder})`,
-					value: '-users',
-				}, {
-					label: `${i18n.ts.following} (${i18n.ts.descendingOrder})`,
-					value: '+following',
-				}, {
-					label: `${i18n.ts.following} (${i18n.ts.ascendingOrder})`,
-					value: '-following',
-				}, {
-					label: `${i18n.ts.followers} (${i18n.ts.descendingOrder})`,
-					value: '+followers',
-				}, {
-					label: `${i18n.ts.followers} (${i18n.ts.ascendingOrder})`,
-					value: '-followers',
-				}, {
-					label: `${i18n.ts.registeredAt} (${i18n.ts.descendingOrder})`,
-					value: '+firstRetrievedAt',
-				}, {
-					label: `${i18n.ts.registeredAt} (${i18n.ts.ascendingOrder})`,
-					value: '-firstRetrievedAt',
-				}]"
-			>
+			<MkSelect v-model="sort" :items="sortDef">
 				<template #label>{{ i18n.ts.sort }}</template>
 			</MkSelect>
 		</FormSplit>
@@ -85,11 +39,46 @@ import MkPagination from '@/components/MkPagination.vue';
 import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
 import FormSplit from '@/components/form/split.vue';
 import { i18n } from '@/i18n.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import { Paginator } from '@/utility/paginator.js';
 
 const host = ref('');
-const state = ref('federating');
-const sort = ref<NonNullable<Misskey.entities.FederationInstancesRequest['sort']>>('+pubSub');
+const {
+	model: state,
+	def: stateDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.all, value: 'all' },
+		{ label: i18n.ts.federating, value: 'federating' },
+		{ label: i18n.ts.subscribing, value: 'subscribing' },
+		{ label: i18n.ts.publishing, value: 'publishing' },
+		{ label: i18n.ts.suspended, value: 'suspended' },
+		{ label: i18n.ts.silence, value: 'silenced' },
+		{ label: i18n.ts.blocked, value: 'blocked' },
+		{ label: i18n.ts.notResponding, value: 'notResponding' },
+	],
+	initialValue: 'federating',
+});
+const {
+	model: sort,
+	def: sortDef,
+} = useMkSelect({
+	items: [
+		{ label: `${i18n.ts.pubSub} (${i18n.ts.descendingOrder})`, value: '+pubSub' },
+		{ label: `${i18n.ts.pubSub} (${i18n.ts.ascendingOrder})`, value: '-pubSub' },
+		{ label: `${i18n.ts.notes} (${i18n.ts.descendingOrder})`, value: '+notes' },
+		{ label: `${i18n.ts.notes} (${i18n.ts.ascendingOrder})`, value: '-notes' },
+		{ label: `${i18n.ts.users} (${i18n.ts.descendingOrder})`, value: '+users' },
+		{ label: `${i18n.ts.users} (${i18n.ts.ascendingOrder})`, value: '-users' },
+		{ label: `${i18n.ts.following} (${i18n.ts.descendingOrder})`, value: '+following' },
+		{ label: `${i18n.ts.following} (${i18n.ts.ascendingOrder})`, value: '-following' },
+		{ label: `${i18n.ts.followers} (${i18n.ts.descendingOrder})`, value: '+followers' },
+		{ label: `${i18n.ts.followers} (${i18n.ts.ascendingOrder})`, value: '-followers' },
+		{ label: `${i18n.ts.registeredAt} (${i18n.ts.descendingOrder})`, value: '+firstRetrievedAt' },
+		{ label: `${i18n.ts.registeredAt} (${i18n.ts.ascendingOrder})`, value: '-firstRetrievedAt' },
+	],
+	initialValue: '+pubSub',
+});
 const paginator = markRaw(new Paginator('federation/instances', {
 	limit: 10,
 	offsetMode: true,
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
index 86dfd38d81..24f2d0d5eb 100644
--- a/packages/frontend/src/pages/admin-user.vue
+++ b/packages/frontend/src/pages/admin-user.vue
@@ -153,17 +153,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<div v-else-if="tab === 'announcements'" class="_gaps">
 			<MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.new }}</MkButton>
 
-			<MkSelect v-model="announcementsStatus">
+			<MkSelect v-model="announcementsStatus" :items="announcementsStatusDef">
 				<template #label>{{ i18n.ts.filter }}</template>
-				<option value="active">{{ i18n.ts.active }}</option>
-				<option value="archived">{{ i18n.ts.archived }}</option>
 			</MkSelect>
 
 			<MkPagination :paginator="announcementsPaginator">
 				<template #default="{ items }">
 					<div class="_gaps_s">
 						<div v-for="announcement in items" :key="announcement.id" v-panel :class="$style.announcementItem" @click="editAnnouncement(announcement)">
-							<span style="margin-right: 0.5em;">
+							<span v-if="'icon' in announcement" style="margin-right: 0.5em;">
 								<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
 								<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i>
 								<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i>
@@ -184,8 +182,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<div v-else-if="tab === 'chart'" class="_gaps_m">
 			<div class="cmhjzshm">
 				<div class="selects">
-					<MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;">
-						<option value="per-user-notes">{{ i18n.ts.notes }}</option>
+					<MkSelect v-model="chartSrc" :items="chartSrcDef" style="margin: 0 10px 0 0; flex: 1;">
 					</MkSelect>
 				</div>
 				<div class="charts">
@@ -229,6 +226,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 import { acct } from '@/filters/user.js';
 import { definePage } from '@/page.js';
 import { i18n } from '@/i18n.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import { ensureSignin, iAmAdmin, iAmModerator } from '@/i.js';
 import MkRolePreview from '@/components/MkRolePreview.vue';
 import MkPagination from '@/components/MkPagination.vue';
@@ -247,7 +245,15 @@ const props = withDefaults(defineProps<{
 const result = await _fetch_();
 
 const tab = ref(props.initialTab);
-const chartSrc = ref<ChartSrc>('per-user-notes');
+const {
+	model: chartSrc,
+	def: chartSrcDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.notes, value: 'per-user-notes' },
+],
+	initialValue: 'per-user-notes',
+});
 const user = ref(result.user);
 const info = ref(result.info);
 const ips = ref(result.ips);
@@ -264,7 +270,16 @@ const filesPaginator = markRaw(new Paginator('admin/drive/files', {
 	})),
 }));
 
-const announcementsStatus = ref<'active' | 'archived'>('active');
+const {
+	model: announcementsStatus,
+	def: announcementsStatusDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.active, value: 'active' },
+		{ label: i18n.ts.archived, value: 'archived' },
+	],
+	initialValue: 'active',
+});
 
 const announcementsPaginator = markRaw(new Paginator('admin/announcements/list', {
 	limit: 10,
@@ -428,22 +443,22 @@ async function assignRole() {
 
 	const { canceled, result: roleId } = await os.select({
 		title: i18n.ts._role.chooseRoleToAssign,
-		items: roles.map(r => ({ text: r.name, value: r.id })),
+		items: roles.map(r => ({ label: r.name, value: r.id })),
 	});
 	if (canceled || roleId == null) return;
 
 	const { canceled: canceled2, result: period } = await os.select({
 		title: i18n.ts.period + ': ' + roles.find(r => r.id === roleId)!.name,
 		items: [{
-			value: 'indefinitely', text: i18n.ts.indefinitely,
+			value: 'indefinitely', label: i18n.ts.indefinitely,
 		}, {
-			value: 'oneHour', text: i18n.ts.oneHour,
+			value: 'oneHour', label: i18n.ts.oneHour,
 		}, {
-			value: 'oneDay', text: i18n.ts.oneDay,
+			value: 'oneDay', label: i18n.ts.oneDay,
 		}, {
-			value: 'oneWeek', text: i18n.ts.oneWeek,
+			value: 'oneWeek', label: i18n.ts.oneWeek,
 		}, {
-			value: 'oneMonth', text: i18n.ts.oneMonth,
+			value: 'oneMonth', label: i18n.ts.oneMonth,
 		}],
 		default: 'indefinitely',
 	});
diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
index 89ecc155b2..9d9db9158d 100644
--- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue
+++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
@@ -6,26 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <div class="_gaps">
 	<div :class="$style.header">
-		<MkSelect v-model="type" :class="$style.typeSelect">
-			<option value="isLocal">{{ i18n.ts._role._condition.isLocal }}</option>
-			<option value="isRemote">{{ i18n.ts._role._condition.isRemote }}</option>
-			<option value="isSuspended">{{ i18n.ts._role._condition.isSuspended }}</option>
-			<option value="isLocked">{{ i18n.ts._role._condition.isLocked }}</option>
-			<option value="isBot">{{ i18n.ts._role._condition.isBot }}</option>
-			<option value="isCat">{{ i18n.ts._role._condition.isCat }}</option>
-			<option value="isExplorable">{{ i18n.ts._role._condition.isExplorable }}</option>
-			<option value="roleAssignedTo">{{ i18n.ts._role._condition.roleAssignedTo }}</option>
-			<option value="createdLessThan">{{ i18n.ts._role._condition.createdLessThan }}</option>
-			<option value="createdMoreThan">{{ i18n.ts._role._condition.createdMoreThan }}</option>
-			<option value="followersLessThanOrEq">{{ i18n.ts._role._condition.followersLessThanOrEq }}</option>
-			<option value="followersMoreThanOrEq">{{ i18n.ts._role._condition.followersMoreThanOrEq }}</option>
-			<option value="followingLessThanOrEq">{{ i18n.ts._role._condition.followingLessThanOrEq }}</option>
-			<option value="followingMoreThanOrEq">{{ i18n.ts._role._condition.followingMoreThanOrEq }}</option>
-			<option value="notesLessThanOrEq">{{ i18n.ts._role._condition.notesLessThanOrEq }}</option>
-			<option value="notesMoreThanOrEq">{{ i18n.ts._role._condition.notesMoreThanOrEq }}</option>
-			<option value="and">{{ i18n.ts._role._condition.and }}</option>
-			<option value="or">{{ i18n.ts._role._condition.or }}</option>
-			<option value="not">{{ i18n.ts._role._condition.not }}</option>
+		<MkSelect v-model="type" :items="typeDef" :class="$style.typeSelect">
 		</MkSelect>
 		<button v-if="draggable" class="drag-handle _button" :class="$style.dragHandle">
 			<i class="ti ti-menu-2"></i>
@@ -58,8 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq', 'notesLessThanOrEq', 'notesMoreThanOrEq'].includes(type)" v-model="v.value" type="number">
 	</MkInput>
 
-	<MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId">
-		<option v-for="role in roles.filter(r => r.target === 'manual')" :key="role.id" :value="role.id">{{ role.name }}</option>
+	<MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId" :items="assignedToDef">
 	</MkSelect>
 </div>
 </template>
@@ -69,6 +49,7 @@ import { computed, defineAsyncComponent, ref, watch } from 'vue';
 import { genId } from '@/utility/id.js';
 import MkInput from '@/components/MkInput.vue';
 import MkSelect from '@/components/MkSelect.vue';
+import type { GetMkSelectValueTypesFromDef, MkSelectItem } from '@/components/MkSelect.vue';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
 import { deepClone } from '@/utility/clone.js';
@@ -99,7 +80,29 @@ watch(v, () => {
 	emit('update:modelValue', v.value);
 }, { deep: true });
 
-const type = computed({
+const typeDef = [
+	{ label: i18n.ts._role._condition.isLocal, value: 'isLocal' },
+	{ label: i18n.ts._role._condition.isRemote, value: 'isRemote' },
+	{ label: i18n.ts._role._condition.isSuspended, value: 'isSuspended' },
+	{ label: i18n.ts._role._condition.isLocked, value: 'isLocked' },
+	{ label: i18n.ts._role._condition.isBot, value: 'isBot' },
+	{ label: i18n.ts._role._condition.isCat, value: 'isCat' },
+	{ label: i18n.ts._role._condition.isExplorable, value: 'isExplorable' },
+	{ label: i18n.ts._role._condition.roleAssignedTo, value: 'roleAssignedTo' },
+	{ label: i18n.ts._role._condition.createdLessThan, value: 'createdLessThan' },
+	{ label: i18n.ts._role._condition.createdMoreThan, value: 'createdMoreThan' },
+	{ label: i18n.ts._role._condition.followersLessThanOrEq, value: 'followersLessThanOrEq' },
+	{ label: i18n.ts._role._condition.followersMoreThanOrEq, value: 'followersMoreThanOrEq' },
+	{ label: i18n.ts._role._condition.followingLessThanOrEq, value: 'followingLessThanOrEq' },
+	{ label: i18n.ts._role._condition.followingMoreThanOrEq, value: 'followingMoreThanOrEq' },
+	{ label: i18n.ts._role._condition.notesLessThanOrEq, value: 'notesLessThanOrEq' },
+	{ label: i18n.ts._role._condition.notesMoreThanOrEq, value: 'notesMoreThanOrEq' },
+	{ label: i18n.ts._role._condition.and, value: 'and' },
+	{ label: i18n.ts._role._condition.or, value: 'or' },
+	{ label: i18n.ts._role._condition.not, value: 'not' },
+] as const satisfies MkSelectItem[];
+
+const type = computed<GetMkSelectValueTypesFromDef<typeof typeDef>>({
 	get: () => v.value.type,
 	set: (t) => {
 		if (t === 'and') v.value.values = [];
@@ -118,6 +121,8 @@ const type = computed({
 	},
 });
 
+const assignedToDef = computed(() => roles.filter(r => r.target === 'manual').map(r => ({ label: r.name, value: r.id })) satisfies MkSelectItem[]);
+
 function addValue() {
 	v.value.values.push({ id: genId(), type: 'isRemote' });
 }
diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue
index b69c818b48..7c3f736506 100644
--- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue
+++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue
@@ -22,27 +22,19 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<MkInput v-model="title">
 					<template #label>{{ i18n.ts.title }}</template>
 				</MkInput>
-				<MkSelect v-model="method">
+				<MkSelect v-model="method" :items="methodDef">
 					<template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template>
-					<option value="email">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option>
-					<option value="webhook">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option>
 					<template #caption>
 						{{ methodCaption }}
 					</template>
 				</MkSelect>
 				<div>
-					<MkSelect v-if="method === 'email'" v-model="userId">
+					<MkSelect v-if="method === 'email'" v-model="userId" :items="userIdDef">
 						<template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedUser }}</template>
-						<option v-for="user in moderators" :key="user.id" :value="user.id">
-							{{ user.name ? `${user.name}(${user.username})` : user.username }}
-						</option>
 					</MkSelect>
 					<div v-else-if="method === 'webhook'" :class="$style.systemWebhook">
-						<MkSelect v-model="systemWebhookId" style="flex: 1">
+						<MkSelect v-model="systemWebhookId" :items="systemWebhookIdDef" style="flex: 1">
 							<template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedWebhook }}</template>
-							<option v-for="webhook in systemWebhooks" :key="webhook.id ?? undefined" :value="webhook.id">
-								{{ webhook.name }}
-							</option>
 						</MkSelect>
 						<MkButton rounded :class="$style.systemWebhookEditButton" @click="onEditSystemWebhookClicked">
 							<span v-if="systemWebhookId === null" class="ti ti-plus" style="line-height: normal"/>
@@ -79,14 +71,13 @@ import MkModalWindow from '@/components/MkModalWindow.vue';
 import { i18n } from '@/i18n.js';
 import MkInput from '@/components/MkInput.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import MkSelect from '@/components/MkSelect.vue';
 import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkDivider from '@/components/MkDivider.vue';
 import * as os from '@/os.js';
 
-type NotificationRecipientMethod = 'email' | 'webhook';
-
 const emit = defineEmits<{
 	(ev: 'submitted'): void;
 	(ev: 'canceled'): void;
@@ -105,9 +96,28 @@ const dialogEl = useTemplateRef('dialogEl');
 const loading = ref<number>(0);
 
 const title = ref<string>('');
-const method = ref<NotificationRecipientMethod>('email');
-const userId = ref<string | null>(null);
-const systemWebhookId = ref<string | null>(null);
+const {
+	model: method,
+	def: methodDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts._abuseReport._notificationRecipient._recipientType.mail, value: 'email' },
+		{ label: i18n.ts._abuseReport._notificationRecipient._recipientType.webhook, value: 'webhook' },
+	],
+	initialValue: 'email',
+});
+const {
+	model: userId,
+	def: userIdDef,
+} = useMkSelect({
+	items: computed(() => moderators.value.map(u => ({ label: u.name ? `${u.name}(${u.username})` : u.username, value: u.id as string | null }))),
+});
+const {
+	model: systemWebhookId,
+	def: systemWebhookIdDef,
+} = useMkSelect({
+	items: computed(() => systemWebhooks.value.map(w => ({ label: w.name, value: w.id }))),
+});
 const isActive = ref<boolean>(true);
 
 const moderators = ref<entities.User[]>([]);
diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue
index f5e77cbe4e..893bd8d6d3 100644
--- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue
+++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue
@@ -13,11 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</MkButton>
 			</div>
 			<div :class="$style.subMenus" class="_gaps_s">
-				<MkSelect v-model="filterMethod" style="flex: 1">
+				<MkSelect v-model="filterMethod" :items="filterMethodDef" style="flex: 1">
 					<template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template>
-					<option :value="null">-</option>
-					<option :value="'email'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option>
-					<option :value="'webhook'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option>
 				</MkSelect>
 				<MkInput v-model="filterText" type="search" style="flex: 1">
 					<template #label>{{ i18n.ts._abuseReport._notificationRecipient.keywords }}</template>
@@ -51,10 +48,21 @@ import MkButton from '@/components/MkButton.vue';
 import * as os from '@/os.js';
 import MkDivider from '@/components/MkDivider.vue';
 import { i18n } from '@/i18n.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 
 const recipients = ref<entities.AbuseReportNotificationRecipient[]>([]);
 
-const filterMethod = ref<string | null>(null);
+const {
+	model: filterMethod,
+	def: filterMethodDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.all, value: null },
+		{ label: i18n.ts._abuseReport._notificationRecipient._recipientType.mail, value: 'email' },
+		{ label: i18n.ts._abuseReport._notificationRecipient._recipientType.webhook, value: 'webhook' },
+	],
+	initialValue: null,
+});
 const filterText = ref<string>('');
 
 const filteredRecipients = computed(() => {
diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue
index ab462229a7..76bf20b409 100644
--- a/packages/frontend/src/pages/admin/abuses.vue
+++ b/packages/frontend/src/pages/admin/abuses.vue
@@ -16,23 +16,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</MkTip>
 
 			<div :class="$style.inputs" class="_gaps">
-				<MkSelect v-model="state" style="margin: 0; flex: 1;">
+				<MkSelect v-model="state" :items="stateDef" style="margin: 0; flex: 1;">
 					<template #label>{{ i18n.ts.state }}</template>
-					<option value="all">{{ i18n.ts.all }}</option>
-					<option value="unresolved">{{ i18n.ts.unresolved }}</option>
-					<option value="resolved">{{ i18n.ts.resolved }}</option>
 				</MkSelect>
-				<MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
+				<MkSelect v-model="targetUserOrigin" :items="targetUserOriginDef" style="margin: 0; flex: 1;">
 					<template #label>{{ i18n.ts.reporteeOrigin }}</template>
-					<option value="combined">{{ i18n.ts.all }}</option>
-					<option value="local">{{ i18n.ts.local }}</option>
-					<option value="remote">{{ i18n.ts.remote }}</option>
 				</MkSelect>
-				<MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
+				<MkSelect v-model="reporterOrigin" :items="reporterOriginDef" style="margin: 0; flex: 1;">
 					<template #label>{{ i18n.ts.reporterOrigin }}</template>
-					<option value="combined">{{ i18n.ts.all }}</option>
-					<option value="local">{{ i18n.ts.local }}</option>
-					<option value="remote">{{ i18n.ts.remote }}</option>
 				</MkSelect>
 			</div>
 
@@ -64,13 +55,44 @@ import MkPagination from '@/components/MkPagination.vue';
 import XAbuseReport from '@/components/MkAbuseReport.vue';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import MkButton from '@/components/MkButton.vue';
 import { store } from '@/store.js';
 import { Paginator } from '@/utility/paginator.js';
 
-const state = ref('unresolved');
-const reporterOrigin = ref('combined');
-const targetUserOrigin = ref('combined');
+const {
+	model: state,
+	def: stateDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.all, value: 'all' },
+		{ label: i18n.ts.unresolved, value: 'unresolved' },
+		{ label: i18n.ts.resolved, value: 'resolved' },
+	],
+	initialValue: 'unresolved',
+});
+const {
+	model: reporterOrigin,
+	def: reporterOriginDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.all, value: 'combined' },
+		{ label: i18n.ts.local, value: 'local' },
+		{ label: i18n.ts.remote, value: 'remote' },
+	],
+	initialValue: 'combined',
+});
+const {
+	model: targetUserOrigin,
+	def: targetUserOriginDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.all, value: 'combined' },
+		{ label: i18n.ts.local, value: 'local' },
+		{ label: i18n.ts.remote, value: 'remote' },
+	],
+	initialValue: 'combined',
+});
 const searchUsername = ref('');
 const searchHost = ref('');
 
diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue
index 06a28db088..17008e0c13 100644
--- a/packages/frontend/src/pages/admin/ads.vue
+++ b/packages/frontend/src/pages/admin/ads.vue
@@ -6,11 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <PageWithHeader :actions="headerActions" :tabs="headerTabs">
 	<div class="_spacer" style="--MI_SPACER-w: 900px;">
-		<MkSelect v-model="filterType" :class="$style.input" @update:modelValue="filterItems">
+		<MkSelect v-model="filterType" :items="filterTypeDef" :class="$style.input" @update:modelValue="filterItems">
 			<template #label>{{ i18n.ts.state }}</template>
-			<option value="all">{{ i18n.ts.all }}</option>
-			<option value="publishing">{{ i18n.ts.publishing }}</option>
-			<option value="expired">{{ i18n.ts.expired }}</option>
 		</MkSelect>
 		<div>
 			<div v-for="ad in ads" class="_panel _gaps_m" :class="$style.ad">
@@ -95,6 +92,7 @@ import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 
 const ads = ref<Misskey.entities.Ad[]>([]);
 
@@ -102,7 +100,17 @@ const ads = ref<Misskey.entities.Ad[]>([]);
 const localTime = new Date();
 const localTimeDiff = localTime.getTimezoneOffset() * 60 * 1000;
 const daysOfWeek: string[] = [i18n.ts._weekday.sunday, i18n.ts._weekday.monday, i18n.ts._weekday.tuesday, i18n.ts._weekday.wednesday, i18n.ts._weekday.thursday, i18n.ts._weekday.friday, i18n.ts._weekday.saturday];
-const filterType = ref('all');
+const {
+	model: filterType,
+	def: filterTypeDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.all, value: 'all' },
+		{ label: i18n.ts.publishing, value: 'publishing' },
+		{ label: i18n.ts.expired, value: 'expired' },
+	],
+	initialValue: 'all',
+});
 let publishing: boolean | null = null;
 
 misskeyApi('admin/ad/list', { publishing: publishing }).then(adsResponse => {
@@ -121,7 +129,7 @@ misskeyApi('admin/ad/list', { publishing: publishing }).then(adsResponse => {
 	}
 });
 
-const filterItems = (v) => {
+const filterItems = (v: typeof filterType.value) => {
 	if (v === 'publishing') {
 		publishing = true;
 	} else if (v === 'expired') {
@@ -134,7 +142,7 @@ const filterItems = (v) => {
 };
 
 // 選択された曜日(index)のビットフラグを操作する
-function toggleDayOfWeek(ad, index) {
+function toggleDayOfWeek(ad: Misskey.entities.Ad, index: number) {
 	ad.dayOfWeek ^= 1 << index;
 }
 
@@ -153,7 +161,7 @@ function add() {
 	});
 }
 
-function remove(ad) {
+function remove(ad: Misskey.entities.Ad) {
 	os.confirm({
 		type: 'warning',
 		text: i18n.tsx.removeAreYouSure({ x: ad.url }),
@@ -169,7 +177,7 @@ function remove(ad) {
 	});
 }
 
-function save(ad) {
+function save(ad: Misskey.entities.Ad) {
 	if (ad.id === '') {
 		misskeyApi('admin/ad/create', {
 			...ad,
diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue
index e5903d6257..b90a724b17 100644
--- a/packages/frontend/src/pages/admin/announcements.vue
+++ b/packages/frontend/src/pages/admin/announcements.vue
@@ -10,10 +10,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<MkInfo>{{ i18n.ts._announcement.shouldNotBeUsedToPresentPermanentInfo }}</MkInfo>
 			<MkInfo v-if="announcements.length > 5" warn>{{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }}</MkInfo>
 
-			<MkSelect v-model="announcementsStatus">
+			<MkSelect v-model="announcementsStatus" :items="announcementsStatusDef">
 				<template #label>{{ i18n.ts.filter }}</template>
-				<option value="active">{{ i18n.ts.active }}</option>
-				<option value="archived">{{ i18n.ts.archived }}</option>
 			</MkSelect>
 
 			<MkLoading v-if="loading"/>
@@ -98,8 +96,18 @@ import { definePage } from '@/page.js';
 import MkFolder from '@/components/MkFolder.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import { genId } from '@/utility/id.js';
-
-const announcementsStatus = ref<'active' | 'archived'>('active');
+import { useMkSelect } from '@/composables/use-mkselect.js';
+
+const {
+	model: announcementsStatus,
+	def: announcementsStatusDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.active, value: 'active' },
+		{ label: i18n.ts.archived, value: 'archived' },
+	],
+	initialValue: 'active',
+});
 
 const loading = ref(true);
 const loadingMore = ref(false);
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue
index 9938d5cc4a..6b5272914b 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue
@@ -56,20 +56,24 @@ SPDX-License-Identifier: AGPL-3.0-only
 					</MkInput>
 					<MkSelect
 						v-model="model.sensitive"
+						:items="[
+							{ label: '-', value: null },
+							{ label: 'true', value: 'true' },
+							{ label: 'false', value: 'false' },
+						]"
 					>
 						<template #label>sensitive</template>
-						<option :value="null">-</option>
-						<option :value="true">true</option>
-						<option :value="false">false</option>
 					</MkSelect>
 
 					<MkSelect
 						v-model="model.localOnly"
+						:items="[
+							{ label: '-', value: null },
+							{ label: 'true', value: 'true' },
+							{ label: 'false', value: 'false' },
+						]"
 					>
 						<template #label>localOnly</template>
-						<option :value="null">-</option>
-						<option :value="true">true</option>
-						<option :value="false">false</option>
 					</MkSelect>
 					<MkInput
 						v-model="model.updatedAtFrom"
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue
index 176d1121c5..c343d88eb1 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue
@@ -12,11 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<template #caption>{{ i18n.ts._customEmojisManager._local._register.uploadSettingDescription }}</template>
 
 			<div class="_gaps">
-				<MkSelect v-model="selectedFolderId">
+				<MkSelect v-model="selectedFolderId" :items="selectedFolderIdDef">
 					<template #label>{{ i18n.ts.uploadFolder }}</template>
-					<option v-for="folder in uploadFolders" :key="folder.id" :value="folder.id">
-						{{ folder.name }}
-					</option>
 				</MkSelect>
 
 				<MkSwitch v-model="directoryToCategory">
@@ -63,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script setup lang="ts">
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
 import * as Misskey from 'misskey-js';
-import { onMounted, ref, useCssModule } from 'vue';
+import { computed, onMounted, ref, useCssModule } from 'vue';
 import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js';
 import type { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
 import type { DroppedFile } from '@/utility/file-drop.js';
@@ -87,6 +84,7 @@ import { chooseDriveFile, chooseFileFromPcAndUpload } from '@/utility/drive.js';
 import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js';
 import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
 import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 
 import { prefer } from '@/preferences.js';
 
@@ -229,7 +227,13 @@ function setupGrid(): GridSetting {
 
 const uploadFolders = ref<FolderItem[]>([]);
 const gridItems = ref<GridItem[]>([]);
-const selectedFolderId = ref(prefer.s.uploadFolder);
+const {
+	model: selectedFolderId,
+	def: selectedFolderIdDef,
+} = useMkSelect({
+	items: computed(() => uploadFolders.value.map(folder => ({ label: folder.name, value: folder.id || '' }))),
+	initialValue: prefer.s.uploadFolder,
+});
 const directoryToCategory = ref<boolean>(false);
 const registerButtonDisabled = ref<boolean>(false);
 const requestLogs = ref<RequestLogItem[]>([]);
diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue
index ddc3ff7b79..cbf7dbbff5 100644
--- a/packages/frontend/src/pages/admin/federation.vue
+++ b/packages/frontend/src/pages/admin/federation.vue
@@ -13,31 +13,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<template #label>{{ i18n.ts.host }}</template>
 				</MkInput>
 				<FormSplit style="margin-top: var(--MI-margin);">
-					<MkSelect v-model="state">
+					<MkSelect v-model="state" :items="stateDef">
 						<template #label>{{ i18n.ts.state }}</template>
-						<option value="all">{{ i18n.ts.all }}</option>
-						<option value="federating">{{ i18n.ts.federating }}</option>
-						<option value="subscribing">{{ i18n.ts.subscribing }}</option>
-						<option value="publishing">{{ i18n.ts.publishing }}</option>
-						<option value="suspended">{{ i18n.ts.suspended }}</option>
-						<option value="blocked">{{ i18n.ts.blocked }}</option>
-						<option value="silenced">{{ i18n.ts.silence }}</option>
-						<option value="notResponding">{{ i18n.ts.notResponding }}</option>
 					</MkSelect>
-					<MkSelect v-model="sort">
+					<MkSelect v-model="sort" :items="sortDef">
 						<template #label>{{ i18n.ts.sort }}</template>
-						<option value="+pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.descendingOrder }})</option>
-						<option value="-pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.ascendingOrder }})</option>
-						<option value="+notes">{{ i18n.ts.notes }} ({{ i18n.ts.descendingOrder }})</option>
-						<option value="-notes">{{ i18n.ts.notes }} ({{ i18n.ts.ascendingOrder }})</option>
-						<option value="+users">{{ i18n.ts.users }} ({{ i18n.ts.descendingOrder }})</option>
-						<option value="-users">{{ i18n.ts.users }} ({{ i18n.ts.ascendingOrder }})</option>
-						<option value="+following">{{ i18n.ts.following }} ({{ i18n.ts.descendingOrder }})</option>
-						<option value="-following">{{ i18n.ts.following }} ({{ i18n.ts.ascendingOrder }})</option>
-						<option value="+followers">{{ i18n.ts.followers }} ({{ i18n.ts.descendingOrder }})</option>
-						<option value="-followers">{{ i18n.ts.followers }} ({{ i18n.ts.ascendingOrder }})</option>
-						<option value="+firstRetrievedAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.descendingOrder }})</option>
-						<option value="-firstRetrievedAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.ascendingOrder }})</option>
 					</MkSelect>
 				</FormSplit>
 			</div>
@@ -64,11 +44,46 @@ import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
 import FormSplit from '@/components/form/split.vue';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import { Paginator } from '@/utility/paginator.js';
 
 const host = ref('');
-const state = ref('federating');
-const sort = ref('+pubSub');
+const {
+	model: state,
+	def: stateDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.all, value: 'all' },
+		{ label: i18n.ts.federating, value: 'federating' },
+		{ label: i18n.ts.subscribing, value: 'subscribing' },
+		{ label: i18n.ts.publishing, value: 'publishing' },
+		{ label: i18n.ts.suspended, value: 'suspended' },
+		{ label: i18n.ts.blocked, value: 'blocked' },
+		{ label: i18n.ts.silence, value: 'silenced' },
+		{ label: i18n.ts.notResponding, value: 'notResponding' },
+	],
+	initialValue: 'federating',
+});
+const {
+	model: sort,
+	def: sortDef,
+} = useMkSelect({
+	items: [
+		{ label: `${i18n.ts.pubSub} (${i18n.ts.descendingOrder})`, value: '+pubSub' },
+		{ label: `${i18n.ts.pubSub} (${i18n.ts.ascendingOrder})`, value: '-pubSub' },
+		{ label: `${i18n.ts.notes} (${i18n.ts.descendingOrder})`, value: '+notes' },
+		{ label: `${i18n.ts.notes} (${i18n.ts.ascendingOrder})`, value: '-notes' },
+		{ label: `${i18n.ts.users} (${i18n.ts.descendingOrder})`, value: '+users' },
+		{ label: `${i18n.ts.users} (${i18n.ts.ascendingOrder})`, value: '-users' },
+		{ label: `${i18n.ts.following} (${i18n.ts.descendingOrder})`, value: '+following' },
+		{ label: `${i18n.ts.following} (${i18n.ts.ascendingOrder})`, value: '-following' },
+		{ label: `${i18n.ts.followers} (${i18n.ts.descendingOrder})`, value: '+followers' },
+		{ label: `${i18n.ts.followers} (${i18n.ts.ascendingOrder})`, value: '-followers' },
+		{ label: `${i18n.ts.registeredAt} (${i18n.ts.descendingOrder})`, value: '+firstRetrievedAt' },
+		{ label: `${i18n.ts.registeredAt} (${i18n.ts.ascendingOrder})`, value: '-firstRetrievedAt' },
+	],
+	initialValue: '+pubSub',
+});
 const paginator = markRaw(new Paginator('federation/instances', {
 	limit: 10,
 	offsetMode: true,
diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue
index b4ec930997..c8b5980883 100644
--- a/packages/frontend/src/pages/admin/files.vue
+++ b/packages/frontend/src/pages/admin/files.vue
@@ -8,11 +8,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<div class="_spacer" style="--MI_SPACER-w: 900px;">
 		<div class="_gaps">
 			<div class="inputs" style="display: flex; gap: var(--MI-margin); flex-wrap: wrap;">
-				<MkSelect v-model="origin" style="margin: 0; flex: 1;">
+				<MkSelect v-model="origin" :items="originDef" style="margin: 0; flex: 1;">
 					<template #label>{{ i18n.ts.instance }}</template>
-					<option value="combined">{{ i18n.ts.all }}</option>
-					<option value="local">{{ i18n.ts.local }}</option>
-					<option value="remote">{{ i18n.ts.remote }}</option>
 				</MkSelect>
 				<MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="paginator.computedParams?.value?.origin === 'local'">
 					<template #label>{{ i18n.ts.host }}</template>
@@ -42,9 +39,20 @@ import * as os from '@/os.js';
 import { lookupFile } from '@/utility/admin-lookup.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import { Paginator } from '@/utility/paginator.js';
 
-const origin = ref<NonNullable<Misskey.entities.AdminDriveFilesRequest['origin']>>('local');
+const {
+	model: origin,
+	def: originDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.all, value: 'combined' },
+		{ label: i18n.ts.local, value: 'local' },
+		{ label: i18n.ts.remote, value: 'remote' },
+	],
+	initialValue: 'local',
+});
 const type = ref<string | null>(null);
 const searchHost = ref('');
 const userId = ref('');
diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue
index 1c551cb477..d52a57e582 100644
--- a/packages/frontend/src/pages/admin/invites.vue
+++ b/packages/frontend/src/pages/admin/invites.vue
@@ -26,19 +26,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</MkFolder>
 
 			<div :class="$style.inputs">
-				<MkSelect v-model="type" :class="$style.input">
+				<MkSelect v-model="type" :items="typeDef" :class="$style.input">
 					<template #label>{{ i18n.ts.state }}</template>
-					<option value="all">{{ i18n.ts.all }}</option>
-					<option value="unused">{{ i18n.ts.unused }}</option>
-					<option value="used">{{ i18n.ts.used }}</option>
-					<option value="expired">{{ i18n.ts.expired }}</option>
 				</MkSelect>
-				<MkSelect v-model="sort" :class="$style.input">
+				<MkSelect v-model="sort" :items="sortDef" :class="$style.input">
 					<template #label>{{ i18n.ts.sort }}</template>
-					<option value="+createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.ascendingOrder }})</option>
-					<option value="-createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.descendingOrder }})</option>
-					<option value="+usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.ascendingOrder }})</option>
-					<option value="-usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.descendingOrder }})</option>
 				</MkSelect>
 			</div>
 			<MkPagination :paginator="paginator">
@@ -67,10 +59,33 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import MkInviteCode from '@/components/MkInviteCode.vue';
 import { definePage } from '@/page.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import { Paginator } from '@/utility/paginator.js';
 
-const type = ref<NonNullable<Misskey.entities.AdminInviteListRequest['type']>>('all');
-const sort = ref<NonNullable<Misskey.entities.AdminInviteListRequest['sort']>>('+createdAt');
+const {
+	model: type,
+	def: typeDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.all, value: 'all' },
+		{ label: i18n.ts.unused, value: 'unused' },
+		{ label: i18n.ts.used, value: 'used' },
+		{ label: i18n.ts.expired, value: 'expired' },
+	],
+	initialValue: 'all',
+});
+const {
+	model: sort,
+	def: sortDef,
+} = useMkSelect({
+	items: [
+		{ label: `${i18n.ts.createdAt} (${i18n.ts.ascendingOrder})`, value: '+createdAt' },
+		{ label: `${i18n.ts.createdAt} (${i18n.ts.descendingOrder})`, value: '-createdAt' },
+		{ label: `${i18n.ts.usedAt} (${i18n.ts.ascendingOrder})`, value: '+usedAt' },
+		{ label: `${i18n.ts.usedAt} (${i18n.ts.descendingOrder})`, value: '-usedAt' },
+	],
+	initialValue: '+createdAt',
+});
 
 const paginator = markRaw(new Paginator('admin/invite/list', {
 	limit: 10,
diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue
index 435dd9c462..a11278b68a 100644
--- a/packages/frontend/src/pages/admin/moderation.vue
+++ b/packages/frontend/src/pages/admin/moderation.vue
@@ -25,18 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</SearchMarker>
 
 				<SearchMarker :keywords="['ugc', 'content', 'visibility', 'visitor', 'guest']">
-					<MkSelect
-						v-model="ugcVisibilityForVisitor" :items="[{
-							value: 'all',
-							label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.all,
-						}, {
-							value: 'local',
-							label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.localOnly + ' (' + i18n.ts.recommended + ')',
-						}, {
-							value: 'none',
-							label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.none,
-						}] as const" @update:modelValue="onChange_ugcVisibilityForVisitor"
-					>
+					<MkSelect v-model="ugcVisibilityForVisitor" :items="ugcVisibilityForVisitorDef" @update:modelValue="onChange_ugcVisibilityForVisitor">
 						<template #label><SearchLabel>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor }}</SearchLabel></template>
 						<template #caption>
 							<div><SearchText>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description }}</SearchText></div>
@@ -176,6 +165,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 import { fetchInstance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import MkButton from '@/components/MkButton.vue';
 import FormLink from '@/components/form/link.vue';
 import MkFolder from '@/components/MkFolder.vue';
@@ -185,7 +175,17 @@ const meta = await misskeyApi('admin/meta');
 
 const enableRegistration = ref(!meta.disableRegistration);
 const emailRequiredForSignup = ref(meta.emailRequiredForSignup);
-const ugcVisibilityForVisitor = ref(meta.ugcVisibilityForVisitor);
+const {
+	model: ugcVisibilityForVisitor,
+	def: ugcVisibilityForVisitorDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.all, value: 'all' },
+		{ label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.localOnly, value: 'local' },
+		{ label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.none, value: 'none' },
+	],
+	initialValue: meta.ugcVisibilityForVisitor,
+});
 const sensitiveWords = ref(meta.sensitiveWords.join('\n'));
 const prohibitedWords = ref(meta.prohibitedWords.join('\n'));
 const prohibitedWordsForNameOfUser = ref(meta.prohibitedWordsForNameOfUser.join('\n'));
@@ -221,7 +221,7 @@ function onChange_emailRequiredForSignup(value: boolean) {
 	});
 }
 
-function onChange_ugcVisibilityForVisitor(value: Misskey.entities.AdminUpdateMetaRequest['ugcVisibilityForVisitor']) {
+function onChange_ugcVisibilityForVisitor(value: typeof ugcVisibilityForVisitor.value) {
 	os.apiWithDialog('admin/update-meta', {
 		ugcVisibilityForVisitor: value,
 	}).then(() => {
diff --git a/packages/frontend/src/pages/admin/modlog.vue b/packages/frontend/src/pages/admin/modlog.vue
index 08bdc8d254..cb75be7edd 100644
--- a/packages/frontend/src/pages/admin/modlog.vue
+++ b/packages/frontend/src/pages/admin/modlog.vue
@@ -8,10 +8,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<div class="_spacer" style="--MI_SPACER-w: 900px;">
 		<div class="_gaps">
 			<MkPaginationControl :paginator="paginator" canFilter>
-				<MkSelect v-model="type" style="margin: 0; flex: 1;">
+				<MkSelect v-model="type" :items="typeDef" style="margin: 0; flex: 1;">
 					<template #label>{{ i18n.ts.type }}</template>
-					<option :value="null">{{ i18n.ts.all }}</option>
-					<option v-for="t in Misskey.moderationLogTypes" :key="t" :value="t">{{ i18n.ts._moderationLogTypes[t] ?? t }}</option>
 				</MkSelect>
 
 				<MkInput v-model="moderatorId" style="margin: 0; flex: 1;">
@@ -54,12 +52,22 @@ import MkTl from '@/components/MkTl.vue';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { prefer } from '@/preferences.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkPaginationControl from '@/components/MkPaginationControl.vue';
 import { Paginator } from '@/utility/paginator.js';
 
-const type = ref<string | null>(null);
+const {
+	model: type,
+	def: typeDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.all, value: null },
+		...Misskey.moderationLogTypes.map(t => ({ label: i18n.ts._moderationLogTypes[t] ?? t, value: t })),
+	],
+	initialValue: null,
+});
 const moderatorId = ref('');
 
 const paginator = markRaw(new Paginator('admin/show-moderation-logs', {
diff --git a/packages/frontend/src/pages/admin/overview.heatmap.vue b/packages/frontend/src/pages/admin/overview.heatmap.vue
index 6192d6eb0f..5edc01404c 100644
--- a/packages/frontend/src/pages/admin/overview.heatmap.vue
+++ b/packages/frontend/src/pages/admin/overview.heatmap.vue
@@ -5,24 +5,30 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div class="_panel" :class="$style.root">
-	<MkSelect v-model="src" style="margin: 0 0 12px 0;" small>
-		<option value="active-users">Active users</option>
-		<option value="notes">Notes</option>
-		<option value="ap-requests-inbox-received">AP Requests: inboxReceived</option>
-		<option value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option>
-		<option value="ap-requests-deliver-failed">AP Requests: deliverFailed</option>
+	<MkSelect v-model="src" :items="srcDef" style="margin: 0 0 12px 0;" small>
 	</MkSelect>
 	<MkHeatmap :src="src"/>
 </div>
 </template>
 
 <script lang="ts" setup>
-import { ref } from 'vue';
 import MkHeatmap from '@/components/MkHeatmap.vue';
 import MkSelect from '@/components/MkSelect.vue';
-import type { HeatmapSource } from '@/components/MkHeatmap.vue';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 
-const src = ref<HeatmapSource>('active-users');
+const {
+	model: src,
+	def: srcDef,
+} = useMkSelect({
+	items: [
+		{ label: 'Active users', value: 'active-users' },
+		{ label: 'Notes', value: 'notes' },
+		{ label: 'AP Requests: inboxReceived', value: 'ap-requests-inbox-received' },
+		{ label: 'AP Requests: deliverSucceeded', value: 'ap-requests-deliver-succeeded' },
+		{ label: 'AP Requests: deliverFailed', value: 'ap-requests-deliver-failed' },
+	],
+	initialValue: 'active-users',
+});
 </script>
 
 <style lang="scss" module>
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 66004a44bb..e10eb1163e 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -30,19 +30,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<template #caption>{{ i18n.ts._role.descriptionOfDisplayOrder }}</template>
 	</MkInput>
 
-	<MkSelect v-model="rolePermission" :readonly="readonly">
+	<MkSelect v-model="rolePermission" :items="rolePermissionDef" :readonly="readonly">
 		<template #label><i class="ti ti-shield-lock"></i> {{ i18n.ts._role.permission }}</template>
 		<template #caption><div v-html="i18n.ts._role.descriptionOfPermission.replaceAll('\n', '<br>')"></div></template>
-		<option value="normal">{{ i18n.ts.normalUser }}</option>
-		<option value="moderator">{{ i18n.ts.moderator }}</option>
-		<option value="administrator">{{ i18n.ts.administrator }}</option>
 	</MkSelect>
 
-	<MkSelect v-model="role.target" :readonly="readonly">
+	<MkSelect v-model="role.target" :items="[{ label: i18n.ts._role.manual, value: 'manual' }, { label: i18n.ts._role.conditional, value: 'conditional' }]" :readonly="readonly">
 		<template #label><i class="ti ti-users"></i> {{ i18n.ts._role.assignTarget }}</template>
 		<template #caption><div v-html="i18n.ts._role.descriptionOfAssignTarget.replaceAll('\n', '<br>')"></div></template>
-		<option value="manual">{{ i18n.ts._role.manual }}</option>
-		<option value="conditional">{{ i18n.ts._role.conditional }}</option>
 	</MkSelect>
 
 	<MkFolder v-if="role.target === 'conditional'" defaultOpen>
@@ -176,11 +171,17 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<MkSwitch v-model="role.policies.chatAvailability.useDefault" :readonly="readonly">
 						<template #label>{{ i18n.ts._role.useBaseValue }}</template>
 					</MkSwitch>
-					<MkSelect v-model="role.policies.chatAvailability.value" :disabled="role.policies.chatAvailability.useDefault" :readonly="readonly">
+					<MkSelect
+						v-model="role.policies.chatAvailability.value"
+						:items="[
+							{ label: i18n.ts.enabled, value: 'available' },
+							{ label: i18n.ts.readonly, value: 'readonly' },
+							{ label: i18n.ts.disabled, value: 'unavailable' },
+						]"
+						:disabled="role.policies.chatAvailability.useDefault"
+						:readonly="readonly"
+					>
 						<template #label>{{ i18n.ts.enable }}</template>
-						<option value="available">{{ i18n.ts.enabled }}</option>
-						<option value="readonly">{{ i18n.ts.readonly }}</option>
-						<option value="unavailable">{{ i18n.ts.disabled }}</option>
 					</MkSelect>
 					<MkRange v-model="role.policies.chatAvailability.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
 						<template #label>{{ i18n.ts._role.priority }}</template>
@@ -841,6 +842,7 @@ import FormSlot from '@/components/form/slot.vue';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import { deepClone } from '@/utility/clone.js';
+import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue';
 
 const emit = defineEmits<{
 	(ev: 'update:modelValue', v: any): void;
@@ -870,11 +872,17 @@ function updateAvatarDecorationLimit(value: string | number) {
 	role.value.policies.avatarDecorationLimit.value = limited;
 }
 
-const rolePermission = computed({
+const rolePermissionDef = [
+	{ label: i18n.ts.normalUser, value: 'normal' },
+	{ label: i18n.ts.moderator, value: 'moderator' },
+	{ label: i18n.ts.administrator, value: 'administrator' },
+] as const satisfies MkSelectItem[];
+
+const rolePermission = computed<GetMkSelectValueTypesFromDef<typeof rolePermissionDef>>({
 	get: () => role.value.isAdministrator ? 'administrator' : role.value.isModerator ? 'moderator' : 'normal',
 	set: (val) => {
-		role.value.isAdministrator = val === 'administrator';
-		role.value.isModerator = val === 'moderator';
+		role.value.isAdministrator = (val === 'administrator');
+		role.value.isModerator = (val === 'moderator');
 	},
 });
 
diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue
index 8e83cdb667..2e249eee50 100644
--- a/packages/frontend/src/pages/admin/roles.role.vue
+++ b/packages/frontend/src/pages/admin/roles.role.vue
@@ -115,15 +115,15 @@ async function assign() {
 	const { canceled: canceled2, result: period } = await os.select({
 		title: i18n.ts.period + ': ' + role.name,
 		items: [{
-			value: 'indefinitely', text: i18n.ts.indefinitely,
+			value: 'indefinitely', label: i18n.ts.indefinitely,
 		}, {
-			value: 'oneHour', text: i18n.ts.oneHour,
+			value: 'oneHour', label: i18n.ts.oneHour,
 		}, {
-			value: 'oneDay', text: i18n.ts.oneDay,
+			value: 'oneDay', label: i18n.ts.oneDay,
 		}, {
-			value: 'oneWeek', text: i18n.ts.oneWeek,
+			value: 'oneWeek', label: i18n.ts.oneWeek,
 		}, {
-			value: 'oneMonth', text: i18n.ts.oneMonth,
+			value: 'oneMonth', label: i18n.ts.oneMonth,
 		}],
 		default: 'indefinitely',
 	});
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 6483bf16a8..7a49860b2d 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -52,11 +52,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<MkFolder v-if="matchQuery([i18n.ts._role._options.chatAvailability, 'chatAvailability'])">
 						<template #label>{{ i18n.ts._role._options.chatAvailability }}</template>
 						<template #suffix>{{ policies.chatAvailability === 'available' ? i18n.ts.yes : policies.chatAvailability === 'readonly' ? i18n.ts.readonly : i18n.ts.no }}</template>
-						<MkSelect v-model="policies.chatAvailability">
+						<MkSelect
+							v-model="policies.chatAvailability"
+							:items="[
+								{ label: i18n.ts.enabled, value: 'available' },
+								{ label: i18n.ts.readonly, value: 'readonly' },
+								{ label: i18n.ts.disabled, value: 'unavailable' },
+							]"
+						>
 							<template #label>{{ i18n.ts.enable }}</template>
-							<option value="available">{{ i18n.ts.enabled }}</option>
-							<option value="readonly">{{ i18n.ts.readonly }}</option>
-							<option value="unavailable">{{ i18n.ts.disabled }}</option>
 						</MkSelect>
 					</MkFolder>
 
diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue
index 7cbaeba8c7..2f7ecca521 100644
--- a/packages/frontend/src/pages/admin/users.vue
+++ b/packages/frontend/src/pages/admin/users.vue
@@ -11,26 +11,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<MkButton style="margin-left: auto" @click="resetQuery">{{ i18n.ts.reset }}</MkButton>
 			</div>
 			<div :class="$style.inputs">
-				<MkSelect v-model="sort" style="flex: 1;">
+				<MkSelect v-model="sort" :items="sortDef" style="flex: 1;">
 					<template #label>{{ i18n.ts.sort }}</template>
-					<option value="-createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.ascendingOrder }})</option>
-					<option value="+createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.descendingOrder }})</option>
-					<option value="-updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.ascendingOrder }})</option>
-					<option value="+updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.descendingOrder }})</option>
 				</MkSelect>
-				<MkSelect v-model="state" style="flex: 1;">
+				<MkSelect v-model="state" :items="stateDef" style="flex: 1;">
 					<template #label>{{ i18n.ts.state }}</template>
-					<option value="all">{{ i18n.ts.all }}</option>
-					<option value="available">{{ i18n.ts.normal }}</option>
-					<option value="admin">{{ i18n.ts.administrator }}</option>
-					<option value="moderator">{{ i18n.ts.moderator }}</option>
-					<option value="suspended">{{ i18n.ts.suspend }}</option>
 				</MkSelect>
-				<MkSelect v-model="origin" style="flex: 1;">
+				<MkSelect v-model="origin" :items="originDef" style="flex: 1;">
 					<template #label>{{ i18n.ts.instance }}</template>
-					<option value="combined">{{ i18n.ts.all }}</option>
-					<option value="local">{{ i18n.ts.local }}</option>
-					<option value="remote">{{ i18n.ts.remote }}</option>
 				</MkSelect>
 			</div>
 			<div :class="$style.inputs">
@@ -67,23 +55,57 @@ import * as os from '@/os.js';
 import { lookupUser } from '@/utility/admin-lookup.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import { dateString } from '@/filters/date.js';
 import { Paginator } from '@/utility/paginator.js';
 
 type SearchQuery = {
-	sort?: string;
-	state?: string;
-	origin?: string;
+	sort?: '-createdAt' | '+createdAt' | '-updatedAt' | '+updatedAt';
+	state?: 'all' | 'available' | 'admin' | 'moderator' | 'suspended';
+	origin?: 'combined' | 'local' | 'remote';
 	username?: string;
 	hostname?: string;
 };
 
 const storedQuery = JSON.parse(defaultMemoryStorage.getItem('admin-users-query') ?? '{}') as SearchQuery;
 
-const sort = ref(storedQuery.sort ?? '+createdAt');
-const state = ref(storedQuery.state ?? 'all');
-const origin = ref(storedQuery.origin ?? 'local');
+const {
+	model: sort,
+	def: sortDef,
+} = useMkSelect({
+	items: [
+		{ label: `${i18n.ts.registeredDate} (${i18n.ts.ascendingOrder})`, value: '-createdAt' },
+		{ label: `${i18n.ts.registeredDate} (${i18n.ts.descendingOrder})`, value: '+createdAt' },
+		{ label: `${i18n.ts.lastUsed} (${i18n.ts.ascendingOrder})`, value: '-updatedAt' },
+		{ label: `${i18n.ts.lastUsed} (${i18n.ts.descendingOrder})`, value: '+updatedAt' },
+	],
+	initialValue: storedQuery.sort ?? '+createdAt',
+});
+const {
+	model: state,
+	def: stateDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.all, value: 'all' },
+		{ label: i18n.ts.normal, value: 'available' },
+		{ label: i18n.ts.administrator, value: 'admin' },
+		{ label: i18n.ts.moderator, value: 'moderator' },
+		{ label: i18n.ts.suspend, value: 'suspended' },
+	],
+	initialValue: storedQuery.state ?? 'all',
+});
+const {
+	model: origin,
+	def: originDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.all, value: 'combined' },
+		{ label: i18n.ts.local, value: 'local' },
+		{ label: i18n.ts.remote, value: 'remote' },
+	],
+	initialValue: storedQuery.origin ?? 'local',
+});
 const searchUsername = ref(storedQuery.username ?? '');
 const searchHost = ref(storedQuery.hostname ?? '');
 const paginator = markRaw(new Paginator('admin/show-users', {
diff --git a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue
index ddc4e89ef1..a8ce527523 100644
--- a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue
+++ b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue
@@ -101,12 +101,12 @@ async function addRole() {
 	const roles = await misskeyApi('admin/roles/list');
 	const currentRoleIds = rolesThatCanBeUsedThisDecoration.value.map(x => x.id);
 
-	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 })),
+	const { canceled, result: roleId } = await os.select({
+		items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ label: r.name, value: r.id })),
 	});
-	if (canceled || role == null) return;
+	if (canceled || roleId == null) return;
 
-	rolesThatCanBeUsedThisDecoration.value.push(role);
+	rolesThatCanBeUsedThisDecoration.value.push(roles.find(r => r.id === roleId)!);
 }
 
 async function removeRole(role, ev) {
diff --git a/packages/frontend/src/pages/debug.vue b/packages/frontend/src/pages/debug.vue
index 5cd68c2c3a..9c0761f0b1 100644
--- a/packages/frontend/src/pages/debug.vue
+++ b/packages/frontend/src/pages/debug.vue
@@ -11,11 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<MkResult v-if="resultType === 'notFound'" type="notFound"/>
 			<MkResult v-if="resultType === 'error'" type="error"/>
 			<MkSelect
-				v-model="resultType" :items="[
-					{ label: 'empty', value: 'empty' },
-					{ label: 'notFound', value: 'notFound' },
-					{ label: 'error', value: 'error' },
-				]"
+				v-model="resultType" :items="resultTypeDef"
 			></MkSelect>
 
 			<MkSystemIcon v-if="iconType === 'info'" type="info" style="width: 150px;"/>
@@ -25,14 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<MkSystemIcon v-if="iconType === 'error'" type="error" style="width: 150px;"/>
 			<MkSystemIcon v-if="iconType === 'waiting'" type="waiting" style="width: 150px;"/>
 			<MkSelect
-				v-model="iconType" :items="[
-					{ label: 'info', value: 'info' },
-					{ label: 'question', value: 'question' },
-					{ label: 'success', value: 'success' },
-					{ label: 'warn', value: 'warn' },
-					{ label: 'error', value: 'error' },
-					{ label: 'waiting', value: 'waiting' },
-				]"
+				v-model="iconType" :items="iconTypeDef"
 			></MkSelect>
 
 			<div class="_buttons">
@@ -56,10 +45,34 @@ import MkKeyValue from '@/components/MkKeyValue.vue';
 import MkLink from '@/components/MkLink.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkButton from '@/components/MkButton.vue';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import * as os from '@/os.js';
 
-const resultType = ref('empty');
-const iconType = ref('info');
+const {
+	model: resultType,
+	def: resultTypeDef,
+} = useMkSelect({
+	items: [
+		{ label: 'empty', value: 'empty' },
+		{ label: 'notFound', value: 'notFound' },
+		{ label: 'error', value: 'error' },
+	],
+	initialValue: 'empty',
+});
+const {
+	model: iconType,
+	def: iconTypeDef,
+} = useMkSelect({
+	items: [
+		{ label: 'info', value: 'info' },
+		{ label: 'question', value: 'question' },
+		{ label: 'success', value: 'success' },
+		{ label: 'warn', value: 'warn' },
+		{ label: 'error', value: 'error' },
+		{ label: 'waiting', value: 'waiting' },
+	],
+	initialValue: 'info',
+});
 
 definePage(() => ({
 	title: 'DEBUG ROOM',
diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue
index c1a8b992b7..0a69dbdd70 100644
--- a/packages/frontend/src/pages/drop-and-fusion.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.vue
@@ -23,12 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<div class="_woodenFrame" style="text-align: center;">
 					<div class="_woodenFrameInner">
 						<div class="_gaps" style="padding: 16px;">
-							<MkSelect v-model="gameMode">
-								<option value="normal">NORMAL</option>
-								<option value="square">SQUARE</option>
-								<option value="yen">YEN</option>
-								<option value="sweets">SWEETS</option>
-								<!--<option value="space">SPACE</option>-->
+							<MkSelect v-model="gameMode" :items="gameModeDef">
 							</MkSelect>
 							<MkButton primary gradate large rounded inline @click="start">{{ i18n.ts.start }}</MkButton>
 						</div>
@@ -92,11 +87,24 @@ import XGame from './drop-and-fusion.game.vue';
 import { definePage } from '@/page.js';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import MkSelect from '@/components/MkSelect.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import { misskeyApiGet } from '@/utility/misskey-api.js';
 
-const gameMode = ref<'normal' | 'square' | 'yen' | 'sweets' | 'space'>('normal');
+const {
+	model: gameMode,
+	def: gameModeDef,
+} = useMkSelect({
+	items: [
+		{ label: 'NORMAL', value: 'normal' },
+		{ label: 'SQUARE', value: 'square' },
+		{ label: 'YEN', value: 'yen' },
+		{ label: 'SWEETS', value: 'sweets' },
+		//{ label: 'SPACE', value: 'space' },
+	],
+	initialValue: 'normal',
+});
 const gameStarted = ref(false);
 const mute = ref(false);
 const ranking = ref<Misskey.entities.BubbleGameRankingResponse | null>(null);
diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue
index 033e3376a5..ea4863950d 100644
--- a/packages/frontend/src/pages/emoji-edit-dialog.vue
+++ b/packages/frontend/src/pages/emoji-edit-dialog.vue
@@ -135,12 +135,12 @@ async function addRole() {
 	const roles = await misskeyApi('admin/roles/list');
 	const currentRoleIds = rolesThatCanBeUsedThisEmojiAsReaction.value.map(x => x.id);
 
-	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 })),
+	const { canceled, result: roleId } = await os.select({
+		items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ label: r.name, value: r.id })),
 	});
-	if (canceled || role == null) return;
+	if (canceled || roleId == null) return;
 
-	rolesThatCanBeUsedThisEmojiAsReaction.value.push(role);
+	rolesThatCanBeUsedThisEmojiAsReaction.value.push(roles.find(r => r.id === roleId)!);
 }
 
 async function removeRole(role: Misskey.entities.RoleLite, ev: Event) {
diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue
index 81b9d1cead..b3e8e88c23 100644
--- a/packages/frontend/src/pages/flash/flash-edit.vue
+++ b/packages/frontend/src/pages/flash/flash-edit.vue
@@ -10,11 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<MkInput v-model="title">
 				<template #label>{{ i18n.ts._play.title }}</template>
 			</MkInput>
-			<MkSelect v-model="visibility">
+			<MkSelect v-model="visibility" :items="visibilityDef">
 				<template #label>{{ i18n.ts.visibility }}</template>
 				<template #caption>{{ i18n.ts._play.visibilityDescription }}</template>
-				<option :key="'public'" :value="'public'">{{ i18n.ts.public }}</option>
-				<option :key="'private'" :value="'private'">{{ i18n.ts.private }}</option>
 			</MkSelect>
 			<MkTextarea v-model="summary" :mfmAutocomplete="true" :mfmPreview="true">
 				<template #label>{{ i18n.ts._play.summary }}</template>
@@ -52,6 +50,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
 import MkCodeEditor from '@/components/MkCodeEditor.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkSelect from '@/components/MkSelect.vue';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import { useRouter } from '@/router.js';
 
 const PRESET_DEFAULT = `/// @ ${AISCRIPT_VERSION}
@@ -384,7 +383,16 @@ if (props.id) {
 const title = ref(flash.value?.title ?? 'New Play');
 const summary = ref(flash.value?.summary ?? '');
 const permissions = ref([]); // not implemented yet
-const visibility = ref<'private' | 'public'>(flash.value?.visibility ?? 'public');
+const {
+	model: visibility,
+	def: visibilityDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.public, value: 'public' },
+		{ label: i18n.ts.private, value: 'private' },
+	],
+	initialValue: flash.value?.visibility ?? 'public',
+});
 const script = ref(flash.value?.script ?? PRESET_DEFAULT);
 
 function selectPreset(ev: MouseEvent) {
diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue
index 473207fe6e..61a40202c0 100644
--- a/packages/frontend/src/pages/instance-info.vue
+++ b/packages/frontend/src/pages/instance-info.vue
@@ -92,18 +92,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<div v-else-if="tab === 'chart'" class="_gaps_m">
 			<div>
 				<div :class="$style.selects">
-					<MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;">
-						<option value="instance-requests">{{ i18n.ts._instanceCharts.requests }}</option>
-						<option value="instance-users">{{ i18n.ts._instanceCharts.users }}</option>
-						<option value="instance-users-total">{{ i18n.ts._instanceCharts.usersTotal }}</option>
-						<option value="instance-notes">{{ i18n.ts._instanceCharts.notes }}</option>
-						<option value="instance-notes-total">{{ i18n.ts._instanceCharts.notesTotal }}</option>
-						<option value="instance-ff">{{ i18n.ts._instanceCharts.ff }}</option>
-						<option value="instance-ff-total">{{ i18n.ts._instanceCharts.ffTotal }}</option>
-						<option value="instance-drive-usage">{{ i18n.ts._instanceCharts.cacheSize }}</option>
-						<option value="instance-drive-usage-total">{{ i18n.ts._instanceCharts.cacheSizeTotal }}</option>
-						<option value="instance-drive-files">{{ i18n.ts._instanceCharts.files }}</option>
-						<option value="instance-drive-files-total">{{ i18n.ts._instanceCharts.filesTotal }}</option>
+					<MkSelect v-model="chartSrc" :items="chartSrcDef" style="margin: 0 10px 0 0; flex: 1;">
 					</MkSelect>
 				</div>
 				<div>
@@ -154,6 +143,7 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
 import { dateString } from '@/filters/date.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import MkTextarea from '@/components/MkTextarea.vue';
 import { Paginator } from '@/utility/paginator.js';
 
@@ -163,7 +153,25 @@ const props = defineProps<{
 
 const tab = ref('overview');
 
-const chartSrc = ref<ChartSrc>('instance-requests');
+const {
+	model: chartSrc,
+	def: chartSrcDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts._instanceCharts.requests, value: 'instance-requests' },
+		{ label: i18n.ts._instanceCharts.users, value: 'instance-users' },
+		{ label: i18n.ts._instanceCharts.usersTotal, value: 'instance-users-total' },
+		{ label: i18n.ts._instanceCharts.notes, value: 'instance-notes' },
+		{ label: i18n.ts._instanceCharts.notesTotal, value: 'instance-notes-total' },
+		{ label: i18n.ts._instanceCharts.ff, value: 'instance-ff' },
+		{ label: i18n.ts._instanceCharts.ffTotal, value: 'instance-ff-total' },
+		{ label: i18n.ts._instanceCharts.cacheSize, value: 'instance-drive-usage' },
+		{ label: i18n.ts._instanceCharts.cacheSizeTotal, value: 'instance-drive-usage-total' },
+		{ label: i18n.ts._instanceCharts.files, value: 'instance-drive-files' },
+		{ label: i18n.ts._instanceCharts.filesTotal, value: 'instance-drive-files-total' },
+	],
+	initialValue: 'instance-requests',
+});
 const meta = ref<Misskey.entities.AdminMetaResponse | null>(null);
 const instance = ref<Misskey.entities.FederationInstance | null>(null);
 const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding' | 'softwareSuspended'>('none');
diff --git a/packages/frontend/src/pages/page-editor/common.ts b/packages/frontend/src/pages/page-editor/common.ts
index 420c8fc967..64cd9cde7a 100644
--- a/packages/frontend/src/pages/page-editor/common.ts
+++ b/packages/frontend/src/pages/page-editor/common.ts
@@ -4,12 +4,13 @@
  */
 
 import { i18n } from '@/i18n.js';
+import type { MkSelectItem } from '@/components/MkSelect.vue';
 
 export function getPageBlockList() {
 	return [
-		{ value: 'section', text: i18n.ts._pages.blocks.section },
-		{ value: 'text', text: i18n.ts._pages.blocks.text },
-		{ value: 'image', text: i18n.ts._pages.blocks.image },
-		{ value: 'note', text: i18n.ts._pages.blocks.note },
-	];
+		{ value: 'section', label: i18n.ts._pages.blocks.section },
+		{ value: 'text', label: i18n.ts._pages.blocks.text },
+		{ value: 'image', label: i18n.ts._pages.blocks.image },
+		{ value: 'note', label: i18n.ts._pages.blocks.note },
+	] as const satisfies MkSelectItem[];
 }
diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue
index 3c51f25676..3dd83b25c5 100644
--- a/packages/frontend/src/pages/page-editor/page-editor.vue
+++ b/packages/frontend/src/pages/page-editor/page-editor.vue
@@ -30,10 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 				<MkSwitch v-model="alignCenter">{{ i18n.ts._pages.alignCenter }}</MkSwitch>
 
-				<MkSelect v-model="font">
+				<MkSelect v-model="font" :items="fontDef">
 					<template #label>{{ i18n.ts._pages.font }}</template>
-					<option value="serif">{{ i18n.ts._pages.fontSerif }}</option>
-					<option value="sans-serif">{{ i18n.ts._pages.fontSansSerif }}</option>
 				</MkSelect>
 
 				<MkSwitch v-model="hideTitleWhenPinned">{{ i18n.ts._pages.hideTitleWhenPinned }}</MkSwitch>
@@ -76,6 +74,7 @@ import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { $i } from '@/i.js';
 import { mainRouter } from '@/router.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import { getPageBlockList } from '@/pages/page-editor/common.js';
 
 const props = defineProps<{
@@ -95,7 +94,16 @@ const summary = ref<string | null>(null);
 const name = ref(Date.now().toString());
 const eyeCatchingImage = ref<Misskey.entities.DriveFile | null>(null);
 const eyeCatchingImageId = ref<string | null>(null);
-const font = ref<'sans-serif' | 'serif'>('sans-serif');
+const {
+	model: font,
+	def: fontDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts._pages.fontSansSerif, value: 'sans-serif' },
+		{ label: i18n.ts._pages.fontSerif, value: 'serif' },
+	],
+	initialValue: 'sans-serif',
+});
 const content = ref<Misskey.entities.Page['content']>([]);
 const alignCenter = ref(false);
 const hideTitleWhenPinned = ref(false);
diff --git a/packages/frontend/src/pages/settings/drive-cleaner.vue b/packages/frontend/src/pages/settings/drive-cleaner.vue
index 63b3c95233..57192c0fb7 100644
--- a/packages/frontend/src/pages/settings/drive-cleaner.vue
+++ b/packages/frontend/src/pages/settings/drive-cleaner.vue
@@ -5,9 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div class="_gaps">
-	<MkSelect v-model="sortModeSelect">
+	<MkSelect v-model="sortModeSelect" :items="sortModeSelectDef">
 		<template #label>{{ i18n.ts.sort }}</template>
-		<option v-for="x in sortOptions" :key="x.value" :value="x.value">{{ x.displayName }}</option>
 	</MkSelect>
 	<div v-if="!fetching">
 		<MkPagination v-slot="{items}" :paginator="paginator">
@@ -60,6 +59,7 @@ import { i18n } from '@/i18n.js';
 import bytes from '@/filters/bytes.js';
 import { definePage } from '@/page.js';
 import MkSelect from '@/components/MkSelect.vue';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js';
 import { Paginator } from '@/utility/paginator.js';
 
@@ -69,15 +69,19 @@ const paginator = markRaw(new Paginator('drive/files', {
 	computedParams: computed(() => ({ sort: sortMode.value })),
 }));
 
-const sortOptions = [
-	{ value: 'sizeDesc', displayName: i18n.ts._drivecleaner.orderBySizeDesc },
-	{ value: 'createdAtAsc', displayName: i18n.ts._drivecleaner.orderByCreatedAtAsc },
-];
-
 const capacity = ref<number>(0);
 const usage = ref<number>(0);
 const fetching = ref(true);
-const sortModeSelect = ref('sizeDesc');
+const {
+	model: sortModeSelect,
+	def: sortModeSelectDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts._drivecleaner.orderBySizeDesc, value: 'sizeDesc' },
+		{ label: i18n.ts._drivecleaner.orderByCreatedAtAsc, value: 'createdAtAsc' },
+	],
+	initialValue: 'sizeDesc',
+});
 
 fetchDriveInfo();
 
diff --git a/packages/frontend/src/pages/settings/emoji-palette.vue b/packages/frontend/src/pages/settings/emoji-palette.vue
index 5ff5f45a2f..34bf1c14af 100644
--- a/packages/frontend/src/pages/settings/emoji-palette.vue
+++ b/packages/frontend/src/pages/settings/emoji-palette.vue
@@ -36,20 +36,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<div class="_gaps_m">
 				<SearchMarker :keywords="['main', 'palette']">
 					<MkPreferenceContainer k="emojiPaletteForMain">
-						<MkSelect v-model="emojiPaletteForMain">
+						<MkSelect v-model="emojiPaletteForMain" :items="emojiPaletteForMainDef">
 							<template #label><SearchLabel>{{ i18n.ts._emojiPalette.paletteForMain }}</SearchLabel></template>
-							<option key="-" :value="null">({{ i18n.ts.auto }})</option>
-							<option v-for="palette in prefer.r.emojiPalettes.value" :key="palette.id" :value="palette.id">{{ palette.name === '' ? '(' + i18n.ts.noName + ')' : palette.name }}</option>
 						</MkSelect>
 					</MkPreferenceContainer>
 				</SearchMarker>
 
 				<SearchMarker :keywords="['reaction', 'palette']">
 					<MkPreferenceContainer k="emojiPaletteForReaction">
-						<MkSelect v-model="emojiPaletteForReaction">
+						<MkSelect v-model="emojiPaletteForReaction" :items="emojiPaletteForReactionDef">
 							<template #label><SearchLabel>{{ i18n.ts._emojiPalette.paletteForReaction }}</SearchLabel></template>
-							<option key="-" :value="null">({{ i18n.ts.auto }})</option>
-							<option v-for="palette in prefer.r.emojiPalettes.value" :key="palette.id" :value="palette.id">{{ palette.name === '' ? '(' + i18n.ts.noName + ')' : palette.name }}</option>
 						</MkSelect>
 					</MkPreferenceContainer>
 				</SearchMarker>
@@ -99,12 +95,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 					<SearchMarker :keywords="['emoji', 'picker', 'style']">
 						<MkPreferenceContainer k="emojiPickerStyle">
-							<MkSelect v-model="emojiPickerStyle">
+							<MkSelect v-model="emojiPickerStyle" :items="[
+								{ label: i18n.ts.auto, value: 'auto' },
+								{ label: i18n.ts.popup, value: 'popup' },
+								{ label: i18n.ts.drawer, value: 'drawer' },
+							]">
 								<template #label><SearchLabel>{{ i18n.ts.style }}</SearchLabel></template>
 								<template #caption>{{ i18n.ts.needReloadToApply }}</template>
-								<option value="auto">{{ i18n.ts.auto }}</option>
-								<option value="popup">{{ i18n.ts.popup }}</option>
-								<option value="drawer">{{ i18n.ts.drawer }}</option>
 							</MkSelect>
 						</MkPreferenceContainer>
 					</SearchMarker>
@@ -125,6 +122,7 @@ import MkRadios from '@/components/MkRadios.vue';
 import MkButton from '@/components/MkButton.vue';
 import FormSection from '@/components/form/section.vue';
 import MkSelect from '@/components/MkSelect.vue';
+import type { MkSelectItem } from '@/components/MkSelect.vue';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
@@ -135,7 +133,21 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import { emojiPicker } from '@/utility/emoji-picker.js';
 
 const emojiPaletteForReaction = prefer.model('emojiPaletteForReaction');
+const emojiPaletteForReactionDef = computed<MkSelectItem[]>(() => [
+	{ label: `(${i18n.ts.auto})`, value: null },
+	...prefer.s.emojiPalettes.map(palette => ({
+		label: palette.name === '' ? `(${i18n.ts.noName})` : palette.name,
+		value: palette.id,
+	})),
+]);
 const emojiPaletteForMain = prefer.model('emojiPaletteForMain');
+const emojiPaletteForMainDef = computed<MkSelectItem[]>(() => [
+	{ label: `(${i18n.ts.auto})`, value: null },
+	...prefer.s.emojiPalettes.map(palette => ({
+		label: palette.name === '' ? `(${i18n.ts.noName})` : palette.name,
+		value: palette.id,
+	})),
+]);
 const emojiPickerScale = prefer.model('emojiPickerScale');
 const emojiPickerWidth = prefer.model('emojiPickerWidth');
 const emojiPickerHeight = prefer.model('emojiPickerHeight');
diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue
index f7c634b42e..c8cbc0977f 100644
--- a/packages/frontend/src/pages/settings/navbar.vue
+++ b/packages/frontend/src/pages/settings/navbar.vue
@@ -86,9 +86,9 @@ async function addItem() {
 	const { canceled, result: item } = await os.select({
 		title: i18n.ts.addItem,
 		items: [...menu.map(k => ({
-			value: k, text: navbarItemDef[k].title,
+			value: k, label: navbarItemDef[k].title,
 		})), {
-			value: '-', text: i18n.ts.divider,
+			value: '-', label: i18n.ts.divider,
 		}],
 	});
 	if (canceled || item == null) return;
diff --git a/packages/frontend/src/pages/settings/notifications.notification-config.vue b/packages/frontend/src/pages/settings/notifications.notification-config.vue
index 0ea415f673..78c3312c27 100644
--- a/packages/frontend/src/pages/settings/notifications.notification-config.vue
+++ b/packages/frontend/src/pages/settings/notifications.notification-config.vue
@@ -5,13 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div class="_gaps_m">
-	<MkSelect v-model="type">
-		<option v-for="type in props.configurableTypes ?? notificationConfigTypes" :key="type" :value="type">{{ notificationConfigTypesI18nMap[type] }}</option>
+	<MkSelect v-model="type" :items="typeDef">
 	</MkSelect>
 
-	<MkSelect v-if="type === 'list'" v-model="userListId">
+	<MkSelect v-if="type === 'list'" v-model="userListId" :items="userListIdDef">
 		<template #label>{{ i18n.ts.userList }}</template>
-		<option v-for="list in props.userLists" :key="list.id" :value="list.id">{{ list.name }}</option>
 	</MkSelect>
 
 	<div class="_buttons">
@@ -41,9 +39,10 @@ export type NotificationConfig = {
 
 <script lang="ts" setup>
 import * as Misskey from 'misskey-js';
-import { ref } from 'vue';
+import { ref, computed } from 'vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkButton from '@/components/MkButton.vue';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import { i18n } from '@/i18n.js';
 
 const props = defineProps<{
@@ -66,8 +65,26 @@ const notificationConfigTypesI18nMap: Record<typeof notificationConfigTypes[numb
 	never: i18n.ts.none,
 };
 
-const type = ref(props.value.type);
-const userListId = ref(props.value.type === 'list' ? props.value.userListId : null);
+const {
+	model: type,
+	def: typeDef,
+} = useMkSelect({
+	items: computed(() => (props.configurableTypes ?? notificationConfigTypes).map((t: NotificationConfig['type']) => ({
+		label: notificationConfigTypesI18nMap[t],
+		value: t,
+	}))),
+	initialValue: props.value.type,
+});
+const {
+	model: userListId,
+	def: userListIdDef,
+} = useMkSelect({
+	items: computed(() => props.userLists.map(list => ({
+		label: list.name,
+		value: list.id,
+	}))),
+	initialValue: props.value.type === 'list' ? props.value.userListId : null,
+});
 
 function save() {
 	emit('update', type.value === 'list' ? { type: type.value, userListId: userListId.value! } : { type: type.value });
diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue
index ba35dd7f43..78f0891a58 100644
--- a/packages/frontend/src/pages/settings/preferences.vue
+++ b/packages/frontend/src/pages/settings/preferences.vue
@@ -18,9 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 					<div class="_gaps_m">
 						<SearchMarker :keywords="['language']">
-							<MkSelect v-model="lang">
+							<MkSelect v-model="lang" :items="langs.map(x => ({ label: x[1], value: x[0] }))">
 								<template #label><SearchLabel>{{ i18n.ts.uiLanguage }}</SearchLabel></template>
-								<option v-for="x in langs" :key="x[0]" :value="x[0]">{{ x[1] }}</option>
 								<template #caption>
 									<I18n :src="i18n.ts.i18nInfo" tag="span">
 										<template #link>
@@ -272,22 +271,31 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 							<SearchMarker :keywords="['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation']">
 								<MkPreferenceContainer k="instanceTicker">
-									<MkSelect v-if="instance.federation !== 'none'" v-model="instanceTicker">
+									<MkSelect
+										v-if="instance.federation !== 'none'"
+										v-model="instanceTicker"
+										:items="[
+											{ label: i18n.ts._instanceTicker.none, value: 'none' },
+											{ label: i18n.ts._instanceTicker.remote, value: 'remote' },
+											{ label: i18n.ts._instanceTicker.always, value: 'always' },
+										]"
+									>
 										<template #label><SearchLabel>{{ i18n.ts.instanceTicker }}</SearchLabel></template>
-										<option value="none">{{ i18n.ts._instanceTicker.none }}</option>
-										<option value="remote">{{ i18n.ts._instanceTicker.remote }}</option>
-										<option value="always">{{ i18n.ts._instanceTicker.always }}</option>
 									</MkSelect>
 								</MkPreferenceContainer>
 							</SearchMarker>
 
 							<SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility']">
 								<MkPreferenceContainer k="nsfw">
-									<MkSelect v-model="nsfw">
+									<MkSelect
+										v-model="nsfw"
+										:items="[
+											{ label: i18n.ts._displayOfSensitiveMedia.respect, value: 'respect' },
+											{ label: i18n.ts._displayOfSensitiveMedia.ignore, value: 'ignore' },
+											{ label: i18n.ts._displayOfSensitiveMedia.force, value: 'force' },
+										]"
+									>
 										<template #label><SearchLabel>{{ i18n.ts.displayOfSensitiveMedia }}</SearchLabel></template>
-										<option value="respect">{{ i18n.ts._displayOfSensitiveMedia.respect }}</option>
-										<option value="ignore">{{ i18n.ts._displayOfSensitiveMedia.ignore }}</option>
-										<option value="force">{{ i18n.ts._displayOfSensitiveMedia.force }}</option>
 									</MkSelect>
 								</MkPreferenceContainer>
 							</SearchMarker>
@@ -339,11 +347,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 									<div class="_gaps_m">
 										<MkPreferenceContainer k="defaultNoteVisibility">
-											<MkSelect v-model="defaultNoteVisibility">
-												<option value="public">{{ i18n.ts._visibility.public }}</option>
-												<option value="home">{{ i18n.ts._visibility.home }}</option>
-												<option value="followers">{{ i18n.ts._visibility.followers }}</option>
-												<option value="specified">{{ i18n.ts._visibility.specified }}</option>
+											<MkSelect
+												v-model="defaultNoteVisibility"
+												:items="[
+													{ label: i18n.ts._visibility.public, value: 'public' },
+													{ label: i18n.ts._visibility.home, value: 'home' },
+													{ label: i18n.ts._visibility.followers, value: 'followers' },
+													{ label: i18n.ts._visibility.specified, value: 'specified' },
+												]"
+											>
 											</MkSelect>
 										</MkPreferenceContainer>
 
@@ -528,22 +540,30 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 						<SearchMarker :keywords="['menu', 'style', 'popup', 'drawer']">
 							<MkPreferenceContainer k="menuStyle">
-								<MkSelect v-model="menuStyle">
+								<MkSelect
+									v-model="menuStyle"
+									:items="[
+										{ label: i18n.ts.auto, value: 'auto' },
+										{ label: i18n.ts.popup, value: 'popup' },
+										{ label: i18n.ts.drawer, value: 'drawer' },
+									]"
+								>
 									<template #label><SearchLabel>{{ i18n.ts.menuStyle }}</SearchLabel></template>
-									<option value="auto">{{ i18n.ts.auto }}</option>
-									<option value="popup">{{ i18n.ts.popup }}</option>
-									<option value="drawer">{{ i18n.ts.drawer }}</option>
 								</MkSelect>
 							</MkPreferenceContainer>
 						</SearchMarker>
 
 						<SearchMarker :keywords="['contextmenu', 'system', 'native']">
 							<MkPreferenceContainer k="contextMenu">
-								<MkSelect v-model="contextMenu">
+								<MkSelect
+									v-model="contextMenu"
+									:items="[
+										{ label: i18n.ts._contextMenu.app, value: 'app' },
+										{ label: i18n.ts._contextMenu.appWithShift, value: 'appWithShift' },
+										{ label: i18n.ts._contextMenu.native, value: 'native' },
+									]"
+								>
 									<template #label><SearchLabel>{{ i18n.ts._contextMenu.title }}</SearchLabel></template>
-									<option value="app">{{ i18n.ts._contextMenu.app }}</option>
-									<option value="appWithShift">{{ i18n.ts._contextMenu.appWithShift }}</option>
-									<option value="native">{{ i18n.ts._contextMenu.native }}</option>
 								</MkSelect>
 							</MkPreferenceContainer>
 						</SearchMarker>
@@ -719,11 +739,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 						<SearchMarker :keywords="['server', 'disconnect', 'reconnect', 'reload', 'streaming']">
 							<MkPreferenceContainer k="serverDisconnectedBehavior">
-								<MkSelect v-model="serverDisconnectedBehavior">
+								<MkSelect
+									v-model="serverDisconnectedBehavior"
+									:items="[
+										{ label: i18n.ts._serverDisconnectedBehavior.reload, value: 'reload' },
+										{ label: i18n.ts._serverDisconnectedBehavior.dialog, value: 'dialog' },
+										{ label: i18n.ts._serverDisconnectedBehavior.quiet, value: 'quiet' },
+									]"
+								>
 									<template #label><SearchLabel>{{ i18n.ts.whenServerDisconnected }}</SearchLabel></template>
-									<option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option>
-									<option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option>
-									<option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option>
 								</MkSelect>
 							</MkPreferenceContainer>
 						</SearchMarker>
@@ -984,16 +1008,15 @@ function removeEmojiIndex(lang: string) {
 
 async function setPinnedList() {
 	const lists = await misskeyApi('users/lists/list');
-	const { canceled, result: list } = await os.select({
+	const { canceled, result: listId } = await os.select({
 		title: i18n.ts.selectList,
 		items: lists.map(x => ({
-			value: x, text: x.name,
+			value: x.id, label: x.name,
 		})),
 	});
-	if (canceled) return;
-	if (list == null) return;
+	if (canceled || listId == null) return;
 
-	prefer.commit('pinnedUserLists', [list]);
+	prefer.commit('pinnedUserLists', [lists.find((x) => x.id === listId)!]);
 }
 
 function removePinnedList() {
diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue
index 8ae8c79ebd..d3020f068b 100644
--- a/packages/frontend/src/pages/settings/privacy.vue
+++ b/packages/frontend/src/pages/settings/privacy.vue
@@ -33,20 +33,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</SearchMarker>
 
 		<SearchMarker :keywords="['following', 'visibility']">
-			<MkSelect v-model="followingVisibility" @update:modelValue="save()">
+			<MkSelect v-model="followingVisibility" :items="followingVisibilityDef" @update:modelValue="save()">
 				<template #label><SearchLabel>{{ i18n.ts.followingVisibility }}</SearchLabel></template>
-				<option value="public">{{ i18n.ts._ffVisibility.public }}</option>
-				<option value="followers">{{ i18n.ts._ffVisibility.followers }}</option>
-				<option value="private">{{ i18n.ts._ffVisibility.private }}</option>
 			</MkSelect>
 		</SearchMarker>
 
 		<SearchMarker :keywords="['follower', 'visibility']">
-			<MkSelect v-model="followersVisibility" @update:modelValue="save()">
+			<MkSelect v-model="followersVisibility" :items="followersVisibilityDef" @update:modelValue="save()">
 				<template #label><SearchLabel>{{ i18n.ts.followersVisibility }}</SearchLabel></template>
-				<option value="public">{{ i18n.ts._ffVisibility.public }}</option>
-				<option value="followers">{{ i18n.ts._ffVisibility.followers }}</option>
-				<option value="private">{{ i18n.ts._ffVisibility.private }}</option>
 			</MkSelect>
 		</SearchMarker>
 
@@ -85,13 +79,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<div class="_gaps_m">
 					<MkInfo v-if="$i.policies.chatAvailability === 'unavailable'">{{ i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo>
 					<SearchMarker :keywords="['chat']">
-						<MkSelect v-model="chatScope" @update:modelValue="save()">
+						<MkSelect v-model="chatScope" :items="chatScopeDef" @update:modelValue="save()">
 							<template #label><SearchLabel>{{ i18n.ts._chat.chatAllowedUsers }}</SearchLabel></template>
-							<option value="everyone">{{ i18n.ts._chat._chatAllowedUsers.everyone }}</option>
-							<option value="followers">{{ i18n.ts._chat._chatAllowedUsers.followers }}</option>
-							<option value="following">{{ i18n.ts._chat._chatAllowedUsers.following }}</option>
-							<option value="mutual">{{ i18n.ts._chat._chatAllowedUsers.mutual }}</option>
-							<option value="none">{{ i18n.ts._chat._chatAllowedUsers.none }}</option>
 							<template #caption>{{ i18n.ts._chat.chatAllowedUsers_note }}</template>
 						</MkSelect>
 					</SearchMarker>
@@ -119,15 +108,24 @@ SPDX-License-Identifier: AGPL-3.0-only
 							<template #label><SearchLabel>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBefore }}</SearchLabel></template>
 
 							<div class="_gaps_s">
-								<MkSelect v-model="makeNotesFollowersOnlyBefore_type">
-									<option :value="null">{{ i18n.ts.none }}</option>
-									<option value="relative">{{ i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod }}</option>
-									<option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option>
+								<MkSelect
+									v-model="makeNotesFollowersOnlyBefore_type"
+									:items="[
+										{ label: i18n.ts.none, value: null },
+										{ label: i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod, value: 'relative' },
+										{ label: i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime, value: 'absolute' },
+									]"
+								>
 								</MkSelect>
 
-								<MkSelect v-if="makeNotesFollowersOnlyBefore_type === 'relative'" v-model="makeNotesFollowersOnlyBefore_selection">
-									<option v-for="preset in makeNotesFollowersOnlyBefore_presets" :value="preset.value">{{ preset.label }}</option>
-									<option value="custom">{{ i18n.ts.custom }}</option>
+								<MkSelect
+									v-if="makeNotesFollowersOnlyBefore_type === 'relative'"
+									v-model="makeNotesFollowersOnlyBefore_selection"
+									:items="[
+										...makeNotesFollowersOnlyBefore_presets,
+										{ label: i18n.ts.custom, value: 'custom' },
+									]"
+								>
 								</MkSelect>
 
 								<MkInput
@@ -162,22 +160,22 @@ SPDX-License-Identifier: AGPL-3.0-only
 							<div class="_gaps_s">
 								<MkSelect
 									v-model="makeNotesHiddenBefore_type"
-									:items="[{
-										value: null,
-										label: i18n.ts.none
-									}, {
-										value: 'relative',
-										label: i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod
-									}, {
-										value: 'absolute',
-										label: i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime
-									}]"
+									:items="[
+										{ label: i18n.ts.none, value: null },
+										{ label: i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod, value: 'relative' },
+										{ label: i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime, value: 'absolute' },
+									]"
 								>
 								</MkSelect>
 
-								<MkSelect v-if="makeNotesHiddenBefore_type === 'relative'" v-model="makeNotesHiddenBefore_selection">
-									<option v-for="preset in makeNotesHiddenBefore_presets" :value="preset.value">{{ preset.label }}</option>
-									<option value="custom">{{ i18n.ts.custom }}</option>
+								<MkSelect
+									v-if="makeNotesHiddenBefore_type === 'relative'"
+									v-model="makeNotesHiddenBefore_selection"
+									:items="[
+										...makeNotesHiddenBefore_presets,
+										{ label: i18n.ts.custom, value: 'custom' },
+									]"
+								>
 								</MkSelect>
 
 								<MkInput
@@ -217,6 +215,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref, computed, watch } from 'vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkSelect from '@/components/MkSelect.vue';
+import type { MkSelectItem } from '@/components/MkSelect.vue';
 import FormSection from '@/components/form/section.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { i18n } from '@/i18n.js';
@@ -225,6 +224,7 @@ import { ensureSignin } from '@/i.js';
 import { definePage } from '@/page.js';
 import FormSlot from '@/components/form/slot.vue';
 import { formatDateTimeString } from '@/utility/format-time-string.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import MkInput from '@/components/MkInput.vue';
 import * as os from '@/os.js';
 import MkDisableSection from '@/components/MkDisableSection.vue';
@@ -243,9 +243,41 @@ const makeNotesFollowersOnlyBefore = ref($i.makeNotesFollowersOnlyBefore ?? null
 const makeNotesHiddenBefore = ref($i.makeNotesHiddenBefore ?? null);
 const hideOnlineStatus = ref($i.hideOnlineStatus);
 const publicReactions = ref($i.publicReactions);
-const followingVisibility = ref($i.followingVisibility);
-const followersVisibility = ref($i.followersVisibility);
-const chatScope = ref($i.chatScope);
+const {
+	model: followingVisibility,
+	def: followingVisibilityDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.public, value: 'public' },
+		{ label: i18n.ts.followers, value: 'followers' },
+		{ label: i18n.ts.private, value: 'private' },
+	],
+	initialValue: $i.followingVisibility,
+});
+const {
+	model: followersVisibility,
+	def: followersVisibilityDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts.public, value: 'public' },
+		{ label: i18n.ts.followers, value: 'followers' },
+		{ label: i18n.ts.private, value: 'private' },
+	],
+	initialValue: $i.followersVisibility,
+});
+const {
+	model: chatScope,
+	def: chatScopeDef,
+} = useMkSelect({
+	items: [
+		{ label: i18n.ts._chat._chatAllowedUsers.everyone, value: 'everyone' },
+		{ label: i18n.ts._chat._chatAllowedUsers.followers, value: 'followers' },
+		{ label: i18n.ts._chat._chatAllowedUsers.following, value: 'following' },
+		{ label: i18n.ts._chat._chatAllowedUsers.mutual, value: 'mutual' },
+		{ label: i18n.ts._chat._chatAllowedUsers.none, value: 'none' },
+	],
+	initialValue: $i.chatScope,
+});
 
 const makeNotesFollowersOnlyBefore_type = computed({
 	get: () => {
@@ -276,7 +308,7 @@ const makeNotesFollowersOnlyBefore_presets = [
 	{ label: i18n.ts.oneMonth, value: -2592000 },
 	{ label: i18n.ts.threeMonths, value: -7776000 },
 	{ label: i18n.ts.oneYear, value: -31104000 },
-];
+] satisfies MkSelectItem[];
 
 const makeNotesFollowersOnlyBefore_isCustomMode = ref(
 	makeNotesFollowersOnlyBefore.value != null &&
@@ -328,7 +360,7 @@ const makeNotesHiddenBefore_presets = [
 	{ label: i18n.ts.oneMonth, value: -2592000 },
 	{ label: i18n.ts.threeMonths, value: -7776000 },
 	{ label: i18n.ts.oneYear, value: -31104000 },
-];
+] satisfies MkSelectItem[];
 
 const makeNotesHiddenBefore_isCustomMode = ref(
 	makeNotesHiddenBefore.value != null &&
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
index 4816a6e33b..17e8505474 100644
--- a/packages/frontend/src/pages/settings/profile.vue
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -53,9 +53,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</SearchMarker>
 
 		<SearchMarker :keywords="['language', 'locale']">
-			<MkSelect v-model="profile.lang">
+			<MkSelect v-model="profile.lang" :items="Object.entries(langmap).map(([code, def]) => ({ label: def.nativeName, value: code }))">
 				<template #label><SearchLabel>{{ i18n.ts.language }}</SearchLabel></template>
-				<option v-for="x in Object.keys(langmap)" :key="x" :value="x">{{ langmap[x].nativeName }}</option>
 			</MkSelect>
 		</SearchMarker>
 
@@ -117,13 +116,17 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</SearchMarker>
 
 		<SearchMarker :keywords="['reaction']">
-			<MkSelect v-model="reactionAcceptance">
+			<MkSelect
+				v-model="reactionAcceptance"
+				:items="[
+					{ label: i18n.ts.all, value: null },
+					{ label: i18n.ts.likeOnlyForRemote, value: 'likeOnlyForRemote' },
+					{ label: i18n.ts.nonSensitiveOnly, value: 'nonSensitiveOnly' },
+					{ label: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote, value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' },
+					{ label: i18n.ts.likeOnly, value: 'likeOnly' },
+				]"
+			>
 				<template #label><SearchLabel>{{ i18n.ts.reactionAcceptance }}</SearchLabel></template>
-				<option :value="null">{{ i18n.ts.all }}</option>
-				<option value="likeOnlyForRemote">{{ i18n.ts.likeOnlyForRemote }}</option>
-				<option value="nonSensitiveOnly">{{ i18n.ts.nonSensitiveOnly }}</option>
-				<option value="nonSensitiveOnlyForLocalLikeOnlyForRemote">{{ i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }}</option>
-				<option value="likeOnly">{{ i18n.ts.likeOnly }}</option>
 			</MkSelect>
 		</SearchMarker>
 
diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue
index 9e9671487e..31fe9a64db 100644
--- a/packages/frontend/src/pages/settings/sounds.sound.vue
+++ b/packages/frontend/src/pages/settings/sounds.sound.vue
@@ -5,9 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div class="_gaps_m">
-	<MkSelect v-model="type">
+	<MkSelect v-model="type" :items="typeDef">
 		<template #label>{{ i18n.ts.sound }}</template>
-		<option v-for="x in soundsTypes" :key="x ?? 'null'" :value="x">{{ getSoundTypeName(x) }}</option>
 	</MkSelect>
 	<div v-if="type === '_driveFile_' && driveFileError === true" :class="$style.fileSelectorRoot">
 		<MkButton :class="$style.fileSelectorButton" inline rounded primary @click="selectSound">{{ i18n.ts.selectFile }}</MkButton>
@@ -38,6 +37,7 @@ import MkButton from '@/components/MkButton.vue';
 import MkRange from '@/components/MkRange.vue';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/utility/sound.js';
 import { selectFile } from '@/utility/drive.js';
@@ -51,7 +51,16 @@ const emit = defineEmits<{
 	(ev: 'update', result: { type: SoundType; fileId?: string; fileUrl?: string; volume: number; }): void;
 }>();
 
-const type = ref<SoundType>(props.def.type);
+const {
+	model: type,
+	def: typeDef,
+} = useMkSelect({
+	items: soundsTypes.map((x) => ({
+		label: getSoundTypeName(x),
+		value: x,
+	})),
+	initialValue: props.def.type,
+});
 const fileId = ref('fileId' in props.def ? props.def.fileId : undefined);
 const fileUrl = ref('fileUrl' in props.def ? props.def.fileUrl : undefined);
 const fileName = ref<string>('');
diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue
index 561d31148f..b69fd2596d 100644
--- a/packages/frontend/src/pages/settings/statusbar.statusbar.vue
+++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue
@@ -5,11 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div class="_gaps_m">
-	<MkSelect v-model="statusbar.type" placeholder="Please select">
+	<MkSelect v-model="statusbar.type" :items="statusbarTypeDef">
 		<template #label>{{ i18n.ts.type }}</template>
-		<option value="rss">RSS</option>
-		<option v-if="instance.federation !== 'none'" value="federation">Federation</option>
-		<option value="userList">User list timeline</option>
 	</MkSelect>
 
 	<MkInput v-model="statusbar.name" manualSave>
@@ -63,9 +60,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</MkSwitch>
 	</template>
 	<template v-else-if="statusbar.type === 'userList' && userLists != null">
-		<MkSelect v-model="statusbar.props.userListId">
+		<MkSelect v-model="statusbar.props.userListId" :items="userListsDef">
 			<template #label>{{ i18n.ts.userList }}</template>
-			<option v-for="list in userLists" :value="list.id">{{ list.name }}</option>
 		</MkSelect>
 		<MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number">
 			<template #label>{{ i18n.ts.refreshInterval }}</template>
@@ -86,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { reactive, watch } from 'vue';
+import { reactive, computed, watch } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkSelect from '@/components/MkSelect.vue';
 import MkInput from '@/components/MkInput.vue';
@@ -98,13 +94,32 @@ import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import { deepClone } from '@/utility/clone.js';
 import { prefer } from '@/preferences.js';
+import type { MkSelectItem } from '@/components/MkSelect.vue';
+import type { StatusbarStore } from '@/preferences/def.js';
 
 const props = defineProps<{
 	_id: string;
 	userLists: Misskey.entities.UserList[] | null;
 }>();
 
-const statusbar = reactive(deepClone(prefer.s.statusbars.find(x => x.id === props._id))!);
+const statusbar = reactive<StatusbarStore>(deepClone(prefer.s.statusbars.find(x => x.id === props._id)!));
+
+const statusbarTypeDef = computed(() => {
+	const items = [
+		{ label: 'RSS', value: 'rss' },
+	] satisfies MkSelectItem[];
+	if (instance.federation !== 'none') {
+		items.push({ label: 'Federation', value: 'federation' });
+	}
+	if (props.userLists != null) {
+		items.push({ label: i18n.ts.userList, value: 'userList' });
+	}
+	return items;
+});
+
+const userListsDef = computed(() => {
+	return (props.userLists ?? []).map(x => ({ label: x.name, value: x.id })) satisfies MkSelectItem[];
+});
 
 watch(() => statusbar.type, () => {
 	if (statusbar.type === 'rss') {
diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue
index e972184278..7bb877ec39 100644
--- a/packages/frontend/src/pages/settings/theme.manage.vue
+++ b/packages/frontend/src/pages/settings/theme.manage.vue
@@ -5,16 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <div class="_gaps_m">
-	<MkSelect v-model="selectedThemeId">
+	<MkSelect v-model="selectedThemeId" :items="selectedThemeIdDef">
 		<template #label>{{ i18n.ts.theme }}</template>
-		<optgroup :label="i18n.ts._theme.installedThemes">
-			<option v-for="x in installedThemes" :key="x.id" :value="x.id">{{ x.name }}</option>
-		</optgroup>
-		<optgroup :label="i18n.ts._theme.builtinThemes">
-			<option v-for="x in builtinThemes" :key="x.id" :value="x.id">{{ x.name }}</option>
-		</optgroup>
 	</MkSelect>
-	<template v-if="selectedTheme">
+	<template v-if="selectedTheme != null">
 		<MkInput readonly :modelValue="selectedTheme.author">
 			<template #label>{{ i18n.ts.author }}</template>
 		</MkInput>
@@ -43,10 +37,26 @@ import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
+import { useMkSelect } from '@/composables/use-mkselect.js';
+import type { MkSelectItem } from '@/components/MkSelect.vue';
 
 const installedThemes = getThemesRef();
 const builtinThemes = getBuiltinThemesRef();
-const selectedThemeId = ref<string | null>(null);
+const {
+	model: selectedThemeId,
+	def: selectedThemeIdDef,
+} = useMkSelect({
+	items: computed<MkSelectItem<string | null>[]>(() => [{
+		type: 'group',
+		label: i18n.ts._theme.installedThemes,
+		items: installedThemes.value.map(x => ({ label: x.name, value: x.id })),
+	}, {
+		type: 'group',
+		label: i18n.ts._theme.builtinThemes,
+		items: builtinThemes.value.map(x => ({ label: x.name, value: x.id })),
+	}]),
+	initialValue: null,
+});
 
 const themes = computed(() => [...installedThemes.value, ...builtinThemes.value]);
 
diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts
index d26d590851..b6d3d55a5f 100644
--- a/packages/frontend/src/preferences/manager.ts
+++ b/packages/frontend/src/preferences/manager.ts
@@ -447,16 +447,16 @@ export class PreferencesManager {
 				title: i18n.ts.preferenceSyncConflictTitle,
 				text: i18n.ts.preferenceSyncConflictText,
 				items: [...(mergedValue !== undefined ? [{
-					text: i18n.ts.preferenceSyncConflictChoiceMerge,
-					value: 'merge',
+					label: i18n.ts.preferenceSyncConflictChoiceMerge,
+					value: 'merge' as const,
 				}] : []), {
-					text: i18n.ts.preferenceSyncConflictChoiceServer,
-					value: 'remote',
+					label: i18n.ts.preferenceSyncConflictChoiceServer,
+					value: 'remote' as const,
 				}, {
-					text: i18n.ts.preferenceSyncConflictChoiceDevice,
-					value: 'local',
+					label: i18n.ts.preferenceSyncConflictChoiceDevice,
+					value: 'local' as const,
 				}, {
-					text: i18n.ts.preferenceSyncConflictChoiceCancel,
+					label: i18n.ts.preferenceSyncConflictChoiceCancel,
 					value: null,
 				}],
 				default: mergedValue !== undefined ? 'merge' : 'remote',
diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts
index 80949f4971..33d379509a 100644
--- a/packages/frontend/src/preferences/utility.ts
+++ b/packages/frontend/src/preferences/utility.ts
@@ -187,7 +187,7 @@ export async function restoreFromCloudBackup() {
 	const select = await os.select({
 		title: i18n.ts._preferencesBackup.selectBackupToRestore,
 		items: backups.map(backup => ({
-			text: backup.name,
+			label: backup.name,
 			value: backup.name,
 		})),
 	});
diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
index 9f6d8267f7..e2ee4b658e 100644
--- a/packages/frontend/src/ui/deck.vue
+++ b/packages/frontend/src/ui/deck.vue
@@ -168,7 +168,7 @@ const addColumn = async (ev) => {
 	const { canceled, result: column } = await os.select({
 		title: i18n.ts._deck.addColumn,
 		items: columnTypes.map(column => ({
-			value: column, text: i18n.ts._deck._columns[column],
+			value: column, label: i18n.ts._deck._columns[column],
 		})),
 	});
 	if (canceled || column == null) return;
diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue
index 0042882728..0423a22ce1 100644
--- a/packages/frontend/src/ui/deck/antenna-column.vue
+++ b/packages/frontend/src/ui/deck/antenna-column.vue
@@ -51,22 +51,24 @@ watch(soundSetting, v => {
 
 async function setAntenna() {
 	const antennas = await misskeyApi('antennas/list');
-	const { canceled, result: antenna } = await os.select<MisskeyEntities.Antenna | '_CREATE_'>({
+	const { canceled, result: antennaIdOrOperation } = await os.select({
 		title: i18n.ts.selectAntenna,
 		items: [
-			{ value: '_CREATE_', text: i18n.ts.createNew },
+			{ value: '_CREATE_', label: i18n.ts.createNew },
 			(antennas.length > 0 ? {
-				sectionTitle: i18n.ts.createdAntennas,
+				type: 'group' as const,
+				label: i18n.ts.createdAntennas,
 				items: antennas.map(x => ({
-					value: x, text: x.name,
+					value: x.id, label: x.name,
 				})),
 			} : undefined),
 		],
 		default: props.column.antennaId,
 	});
-	if (canceled || antenna == null) return;
 
-	if (antenna === '_CREATE_') {
+	if (canceled || antennaIdOrOperation == null) return;
+
+	if (antennaIdOrOperation === '_CREATE_') {
 		const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAntennaEditorDialog.vue').then(x => x.default), {}, {
 			created: (newAntenna: MisskeyEntities.Antenna) => {
 				antennasCache.delete();
@@ -82,6 +84,8 @@ async function setAntenna() {
 		return;
 	}
 
+	const antenna = antennas.find(x => x.id === antennaIdOrOperation)!;
+
 	updateColumn(props.column.id, {
 		antennaId: antenna.id,
 		timelineNameCache: antenna.name,
diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue
index c02499e2d7..35ca9f5cc6 100644
--- a/packages/frontend/src/ui/deck/channel-column.vue
+++ b/packages/frontend/src/ui/deck/channel-column.vue
@@ -58,14 +58,15 @@ watch(soundSetting, v => {
 
 async function setChannel() {
 	const channels = await favoritedChannelsCache.fetch();
-	const { canceled, result: chosenChannel } = await os.select({
+	const { canceled, result: chosenChannelId } = await os.select({
 		title: i18n.ts.selectChannel,
 		items: channels.map(x => ({
-			value: x, text: x.name,
+			value: x.id, label: x.name,
 		})),
 		default: props.column.channelId,
 	});
-	if (canceled || chosenChannel == null) return;
+	if (canceled || chosenChannelId == null) return;
+	const chosenChannel = channels.find(x => x.id === chosenChannelId)!;
 	updateColumn(props.column.id, {
 		channelId: chosenChannel.id,
 		timelineNameCache: chosenChannel.name,
diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue
index 5c5891ece8..7fb0aba1e1 100644
--- a/packages/frontend/src/ui/deck/list-column.vue
+++ b/packages/frontend/src/ui/deck/list-column.vue
@@ -58,22 +58,23 @@ watch(soundSetting, v => {
 
 async function setList() {
 	const lists = await misskeyApi('users/lists/list');
-	const { canceled, result: list } = await os.select<MisskeyEntities.UserList | '_CREATE_'>({
+	const { canceled, result: listIdOrOperation } = await os.select({
 		title: i18n.ts.selectList,
 		items: [
-			{ value: '_CREATE_', text: i18n.ts.createNew },
+			{ value: '_CREATE_', label: i18n.ts.createNew },
 			(lists.length > 0 ? {
-				sectionTitle: i18n.ts.createdLists,
+				type: 'group' as const,
+				label: i18n.ts.createdLists,
 				items: lists.map(x => ({
-					value: x, text: x.name,
+					value: x.id, label: x.name,
 				})),
 			} : undefined),
 		],
 		default: props.column.listId,
 	});
-	if (canceled || list == null) return;
+	if (canceled || listIdOrOperation == null) return;
 
-	if (list === '_CREATE_') {
+	if (listIdOrOperation === '_CREATE_') {
 		const { canceled, result: name } = await os.inputText({
 			title: i18n.ts.enterListName,
 		});
@@ -87,6 +88,8 @@ async function setList() {
 			timelineNameCache: res.name,
 		});
 	} else {
+		const list = lists.find(x => x.id === listIdOrOperation)!;
+
 		updateColumn(props.column.id, {
 			listId: list.id,
 			timelineNameCache: list.name,
diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue
index 0aafeb56d7..beb679169c 100644
--- a/packages/frontend/src/ui/deck/role-timeline-column.vue
+++ b/packages/frontend/src/ui/deck/role-timeline-column.vue
@@ -49,14 +49,15 @@ watch(soundSetting, v => {
 
 async function setRole() {
 	const roles = (await misskeyApi('roles/list')).filter(x => x.isExplorable);
-	const { canceled, result: role } = await os.select({
+	const { canceled, result: roleId } = await os.select({
 		title: i18n.ts.role,
 		items: roles.map(x => ({
-			value: x, text: x.name,
+			value: x.id, label: x.name,
 		})),
 		default: props.column.roleId,
 	});
-	if (canceled || role == null) return;
+	if (canceled || roleId == null) return;
+	const role = roles.find(x => x.id === roleId)!;
 	updateColumn(props.column.id, {
 		roleId: role.id,
 		timelineNameCache: role.name,
diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue
index 37814f0914..afaa08e6d0 100644
--- a/packages/frontend/src/ui/deck/tl-column.vue
+++ b/packages/frontend/src/ui/deck/tl-column.vue
@@ -96,13 +96,13 @@ async function setType() {
 	const { canceled, result: src } = await os.select({
 		title: i18n.ts.timeline,
 		items: [{
-			value: 'home' as const, text: i18n.ts._timelines.home,
+			value: 'home', label: i18n.ts._timelines.home,
 		}, {
-			value: 'local' as const, text: i18n.ts._timelines.local,
+			value: 'local', label: i18n.ts._timelines.local,
 		}, {
-			value: 'social' as const, text: i18n.ts._timelines.social,
+			value: 'social', label: i18n.ts._timelines.social,
 		}, {
-			value: 'global' as const, text: i18n.ts._timelines.global,
+			value: 'global', label: i18n.ts._timelines.global,
 		}],
 	});
 	if (canceled) {
diff --git a/packages/frontend/src/utility/form.ts b/packages/frontend/src/utility/form.ts
index b0b09f0f6b..cb4a227f67 100644
--- a/packages/frontend/src/utility/form.ts
+++ b/packages/frontend/src/utility/form.ts
@@ -4,10 +4,11 @@
  */
 
 import * as Misskey from 'misskey-js';
+import type { OptionValue } from '@/components/MkSelect.vue';
 
 export type EnumItem = string | {
 	label: string;
-	value: unknown;
+	value: OptionValue;
 };
 
 type Hidden = boolean | ((v: any) => boolean);
diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts
index d6ddebc868..89dcae28e1 100644
--- a/packages/frontend/src/utility/get-user-menu.ts
+++ b/packages/frontend/src/utility/get-user-menu.ts
@@ -37,15 +37,15 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
 			const { canceled, result: period } = await os.select({
 				title: i18n.ts.mutePeriod,
 				items: [{
-					value: 'indefinitely', text: i18n.ts.indefinitely,
+					value: 'indefinitely', label: i18n.ts.indefinitely,
 				}, {
-					value: 'tenMinutes', text: i18n.ts.tenMinutes,
+					value: 'tenMinutes', label: i18n.ts.tenMinutes,
 				}, {
-					value: 'oneHour', text: i18n.ts.oneHour,
+					value: 'oneHour', label: i18n.ts.oneHour,
 				}, {
-					value: 'oneDay', text: i18n.ts.oneDay,
+					value: 'oneDay', label: i18n.ts.oneDay,
 				}, {
-					value: 'oneWeek', text: i18n.ts.oneWeek,
+					value: 'oneWeek', label: i18n.ts.oneWeek,
 				}],
 				default: 'indefinitely',
 			});
@@ -312,15 +312,15 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
 							const { canceled, result: period } = await os.select({
 								title: i18n.ts.period + ': ' + r.name,
 								items: [{
-									value: 'indefinitely', text: i18n.ts.indefinitely,
+									value: 'indefinitely', label: i18n.ts.indefinitely,
 								}, {
-									value: 'oneHour', text: i18n.ts.oneHour,
+									value: 'oneHour', label: i18n.ts.oneHour,
 								}, {
-									value: 'oneDay', text: i18n.ts.oneDay,
+									value: 'oneDay', label: i18n.ts.oneDay,
 								}, {
-									value: 'oneWeek', text: i18n.ts.oneWeek,
+									value: 'oneWeek', label: i18n.ts.oneWeek,
 								}, {
-									value: 'oneMonth', text: i18n.ts.oneMonth,
+									value: 'oneMonth', label: i18n.ts.oneMonth,
 								}],
 								default: 'indefinitely',
 							});
diff --git a/packages/frontend/src/widgets/WidgetUserList.vue b/packages/frontend/src/widgets/WidgetUserList.vue
index d87ea5ade2..9e914fa648 100644
--- a/packages/frontend/src/widgets/WidgetUserList.vue
+++ b/packages/frontend/src/widgets/WidgetUserList.vue
@@ -67,15 +67,15 @@ const fetching = ref(true);
 
 async function chooseList() {
 	const lists = await misskeyApi('users/lists/list');
-	const { canceled, result: list } = await os.select({
+	const { canceled, result: listId } = await os.select({
 		title: i18n.ts.selectList,
 		items: lists.map(x => ({
-			value: x, text: x.name,
+			value: x.id, label: x.name,
 		})),
 		default: widgetProps.listId,
 	});
-	if (canceled || list == null) return;
-
+	if (canceled || listId == null) return;
+	const list = lists.find(x => x.id === listId)!;
 	widgetProps.listId = list.id;
 	save();
 	fetch();
-- 
cgit v1.3.1-freya


From d1446d195abb52c560c7c97177d08103a175acf7 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Fri, 26 Sep 2025 15:29:52 +0900
Subject: feat: scheduled post (#16577)

* Update NoteDraft.ts

* Update NoteDraft.ts

* wip

* Update CHANGELOG.md

* wip

* Update PostScheduledNoteProcessorService.ts

* Update PostScheduledNoteProcessorService.ts

* Update Notification.ts

* wip

* Update NoteDraftService.ts

* Update NoteDraftService.ts

* Update NoteDraftService.ts

* wip

* Create 1758677617888-scheduled-post.js

* Update index.d.ts

* Update stats.ts

* wip

* wip

* wip

* wip

* wip

* Update MkNotification.vue

* wip

* wip

* wip

* Update NoteDraftService.ts

* Update NoteDraftService.ts

* wip

* wip

* Update NoteDraftEntityService.ts

* wip

* Update index.d.ts

* Update MkPostForm.vue

* wip

* wip

* wip

* Update NoteCreateService.ts

* wip

* wip

* wip

* Update NoteDraftEntityService.ts

* Update NoteCreateService.ts

* Update NoteDraftService.ts

* wip

* Update NoteDraftService.ts

* wip

* wip

* Update MkPostForm.vue

* wip

* Update MkPostForm.vue

* Update os.ts

* wip

* Update MkNoteDraftsDialog.vue
---
 CHANGELOG.md                                       |   2 +
 locales/index.d.ts                                 |  48 ++++
 locales/ja-JP.yml                                  |  12 +
 .../migration/1758677617888-scheduled-post.js      |  24 ++
 packages/backend/src/core/NoteCreateService.ts     | 170 ++++++++++++-
 packages/backend/src/core/NoteDraftService.ts      | 168 +++++++------
 packages/backend/src/core/QueueModule.ts           |  12 +
 packages/backend/src/core/QueueService.ts          |   4 +
 packages/backend/src/core/RoleService.ts           |   3 +
 .../src/core/entities/NoteDraftEntityService.ts    |  21 +-
 .../src/core/entities/NotificationEntityService.ts |  13 +-
 packages/backend/src/models/NoteDraft.ts           |  18 +-
 packages/backend/src/models/Notification.ts        |  11 +
 .../backend/src/models/json-schema/note-draft.ts   |  31 +--
 .../backend/src/models/json-schema/notification.ts |  30 +++
 packages/backend/src/models/json-schema/role.ts    |   4 +
 packages/backend/src/models/json-schema/user.ts    |   2 +
 packages/backend/src/queue/QueueProcessorModule.ts |   2 +
 .../backend/src/queue/QueueProcessorService.ts     |  20 ++
 packages/backend/src/queue/const.ts                |   1 +
 .../PostScheduledNoteProcessorService.ts           |  72 ++++++
 packages/backend/src/queue/types.ts                |   4 +
 .../src/server/api/endpoints/admin/queue/stats.ts  |   3 +-
 .../src/server/api/endpoints/admin/show-user.ts    |   2 +
 .../backend/src/server/api/endpoints/i/update.ts   |   2 +
 .../src/server/api/endpoints/notes/create.ts       | 207 ++++------------
 .../server/api/endpoints/notes/drafts/create.ts    |  41 ++--
 .../src/server/api/endpoints/notes/drafts/list.ts  |   7 +
 .../server/api/endpoints/notes/drafts/update.ts    |  44 ++--
 packages/backend/src/types.ts                      |   4 +
 .../frontend/src/components/MkNoteDraftsDialog.vue | 263 ++++++++++++++-------
 .../frontend/src/components/MkNotification.vue     |  23 +-
 packages/frontend/src/components/MkPostForm.vue    | 121 ++++++++--
 packages/frontend/src/os.ts                        |   8 +-
 packages/frontend/src/pages/admin/roles.editor.vue |  21 +-
 packages/frontend/src/pages/admin/roles.vue        |   7 +
 packages/misskey-js/etc/misskey-js.api.md          |   6 +-
 packages/misskey-js/src/autogen/types.ts           | 177 +++++++++-----
 packages/misskey-js/src/consts.ts                  |   4 +
 39 files changed, 1127 insertions(+), 485 deletions(-)
 create mode 100644 packages/backend/migration/1758677617888-scheduled-post.js
 create mode 100644 packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts

(limited to 'packages/frontend/src/pages/admin/roles.editor.vue')

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3672772665..66837034ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
 - pnpm 10.16.0 が必要です
 
 ### General
+- Feat: 予約投稿ができるようになりました
+	- デフォルトで作成可能数は1になっています。適宜ロールのポリシーで設定を行ってください。
 - Enhance: 広告ごとにセンシティブフラグを設定できるようになりました
 
 ### Client
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 4071d5c373..43e7d6e2a8 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -5286,6 +5286,10 @@ export interface Locale extends ILocale {
      * 下書き
      */
     "draft": string;
+    /**
+     * 下書きと予約投稿
+     */
+    "draftsAndScheduledNotes": string;
     /**
      * リアクションする際に確認する
      */
@@ -5553,6 +5557,26 @@ export interface Locale extends ILocale {
      * ユーザー指定ノートを作成
      */
     "createUserSpecifiedNote": string;
+    /**
+     * 投稿を予約
+     */
+    "schedulePost": string;
+    /**
+     * {x}に投稿を予約します
+     */
+    "scheduleToPostOnX": ParameterizedString<"x">;
+    /**
+     * {x}に投稿が予約されています
+     */
+    "scheduledToPostOnX": ParameterizedString<"x">;
+    /**
+     * 予約
+     */
+    "schedule": string;
+    /**
+     * 予約
+     */
+    "scheduled": string;
     "_compression": {
         "_quality": {
             /**
@@ -7933,6 +7957,10 @@ export interface Locale extends ILocale {
              * サーバーサイドのノートの下書きの作成可能数
              */
             "noteDraftLimit": string;
+            /**
+             * 予約投稿の同時作成可能数
+             */
+            "scheduledNoteLimit": string;
             /**
              * ウォーターマーク機能の使用可否
              */
@@ -10328,6 +10356,14 @@ export interface Locale extends ILocale {
          * アンケートの結果が出ました
          */
         "pollEnded": string;
+        /**
+         * 予約ノートが投稿されました
+         */
+        "scheduledNotePosted": string;
+        /**
+         * 予約ノートの投稿に失敗しました
+         */
+        "scheduledNotePostFailed": string;
         /**
          * 新しい投稿
          */
@@ -12641,6 +12677,18 @@ export interface Locale extends ILocale {
          * 下書き一覧
          */
         "listDrafts": string;
+        /**
+         * 投稿予約
+         */
+        "schedule": string;
+        /**
+         * 予約投稿一覧
+         */
+        "listScheduledNotes": string;
+        /**
+         * 予約解除
+         */
+        "cancelSchedule": string;
     };
     /**
      * 二次元コード
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index c0e598ef7b..e193abeb09 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1317,6 +1317,7 @@ acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします
 federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。"
 federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。"
 draft: "下書き"
+draftsAndScheduledNotes: "下書きと予約投稿"
 confirmOnReact: "リアクションする際に確認する"
 reactAreYouSure: "\" {emoji} \" をリアクションしますか？"
 markAsSensitiveConfirm: "このメディアをセンシティブとして設定しますか？"
@@ -1383,6 +1384,11 @@ customCssIsDisabledBecauseSafeMode: "セーフモードが有効なため、カ
 themeIsDefaultBecauseSafeMode: "セーフモードが有効な間はデフォルトのテーマが使用されます。セーフモードをオフにすると元に戻ります。"
 thankYouForTestingBeta: "ベータ版の検証にご協力いただきありがとうございます！"
 createUserSpecifiedNote: "ユーザー指定ノートを作成"
+schedulePost: "投稿を予約"
+scheduleToPostOnX: "{x}に投稿を予約します"
+scheduledToPostOnX: "{x}に投稿が予約されています"
+schedule: "予約"
+scheduled: "予約"
 
 _compression:
   _quality:
@@ -2056,6 +2062,7 @@ _role:
     uploadableFileTypes_caption: "MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)"
     uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。"
     noteDraftLimit: "サーバーサイドのノートの下書きの作成可能数"
+    scheduledNoteLimit: "予約投稿の同時作成可能数"
     watermarkAvailable: "ウォーターマーク機能の使用可否"
   _condition:
     roleAssignedTo: "マニュアルロールにアサイン済み"
@@ -2728,6 +2735,8 @@ _notification:
   youReceivedFollowRequest: "フォローリクエストが来ました"
   yourFollowRequestAccepted: "フォローリクエストが承認されました"
   pollEnded: "アンケートの結果が出ました"
+  scheduledNotePosted: "予約ノートが投稿されました"
+  scheduledNotePostFailed: "予約ノートの投稿に失敗しました"
   newNote: "新しい投稿"
   unreadAntennaNote: "アンテナ {name}"
   roleAssigned: "ロールが付与されました"
@@ -3385,6 +3394,9 @@ _drafts:
   restoreFromDraft: "下書きから復元"
   restore: "復元"
   listDrafts: "下書き一覧"
+  schedule: "投稿予約"
+  listScheduledNotes: "予約投稿一覧"
+  cancelSchedule: "予約解除"
 
 qr: "二次元コード"
 _qr:
diff --git a/packages/backend/migration/1758677617888-scheduled-post.js b/packages/backend/migration/1758677617888-scheduled-post.js
new file mode 100644
index 0000000000..b31313d9db
--- /dev/null
+++ b/packages/backend/migration/1758677617888-scheduled-post.js
@@ -0,0 +1,24 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class ScheduledPost1758677617888 {
+    name = 'ScheduledPost1758677617888'
+
+    /**
+     * @param {QueryRunner} queryRunner
+     */
+    async up(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "note_draft" ADD "scheduledAt" TIMESTAMP WITH TIME ZONE`);
+        await queryRunner.query(`ALTER TABLE "note_draft" ADD "isActuallyScheduled" boolean NOT NULL DEFAULT false`);
+    }
+
+    /**
+     * @param {QueryRunner} queryRunner
+     */
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "note_draft" DROP COLUMN "isActuallyScheduled"`);
+        await queryRunner.query(`ALTER TABLE "note_draft" DROP COLUMN "scheduledAt"`);
+    }
+}
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 1eefcfa054..b6acf4c5fb 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -13,7 +13,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
 import { extractHashtags } from '@/misc/extract-hashtags.js';
 import type { IMentionedRemoteUsers } from '@/models/Note.js';
 import { MiNote } from '@/models/Note.js';
-import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import type { BlockingsRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
 import type { MiDriveFile } from '@/models/DriveFile.js';
 import type { MiApp } from '@/models/App.js';
 import { concat } from '@/misc/prelude/array.js';
@@ -56,6 +56,7 @@ import { trackPromise } from '@/misc/promise-tracker.js';
 import { IdentifiableError } from '@/misc/identifiable-error.js';
 import { CollapsedQueue } from '@/misc/collapsed-queue.js';
 import { CacheService } from '@/core/CacheService.js';
+import { isQuote, isRenote } from '@/misc/is-renote.js';
 
 type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
 
@@ -192,6 +193,12 @@ export class NoteCreateService implements OnApplicationShutdown {
 		@Inject(DI.channelFollowingsRepository)
 		private channelFollowingsRepository: ChannelFollowingsRepository,
 
+		@Inject(DI.blockingsRepository)
+		private blockingsRepository: BlockingsRepository,
+
+		@Inject(DI.driveFilesRepository)
+		private driveFilesRepository: DriveFilesRepository,
+
 		private userEntityService: UserEntityService,
 		private noteEntityService: NoteEntityService,
 		private idService: IdService,
@@ -221,6 +228,167 @@ export class NoteCreateService implements OnApplicationShutdown {
 		this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
 	}
 
+	@bindThis
+	public async fetchAndCreate(user: {
+		id: MiUser['id'];
+		username: MiUser['username'];
+		host: MiUser['host'];
+		isBot: MiUser['isBot'];
+		isCat: MiUser['isCat'];
+	}, data: {
+		createdAt: Date;
+		replyId: MiNote['id'] | null;
+		renoteId: MiNote['id'] | null;
+		fileIds: MiDriveFile['id'][];
+		text: string | null;
+		cw: string | null;
+		visibility: string;
+		visibleUserIds: MiUser['id'][];
+		channelId: MiChannel['id'] | null;
+		localOnly: boolean;
+		reactionAcceptance: MiNote['reactionAcceptance'];
+		poll: IPoll | null;
+		apMentions?: MinimumUser[] | null;
+		apHashtags?: string[] | null;
+		apEmojis?: string[] | null;
+	}): Promise<MiNote> {
+		const visibleUsers = data.visibleUserIds.length > 0 ? await this.usersRepository.findBy({
+			id: In(data.visibleUserIds),
+		}) : [];
+
+		let files: MiDriveFile[] = [];
+		if (data.fileIds.length > 0) {
+			files = await this.driveFilesRepository.createQueryBuilder('file')
+				.where('file.userId = :userId AND file.id IN (:...fileIds)', {
+					userId: user.id,
+					fileIds: data.fileIds,
+				})
+				.orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
+				.setParameters({ fileIds: data.fileIds })
+				.getMany();
+
+			if (files.length !== data.fileIds.length) {
+				throw new IdentifiableError('801c046c-5bf5-4234-ad2b-e78fc20a2ac7', 'No such file');
+			}
+		}
+
+		let renote: MiNote | null = null;
+		if (data.renoteId != null) {
+			// Fetch renote to note
+			renote = await this.notesRepository.findOne({
+				where: { id: data.renoteId },
+				relations: ['user', 'renote', 'reply'],
+			});
+
+			if (renote == null) {
+				throw new IdentifiableError('53983c56-e163-45a6-942f-4ddc485d4290', 'No such renote target');
+			} else if (isRenote(renote) && !isQuote(renote)) {
+				throw new IdentifiableError('bde24c37-121f-4e7d-980d-cec52f599f02', 'Cannot renote pure renote');
+			}
+
+			// Check blocking
+			if (renote.userId !== user.id) {
+				const blockExist = await this.blockingsRepository.exists({
+					where: {
+						blockerId: renote.userId,
+						blockeeId: user.id,
+					},
+				});
+				if (blockExist) {
+					throw new IdentifiableError('2b4fe776-4414-4a2d-ae39-f3418b8fd4d3', 'You have been blocked by the user');
+				}
+			}
+
+			if (renote.visibility === 'followers' && renote.userId !== user.id) {
+				// 他人のfollowers noteはreject
+				throw new IdentifiableError('90b9d6f0-893a-4fef-b0f1-e9a33989f71a', 'Renote target visibility');
+			} else if (renote.visibility === 'specified') {
+				// specified / direct noteはreject
+				throw new IdentifiableError('48d7a997-da5c-4716-b3c3-92db3f37bf7d', 'Renote target visibility');
+			}
+
+			if (renote.channelId && renote.channelId !== data.channelId) {
+				// チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
+				// リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
+				const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId });
+				if (renoteChannel == null) {
+					// リノートしたいノートが書き込まれているチャンネルが無い
+					throw new IdentifiableError('b060f9a6-8909-4080-9e0b-94d9fa6f6a77', 'No such channel');
+				} else if (!renoteChannel.allowRenoteToExternal) {
+					// リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合
+					throw new IdentifiableError('7e435f4a-780d-4cfc-a15a-42519bd6fb67', 'Channel does not allow renote to external');
+				}
+			}
+		}
+
+		let reply: MiNote | null = null;
+		if (data.replyId != null) {
+			// Fetch reply
+			reply = await this.notesRepository.findOne({
+				where: { id: data.replyId },
+				relations: ['user'],
+			});
+
+			if (reply == null) {
+				throw new IdentifiableError('60142edb-1519-408e-926d-4f108d27bee0', 'No such reply target');
+			} else if (isRenote(reply) && !isQuote(reply)) {
+				throw new IdentifiableError('f089e4e2-c0e7-4f60-8a23-e5a6bf786b36', 'Cannot reply to pure renote');
+			} else if (!await this.noteEntityService.isVisibleForMe(reply, user.id)) {
+				throw new IdentifiableError('11cd37b3-a411-4f77-8633-c580ce6a8dce', 'No such reply target');
+			} else if (reply.visibility === 'specified' && data.visibility !== 'specified') {
+				throw new IdentifiableError('ced780a1-2012-4caf-bc7e-a95a291294cb', 'Cannot reply to specified note with different visibility');
+			}
+
+			// Check blocking
+			if (reply.userId !== user.id) {
+				const blockExist = await this.blockingsRepository.exists({
+					where: {
+						blockerId: reply.userId,
+						blockeeId: user.id,
+					},
+				});
+				if (blockExist) {
+					throw new IdentifiableError('b0df6025-f2e8-44b4-a26a-17ad99104612', 'You have been blocked by the user');
+				}
+			}
+		}
+
+		if (data.poll) {
+			if (data.poll.expiresAt != null) {
+				if (data.poll.expiresAt.getTime() < Date.now()) {
+					throw new IdentifiableError('0c11c11e-0c8d-48e7-822c-76ccef660068', 'Poll expiration must be future time');
+				}
+			}
+		}
+
+		let channel: MiChannel | null = null;
+		if (data.channelId != null) {
+			channel = await this.channelsRepository.findOneBy({ id: data.channelId, isArchived: false });
+
+			if (channel == null) {
+				throw new IdentifiableError('bfa3905b-25f5-4894-b430-da331a490e4b', 'No such channel');
+			}
+		}
+
+		return this.create(user, {
+			createdAt: data.createdAt,
+			files: files,
+			poll: data.poll,
+			text: data.text,
+			reply,
+			renote,
+			cw: data.cw,
+			localOnly: data.localOnly,
+			reactionAcceptance: data.reactionAcceptance,
+			visibility: data.visibility,
+			visibleUsers,
+			channel,
+			apMentions: data.apMentions,
+			apHashtags: data.apHashtags,
+			apEmojis: data.apEmojis,
+		});
+	}
+
 	@bindThis
 	public async create(user: {
 		id: MiUser['id'];
diff --git a/packages/backend/src/core/NoteDraftService.ts b/packages/backend/src/core/NoteDraftService.ts
index c43be96efa..7666407c1e 100644
--- a/packages/backend/src/core/NoteDraftService.ts
+++ b/packages/backend/src/core/NoteDraftService.ts
@@ -5,32 +5,18 @@
 
 import { Inject, Injectable } from '@nestjs/common';
 import { In } from 'typeorm';
-import type { noteVisibilities, noteReactionAcceptances } from '@/types.js';
 import { DI } from '@/di-symbols.js';
 import type { MiNoteDraft, NoteDraftsRepository, MiNote, MiDriveFile, MiChannel, UsersRepository, DriveFilesRepository, NotesRepository, BlockingsRepository, ChannelsRepository } from '@/models/_.js';
 import { bindThis } from '@/decorators.js';
 import { RoleService } from '@/core/RoleService.js';
 import { IdService } from '@/core/IdService.js';
 import type { MiLocalUser, MiUser } from '@/models/User.js';
-import { IPoll } from '@/models/Poll.js';
 import { IdentifiableError } from '@/misc/identifiable-error.js';
 import { isRenote, isQuote } from '@/misc/is-renote.js';
 import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
+import { QueueService } from '@/core/QueueService.js';
 
-export type NoteDraftOptions = {
-	replyId?: MiNote['id'] | null;
-	renoteId?: MiNote['id'] | null;
-	text?: string | null;
-	cw?: string | null;
-	localOnly?: boolean | null;
-	reactionAcceptance?: typeof noteReactionAcceptances[number];
-	visibility?: typeof noteVisibilities[number];
-	fileIds?: MiDriveFile['id'][];
-	visibleUserIds?: MiUser['id'][];
-	hashtag?: string;
-	channelId?: MiChannel['id'] | null;
-	poll?: (IPoll & { expiredAfter?: number | null }) | null;
-};
+export type NoteDraftOptions = Omit<MiNoteDraft, 'id' | 'userId' | 'user' | 'reply' | 'renote' | 'channel'>;
 
 @Injectable()
 export class NoteDraftService {
@@ -56,6 +42,7 @@ export class NoteDraftService {
 		private roleService: RoleService,
 		private idService: IdService,
 		private noteEntityService: NoteEntityService,
+		private queueService: QueueService,
 	) {
 	}
 
@@ -72,36 +59,43 @@ export class NoteDraftService {
 	@bindThis
 	public async create(me: MiLocalUser, data: NoteDraftOptions): Promise<MiNoteDraft> {
 		//#region check draft limit
+		const policies = await this.roleService.getUserPolicies(me.id);
 
 		const currentCount = await this.noteDraftsRepository.countBy({
 			userId: me.id,
 		});
-		if (currentCount >= (await this.roleService.getUserPolicies(me.id)).noteDraftLimit) {
+		if (currentCount >= policies.noteDraftLimit) {
 			throw new IdentifiableError('9ee33bbe-fde3-4c71-9b51-e50492c6b9c8', 'Too many drafts');
 		}
-		//#endregion
 
-		if (data.poll) {
-			if (typeof data.poll.expiresAt === 'number') {
-				if (data.poll.expiresAt < Date.now()) {
-					throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll');
-				}
-			} else if (typeof data.poll.expiredAfter === 'number') {
-				data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter);
+		if (data.isActuallyScheduled) {
+			const currentScheduledCount = await this.noteDraftsRepository.countBy({
+				userId: me.id,
+				isActuallyScheduled: true,
+			});
+			if (currentScheduledCount >= policies.scheduledNoteLimit) {
+				throw new IdentifiableError('c3275f19-4558-4c59-83e1-4f684b5fab66', 'Too many scheduled notes');
 			}
 		}
+		//#endregion
 
-		const appliedDraft = await this.checkAndSetDraftNoteOptions(me, this.noteDraftsRepository.create(), data);
+		await this.validate(me, data);
 
-		appliedDraft.id = this.idService.gen();
-		appliedDraft.userId = me.id;
-		const draft = this.noteDraftsRepository.save(appliedDraft);
+		const draft = await this.noteDraftsRepository.insertOne({
+			...data,
+			id: this.idService.gen(),
+			userId: me.id,
+		});
+
+		if (draft.scheduledAt && draft.isActuallyScheduled) {
+			this.schedule(draft);
+		}
 
 		return draft;
 	}
 
 	@bindThis
-	public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: NoteDraftOptions): Promise<MiNoteDraft> {
+	public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: Partial<NoteDraftOptions>): Promise<MiNoteDraft> {
 		const draft = await this.noteDraftsRepository.findOneBy({
 			id: draftId,
 			userId: me.id,
@@ -111,19 +105,36 @@ export class NoteDraftService {
 			throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft');
 		}
 
-		if (data.poll) {
-			if (typeof data.poll.expiresAt === 'number') {
-				if (data.poll.expiresAt < Date.now()) {
-					throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll');
-				}
-			} else if (typeof data.poll.expiredAfter === 'number') {
-				data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter);
+		//#region check draft limit
+		const policies = await this.roleService.getUserPolicies(me.id);
+
+		if (!draft.isActuallyScheduled && data.isActuallyScheduled) {
+			const currentScheduledCount = await this.noteDraftsRepository.countBy({
+				userId: me.id,
+				isActuallyScheduled: true,
+			});
+			if (currentScheduledCount >= policies.scheduledNoteLimit) {
+				throw new IdentifiableError('bacdf856-5c51-4159-b88a-804fa5103be5', 'Too many scheduled notes');
 			}
 		}
+		//#endregion
+
+		await this.validate(me, data);
 
-		const appliedDraft = await this.checkAndSetDraftNoteOptions(me, draft, data);
+		const updatedDraft = await this.noteDraftsRepository.createQueryBuilder().update()
+			.set(data)
+			.where('id = :id', { id: draftId })
+			.returning('*')
+			.execute()
+			.then((response) => response.raw[0]);
 
-		return await this.noteDraftsRepository.save(appliedDraft);
+		this.clearSchedule(draftId).then(() => {
+			if (updatedDraft.scheduledAt != null && updatedDraft.isActuallyScheduled) {
+				this.schedule(updatedDraft);
+			}
+		});
+
+		return updatedDraft;
 	}
 
 	@bindThis
@@ -138,6 +149,8 @@ export class NoteDraftService {
 		}
 
 		await this.noteDraftsRepository.delete(draft.id);
+
+		this.clearSchedule(draftId);
 	}
 
 	@bindThis
@@ -154,27 +167,20 @@ export class NoteDraftService {
 		return draft;
 	}
 
-	// 関連エンティティを取得し紐づける部分を共通化する
 	@bindThis
-	public async checkAndSetDraftNoteOptions(
+	public async validate(
 		me: MiLocalUser,
-		draft: MiNoteDraft,
-		data: NoteDraftOptions,
-	): Promise<MiNoteDraft> {
-		data.visibility ??= 'public';
-		data.localOnly ??= false;
-		if (data.reactionAcceptance === undefined) data.reactionAcceptance = null;
-		if (data.channelId != null) {
-			data.visibility = 'public';
-			data.visibleUserIds = [];
-			data.localOnly = true;
+		data: Partial<NoteDraftOptions>,
+	): Promise<void> {
+		if (data.pollExpiresAt != null) {
+			if (data.pollExpiresAt.getTime() < Date.now()) {
+				throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll');
+			}
 		}
 
-		let appliedDraft = draft;
-
 		//#region visibleUsers
 		let visibleUsers: MiUser[] = [];
-		if (data.visibleUserIds != null) {
+		if (data.visibleUserIds != null && data.visibleUserIds.length > 0) {
 			visibleUsers = await this.usersRepository.findBy({
 				id: In(data.visibleUserIds),
 			});
@@ -184,7 +190,7 @@ export class NoteDraftService {
 		//#region files
 		let files: MiDriveFile[] = [];
 		const fileIds = data.fileIds ?? null;
-		if (fileIds != null) {
+		if (fileIds != null && fileIds.length > 0) {
 			files = await this.driveFilesRepository.createQueryBuilder('file')
 				.where('file.userId = :userId AND file.id IN (:...fileIds)', {
 					userId: me.id,
@@ -288,27 +294,37 @@ export class NoteDraftService {
 			}
 		}
 		//#endregion
+	}
 
-		appliedDraft = {
-			...appliedDraft,
-			visibility: data.visibility,
-			cw: data.cw ?? null,
-			fileIds: fileIds ?? [],
-			replyId: data.replyId ?? null,
-			renoteId: data.renoteId ?? null,
-			channelId: data.channelId ?? null,
-			text: data.text ?? null,
-			hashtag: data.hashtag ?? null,
-			hasPoll: data.poll != null,
-			pollChoices: data.poll ? data.poll.choices : [],
-			pollMultiple: data.poll ? data.poll.multiple : false,
-			pollExpiresAt: data.poll ? data.poll.expiresAt : null,
-			pollExpiredAfter: data.poll ? data.poll.expiredAfter ?? null : null,
-			visibleUserIds: data.visibleUserIds ?? [],
-			localOnly: data.localOnly,
-			reactionAcceptance: data.reactionAcceptance,
-		} satisfies MiNoteDraft;
-
-		return appliedDraft;
+	@bindThis
+	public async schedule(draft: MiNoteDraft): Promise<void> {
+		if (!draft.isActuallyScheduled) return;
+		if (draft.scheduledAt == null) return;
+		if (draft.scheduledAt.getTime() <= Date.now()) return;
+
+		const delay = draft.scheduledAt.getTime() - Date.now();
+		this.queueService.postScheduledNoteQueue.add(draft.id, {
+			noteDraftId: draft.id,
+		}, {
+			delay,
+			removeOnComplete: {
+				age: 3600 * 24 * 7, // keep up to 7 days
+				count: 30,
+			},
+			removeOnFail: {
+				age: 3600 * 24 * 7, // keep up to 7 days
+				count: 100,
+			},
+		});
+	}
+
+	@bindThis
+	public async clearSchedule(draftId: MiNoteDraft['id']): Promise<void> {
+		const jobs = await this.queueService.postScheduledNoteQueue.getJobs(['delayed', 'waiting', 'active']);
+		for (const job of jobs) {
+			if (job.data.noteDraftId === draftId) {
+				await job.remove();
+			}
+		}
 	}
 }
diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts
index b10b8e5899..ecd96261e0 100644
--- a/packages/backend/src/core/QueueModule.ts
+++ b/packages/backend/src/core/QueueModule.ts
@@ -16,11 +16,13 @@ import {
 	RelationshipJobData,
 	UserWebhookDeliverJobData,
 	SystemWebhookDeliverJobData,
+	PostScheduledNoteJobData,
 } from '../queue/types.js';
 import type { Provider } from '@nestjs/common';
 
 export type SystemQueue = Bull.Queue<Record<string, unknown>>;
 export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
+export type PostScheduledNoteQueue = Bull.Queue<PostScheduledNoteJobData>;
 export type DeliverQueue = Bull.Queue<DeliverJobData>;
 export type InboxQueue = Bull.Queue<InboxJobData>;
 export type DbQueue = Bull.Queue;
@@ -41,6 +43,12 @@ const $endedPollNotification: Provider = {
 	inject: [DI.config],
 };
 
+const $postScheduledNote: Provider = {
+	provide: 'queue:postScheduledNote',
+	useFactory: (config: Config) => new Bull.Queue(QUEUE.POST_SCHEDULED_NOTE, baseQueueOptions(config, QUEUE.POST_SCHEDULED_NOTE)),
+	inject: [DI.config],
+};
+
 const $deliver: Provider = {
 	provide: 'queue:deliver',
 	useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)),
@@ -89,6 +97,7 @@ const $systemWebhookDeliver: Provider = {
 	providers: [
 		$system,
 		$endedPollNotification,
+		$postScheduledNote,
 		$deliver,
 		$inbox,
 		$db,
@@ -100,6 +109,7 @@ const $systemWebhookDeliver: Provider = {
 	exports: [
 		$system,
 		$endedPollNotification,
+		$postScheduledNote,
 		$deliver,
 		$inbox,
 		$db,
@@ -113,6 +123,7 @@ export class QueueModule implements OnApplicationShutdown {
 	constructor(
 		@Inject('queue:system') public systemQueue: SystemQueue,
 		@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
+		@Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
 		@Inject('queue:deliver') public deliverQueue: DeliverQueue,
 		@Inject('queue:inbox') public inboxQueue: InboxQueue,
 		@Inject('queue:db') public dbQueue: DbQueue,
@@ -129,6 +140,7 @@ export class QueueModule implements OnApplicationShutdown {
 		await Promise.all([
 			this.systemQueue.close(),
 			this.endedPollNotificationQueue.close(),
+			this.postScheduledNoteQueue.close(),
 			this.deliverQueue.close(),
 			this.inboxQueue.close(),
 			this.dbQueue.close(),
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index 2d0e7b5d83..42782167bb 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -31,6 +31,7 @@ import type {
 	DbQueue,
 	DeliverQueue,
 	EndedPollNotificationQueue,
+	PostScheduledNoteQueue,
 	InboxQueue,
 	ObjectStorageQueue,
 	RelationshipQueue,
@@ -44,6 +45,7 @@ import type * as Bull from 'bullmq';
 export const QUEUE_TYPES = [
 	'system',
 	'endedPollNotification',
+	'postScheduledNote',
 	'deliver',
 	'inbox',
 	'db',
@@ -92,6 +94,7 @@ export class QueueService {
 
 		@Inject('queue:system') public systemQueue: SystemQueue,
 		@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
+		@Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
 		@Inject('queue:deliver') public deliverQueue: DeliverQueue,
 		@Inject('queue:inbox') public inboxQueue: InboxQueue,
 		@Inject('queue:db') public dbQueue: DbQueue,
@@ -717,6 +720,7 @@ export class QueueService {
 		switch (type) {
 			case 'system': return this.systemQueue;
 			case 'endedPollNotification': return this.endedPollNotificationQueue;
+			case 'postScheduledNote': return this.postScheduledNoteQueue;
 			case 'deliver': return this.deliverQueue;
 			case 'inbox': return this.inboxQueue;
 			case 'db': return this.dbQueue;
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 9e1d9cc370..6e4ac66e81 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -69,6 +69,7 @@ export type RolePolicies = {
 	chatAvailability: 'available' | 'readonly' | 'unavailable';
 	uploadableFileTypes: string[];
 	noteDraftLimit: number;
+	scheduledNoteLimit: number;
 	watermarkAvailable: boolean;
 };
 
@@ -116,6 +117,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
 		'audio/*',
 	],
 	noteDraftLimit: 10,
+	scheduledNoteLimit: 1,
 	watermarkAvailable: true,
 };
 
@@ -440,6 +442,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
 				return [...set];
 			}),
 			noteDraftLimit: calc('noteDraftLimit', vs => Math.max(...vs)),
+			scheduledNoteLimit: calc('scheduledNoteLimit', vs => Math.max(...vs)),
 			watermarkAvailable: calc('watermarkAvailable', vs => vs.some(v => v === true)),
 		};
 	}
diff --git a/packages/backend/src/core/entities/NoteDraftEntityService.ts b/packages/backend/src/core/entities/NoteDraftEntityService.ts
index 3ef8cdaa12..71e41a588d 100644
--- a/packages/backend/src/core/entities/NoteDraftEntityService.ts
+++ b/packages/backend/src/core/entities/NoteDraftEntityService.ts
@@ -105,6 +105,8 @@ export class NoteDraftEntityService implements OnModuleInit {
 		const packed: Packed<'NoteDraft'> = await awaitAll({
 			id: noteDraft.id,
 			createdAt: this.idService.parse(noteDraft.id).date.toISOString(),
+			scheduledAt: noteDraft.scheduledAt?.getTime() ?? null,
+			isActuallyScheduled: noteDraft.isActuallyScheduled,
 			userId: noteDraft.userId,
 			user: packedUsers?.get(noteDraft.userId) ?? this.userEntityService.pack(noteDraft.user ?? noteDraft.userId, me),
 			text: text,
@@ -112,13 +114,13 @@ export class NoteDraftEntityService implements OnModuleInit {
 			visibility: noteDraft.visibility,
 			localOnly: noteDraft.localOnly,
 			reactionAcceptance: noteDraft.reactionAcceptance,
-			visibleUserIds: noteDraft.visibility === 'specified' ? noteDraft.visibleUserIds : undefined,
-			hashtag: noteDraft.hashtag ?? undefined,
+			visibleUserIds: noteDraft.visibleUserIds,
+			hashtag: noteDraft.hashtag,
 			fileIds: noteDraft.fileIds,
 			files: packedFiles != null ? this.packAttachedFiles(noteDraft.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(noteDraft.fileIds),
 			replyId: noteDraft.replyId,
 			renoteId: noteDraft.renoteId,
-			channelId: noteDraft.channelId ?? undefined,
+			channelId: noteDraft.channelId,
 			channel: channel ? {
 				id: channel.id,
 				name: channel.name,
@@ -127,6 +129,12 @@ export class NoteDraftEntityService implements OnModuleInit {
 				allowRenoteToExternal: channel.allowRenoteToExternal,
 				userId: channel.userId,
 			} : undefined,
+			poll: noteDraft.hasPoll ? {
+				choices: noteDraft.pollChoices,
+				multiple: noteDraft.pollMultiple,
+				expiresAt: noteDraft.pollExpiresAt?.toISOString(),
+				expiredAfter: noteDraft.pollExpiredAfter,
+			} : null,
 
 			...(opts.detail ? {
 				reply: noteDraft.replyId ? nullIfEntityNotFound(this.noteEntityService.pack(noteDraft.replyId, me, {
@@ -138,13 +146,6 @@ export class NoteDraftEntityService implements OnModuleInit {
 					detail: true,
 					skipHide: opts.skipHide,
 				})) : undefined,
-
-				poll: noteDraft.hasPoll ? {
-					choices: noteDraft.pollChoices,
-					multiple: noteDraft.pollMultiple,
-					expiresAt: noteDraft.pollExpiresAt?.toISOString(),
-					expiredAfter: noteDraft.pollExpiredAfter,
-				} : undefined,
 			} : {} ),
 		});
 
diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts
index e91fb9eb51..0e96237d32 100644
--- a/packages/backend/src/core/entities/NotificationEntityService.ts
+++ b/packages/backend/src/core/entities/NotificationEntityService.ts
@@ -21,7 +21,18 @@ import type { OnModuleInit } from '@nestjs/common';
 import type { UserEntityService } from './UserEntityService.js';
 import type { NoteEntityService } from './NoteEntityService.js';
 
-const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded'] as (typeof groupedNotificationTypes[number])[]);
+const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set([
+	'note',
+	'mention',
+	'reply',
+	'renote',
+	'renote:grouped',
+	'quote',
+	'reaction',
+	'reaction:grouped',
+	'pollEnded',
+	'scheduledNotePosted',
+] as (typeof groupedNotificationTypes[number])[]);
 
 @Injectable()
 export class NotificationEntityService implements OnModuleInit {
diff --git a/packages/backend/src/models/NoteDraft.ts b/packages/backend/src/models/NoteDraft.ts
index 6483748bc2..0ece02c943 100644
--- a/packages/backend/src/models/NoteDraft.ts
+++ b/packages/backend/src/models/NoteDraft.ts
@@ -126,7 +126,7 @@ export class MiNoteDraft {
 	@JoinColumn()
 	public channel: MiChannel | null;
 
-	// 以下、Pollについて追加
+	//#region 以下、Pollについて追加
 
 	@Column('boolean', {
 		default: false,
@@ -151,13 +151,15 @@ export class MiNoteDraft {
 	})
 	public pollExpiredAfter: number | null;
 
-	// ここまで追加
+	//#endregion
 
-	constructor(data: Partial<MiNoteDraft>) {
-		if (data == null) return;
+	@Column('timestamp with time zone', {
+		nullable: true,
+	})
+	public scheduledAt: Date | null;
 
-		for (const [k, v] of Object.entries(data)) {
-			(this as any)[k] = v;
-		}
-	}
+	@Column('boolean', {
+		default: false,
+	})
+	public isActuallyScheduled: boolean;
 }
diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts
index 0b4eeb3455..7fa17e20fa 100644
--- a/packages/backend/src/models/Notification.ts
+++ b/packages/backend/src/models/Notification.ts
@@ -9,6 +9,7 @@ import { MiNote } from './Note.js';
 import { MiAccessToken } from './AccessToken.js';
 import { MiRole } from './Role.js';
 import { MiDriveFile } from './DriveFile.js';
+import { MiNoteDraft } from './NoteDraft.js';
 
 // misskey-js の notificationTypes と同期すべし
 export type MiNotification = {
@@ -60,6 +61,16 @@ export type MiNotification = {
 	createdAt: string;
 	notifierId: MiUser['id'];
 	noteId: MiNote['id'];
+} | {
+	type: 'scheduledNotePosted';
+	id: string;
+	createdAt: string;
+	noteId: MiNote['id'];
+} | {
+	type: 'scheduledNotePostFailed';
+	id: string;
+	createdAt: string;
+	noteDraftId: MiNoteDraft['id'];
 } | {
 	type: 'receiveFollowRequest';
 	id: string;
diff --git a/packages/backend/src/models/json-schema/note-draft.ts b/packages/backend/src/models/json-schema/note-draft.ts
index 504b263a6d..8144ac7b3b 100644
--- a/packages/backend/src/models/json-schema/note-draft.ts
+++ b/packages/backend/src/models/json-schema/note-draft.ts
@@ -23,7 +23,7 @@ export const packedNoteDraftSchema = {
 		},
 		cw: {
 			type: 'string',
-			optional: true, nullable: true,
+			optional: false, nullable: true,
 		},
 		userId: {
 			type: 'string',
@@ -37,27 +37,23 @@ export const packedNoteDraftSchema = {
 		},
 		replyId: {
 			type: 'string',
-			optional: true, nullable: true,
+			optional: false, nullable: true,
 			format: 'id',
-			example: 'xxxxxxxxxx',
 		},
 		renoteId: {
 			type: 'string',
-			optional: true, nullable: true,
+			optional: false, nullable: true,
 			format: 'id',
-			example: 'xxxxxxxxxx',
 		},
 		reply: {
 			type: 'object',
 			optional: true, nullable: true,
 			ref: 'Note',
-			description: 'The reply target note contents if exists. If the reply target has been deleted since the draft was created, this will be null while replyId is not null.',
 		},
 		renote: {
 			type: 'object',
 			optional: true, nullable: true,
 			ref: 'Note',
-			description: 'The renote target note contents if exists. If the renote target has been deleted since the draft was created, this will be null while renoteId is not null.',
 		},
 		visibility: {
 			type: 'string',
@@ -66,7 +62,7 @@ export const packedNoteDraftSchema = {
 		},
 		visibleUserIds: {
 			type: 'array',
-			optional: true, nullable: false,
+			optional: false, nullable: false,
 			items: {
 				type: 'string',
 				optional: false, nullable: false,
@@ -75,7 +71,7 @@ export const packedNoteDraftSchema = {
 		},
 		fileIds: {
 			type: 'array',
-			optional: true, nullable: false,
+			optional: false, nullable: false,
 			items: {
 				type: 'string',
 				optional: false, nullable: false,
@@ -93,11 +89,11 @@ export const packedNoteDraftSchema = {
 		},
 		hashtag: {
 			type: 'string',
-			optional: true, nullable: false,
+			optional: false, nullable: true,
 		},
 		poll: {
 			type: 'object',
-			optional: true, nullable: true,
+			optional: false, nullable: true,
 			properties: {
 				expiresAt: {
 					type: 'string',
@@ -124,9 +120,8 @@ export const packedNoteDraftSchema = {
 		},
 		channelId: {
 			type: 'string',
-			optional: true, nullable: true,
+			optional: false, nullable: true,
 			format: 'id',
-			example: 'xxxxxxxxxx',
 		},
 		channel: {
 			type: 'object',
@@ -160,12 +155,20 @@ export const packedNoteDraftSchema = {
 		},
 		localOnly: {
 			type: 'boolean',
-			optional: true, nullable: false,
+			optional: false, nullable: false,
 		},
 		reactionAcceptance: {
 			type: 'string',
 			optional: false, nullable: true,
 			enum: ['likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote', null],
 		},
+		scheduledAt: {
+			type: 'number',
+			optional: false, nullable: true,
+		},
+		isActuallyScheduled: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
 	},
 } as const;
diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts
index 6de120c8d7..30e9c9327a 100644
--- a/packages/backend/src/models/json-schema/notification.ts
+++ b/packages/backend/src/models/json-schema/notification.ts
@@ -207,6 +207,36 @@ export const packedNotificationSchema = {
 				optional: false, nullable: false,
 			},
 		},
+	}, {
+		type: 'object',
+		properties: {
+			...baseSchema.properties,
+			type: {
+				type: 'string',
+				optional: false, nullable: false,
+				enum: ['scheduledNotePosted'],
+			},
+			note: {
+				type: 'object',
+				ref: 'Note',
+				optional: false, nullable: false,
+			},
+		},
+	}, {
+		type: 'object',
+		properties: {
+			...baseSchema.properties,
+			type: {
+				type: 'string',
+				optional: false, nullable: false,
+				enum: ['scheduledNotePostFailed'],
+			},
+			noteDraft: {
+				type: 'object',
+				ref: 'NoteDraft',
+				optional: false, nullable: false,
+			},
+		},
 	}, {
 		type: 'object',
 		properties: {
diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts
index 0b9234cb81..b9000152d4 100644
--- a/packages/backend/src/models/json-schema/role.ts
+++ b/packages/backend/src/models/json-schema/role.ts
@@ -317,6 +317,10 @@ export const packedRolePoliciesSchema = {
 			type: 'integer',
 			optional: false, nullable: false,
 		},
+		scheduledNoteLimit: {
+			type: 'integer',
+			optional: false, nullable: false,
+		},
 		watermarkAvailable: {
 			type: 'boolean',
 			optional: false, nullable: false,
diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts
index c507d8d5c6..b5fd38a7d7 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -609,6 +609,8 @@ export const packedMeDetailedOnlySchema = {
 				quote: { optional: true, ...notificationRecieveConfig },
 				reaction: { optional: true, ...notificationRecieveConfig },
 				pollEnded: { optional: true, ...notificationRecieveConfig },
+				scheduledNotePosted: { optional: true, ...notificationRecieveConfig },
+				scheduledNotePostFailed: { optional: true, ...notificationRecieveConfig },
 				receiveFollowRequest: { optional: true, ...notificationRecieveConfig },
 				followRequestAccepted: { optional: true, ...notificationRecieveConfig },
 				roleAssigned: { optional: true, ...notificationRecieveConfig },
diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts
index e01414cd53..e64882c4df 100644
--- a/packages/backend/src/queue/QueueProcessorModule.ts
+++ b/packages/backend/src/queue/QueueProcessorModule.ts
@@ -10,6 +10,7 @@ import { QueueLoggerService } from './QueueLoggerService.js';
 import { QueueProcessorService } from './QueueProcessorService.js';
 import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
 import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
+import { PostScheduledNoteProcessorService } from './processors/PostScheduledNoteProcessorService.js';
 import { InboxProcessorService } from './processors/InboxProcessorService.js';
 import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
 import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
@@ -79,6 +80,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
 		UserWebhookDeliverProcessorService,
 		SystemWebhookDeliverProcessorService,
 		EndedPollNotificationProcessorService,
+		PostScheduledNoteProcessorService,
 		DeliverProcessorService,
 		InboxProcessorService,
 		AggregateRetentionProcessorService,
diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts
index 7b64182754..642d3fc8ad 100644
--- a/packages/backend/src/queue/QueueProcessorService.ts
+++ b/packages/backend/src/queue/QueueProcessorService.ts
@@ -14,6 +14,7 @@ import { CheckModeratorsActivityProcessorService } from '@/queue/processors/Chec
 import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
 import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
 import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
+import { PostScheduledNoteProcessorService } from './processors/PostScheduledNoteProcessorService.js';
 import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
 import { InboxProcessorService } from './processors/InboxProcessorService.js';
 import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js';
@@ -85,6 +86,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
 	private relationshipQueueWorker: Bull.Worker;
 	private objectStorageQueueWorker: Bull.Worker;
 	private endedPollNotificationQueueWorker: Bull.Worker;
+	private postScheduledNoteQueueWorker: Bull.Worker;
 
 	constructor(
 		@Inject(DI.config)
@@ -94,6 +96,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
 		private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService,
 		private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService,
 		private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
+		private postScheduledNoteProcessorService: PostScheduledNoteProcessorService,
 		private deliverProcessorService: DeliverProcessorService,
 		private inboxProcessorService: InboxProcessorService,
 		private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService,
@@ -520,6 +523,21 @@ export class QueueProcessorService implements OnApplicationShutdown {
 			});
 		}
 		//#endregion
+
+		//#region post scheduled note
+		{
+			this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => {
+				if (this.config.sentryForBackend) {
+					return Sentry.startSpan({ name: 'Queue: PostScheduledNote' }, () => this.postScheduledNoteProcessorService.process(job));
+				} else {
+					return this.postScheduledNoteProcessorService.process(job);
+				}
+			}, {
+				...baseWorkerOptions(this.config, QUEUE.POST_SCHEDULED_NOTE),
+				autorun: false,
+			});
+		}
+		//#endregion
 	}
 
 	@bindThis
@@ -534,6 +552,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
 			this.relationshipQueueWorker.run(),
 			this.objectStorageQueueWorker.run(),
 			this.endedPollNotificationQueueWorker.run(),
+			this.postScheduledNoteQueueWorker.run(),
 		]);
 	}
 
@@ -549,6 +568,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
 			this.relationshipQueueWorker.close(),
 			this.objectStorageQueueWorker.close(),
 			this.endedPollNotificationQueueWorker.close(),
+			this.postScheduledNoteQueueWorker.close(),
 		]);
 	}
 
diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts
index 7e146a7e03..625204b7ad 100644
--- a/packages/backend/src/queue/const.ts
+++ b/packages/backend/src/queue/const.ts
@@ -12,6 +12,7 @@ export const QUEUE = {
 	INBOX: 'inbox',
 	SYSTEM: 'system',
 	ENDED_POLL_NOTIFICATION: 'endedPollNotification',
+	POST_SCHEDULED_NOTE: 'postScheduledNote',
 	DB: 'db',
 	RELATIONSHIP: 'relationship',
 	OBJECT_STORAGE: 'objectStorage',
diff --git a/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts b/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts
new file mode 100644
index 0000000000..d0eaeee090
--- /dev/null
+++ b/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts
@@ -0,0 +1,72 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import type { NoteDraftsRepository } from '@/models/_.js';
+import type Logger from '@/logger.js';
+import { NotificationService } from '@/core/NotificationService.js';
+import { bindThis } from '@/decorators.js';
+import { NoteCreateService } from '@/core/NoteCreateService.js';
+import { QueueLoggerService } from '../QueueLoggerService.js';
+import type * as Bull from 'bullmq';
+import type { PostScheduledNoteJobData } from '../types.js';
+
+@Injectable()
+export class PostScheduledNoteProcessorService {
+	private logger: Logger;
+
+	constructor(
+		@Inject(DI.noteDraftsRepository)
+		private noteDraftsRepository: NoteDraftsRepository,
+
+		private noteCreateService: NoteCreateService,
+		private notificationService: NotificationService,
+		private queueLoggerService: QueueLoggerService,
+	) {
+		this.logger = this.queueLoggerService.logger.createSubLogger('post-scheduled-note');
+	}
+
+	@bindThis
+	public async process(job: Bull.Job<PostScheduledNoteJobData>): Promise<void> {
+		const draft = await this.noteDraftsRepository.findOne({ where: { id: job.data.noteDraftId }, relations: ['user'] });
+		if (draft == null || draft.user == null || draft.scheduledAt == null || !draft.isActuallyScheduled) {
+			return;
+		}
+
+		try {
+			const note = await this.noteCreateService.fetchAndCreate(draft.user, {
+				createdAt: new Date(),
+				fileIds: draft.fileIds,
+				poll: draft.hasPoll ? {
+					choices: draft.pollChoices,
+					multiple: draft.pollMultiple,
+					expiresAt: draft.pollExpiredAfter ? new Date(Date.now() + draft.pollExpiredAfter) : draft.pollExpiresAt ? new Date(draft.pollExpiresAt) : null,
+				} : null,
+				text: draft.text ?? null,
+				replyId: draft.replyId,
+				renoteId: draft.renoteId,
+				cw: draft.cw,
+				localOnly: draft.localOnly,
+				reactionAcceptance: draft.reactionAcceptance,
+				visibility: draft.visibility,
+				visibleUserIds: draft.visibleUserIds,
+				channelId: draft.channelId,
+			});
+
+			// await不要
+			this.noteDraftsRepository.remove(draft);
+
+			// await不要
+			this.notificationService.createNotification(draft.userId, 'scheduledNotePosted', {
+				noteId: note.id,
+			});
+		} catch (err) {
+			this.notificationService.createNotification(draft.userId, 'scheduledNotePostFailed', {
+				noteDraftId: draft.id,
+			});
+		}
+	}
+}
diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts
index 757daea88b..1cb2b93918 100644
--- a/packages/backend/src/queue/types.ts
+++ b/packages/backend/src/queue/types.ts
@@ -109,6 +109,10 @@ export type EndedPollNotificationJobData = {
 	noteId: MiNote['id'];
 };
 
+export type PostScheduledNoteJobData = {
+	noteDraftId: string;
+};
+
 export type SystemWebhookDeliverJobData<T extends SystemWebhookEventType = SystemWebhookEventType> = {
 	type: T;
 	content: SystemWebhookPayload<T>;
diff --git a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts
index d7f9e4eaa3..b69699c338 100644
--- a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts
+++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts
@@ -5,7 +5,7 @@
 
 import { Inject, Injectable } from '@nestjs/common';
 import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js';
+import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, PostScheduledNoteQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js';
 
 export const meta = {
 	tags: ['admin'],
@@ -49,6 +49,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 	constructor(
 		@Inject('queue:system') public systemQueue: SystemQueue,
 		@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
+		@Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
 		@Inject('queue:deliver') public deliverQueue: DeliverQueue,
 		@Inject('queue:inbox') public inboxQueue: InboxQueue,
 		@Inject('queue:db') public dbQueue: DbQueue,
diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts
index 1ba6853dbe..2fd7ab8ca2 100644
--- a/packages/backend/src/server/api/endpoints/admin/show-user.ts
+++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts
@@ -103,6 +103,8 @@ export const meta = {
 					quote: { optional: true, ...notificationRecieveConfig },
 					reaction: { optional: true, ...notificationRecieveConfig },
 					pollEnded: { optional: true, ...notificationRecieveConfig },
+					scheduledNotePosted: { optional: true, ...notificationRecieveConfig },
+					scheduledNotePostFailed: { optional: true, ...notificationRecieveConfig },
 					receiveFollowRequest: { optional: true, ...notificationRecieveConfig },
 					followRequestAccepted: { optional: true, ...notificationRecieveConfig },
 					roleAssigned: { optional: true, ...notificationRecieveConfig },
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index 082d97f5d4..5c7958fc1c 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -209,6 +209,8 @@ export const paramDef = {
 				quote: notificationRecieveConfig,
 				reaction: notificationRecieveConfig,
 				pollEnded: notificationRecieveConfig,
+				scheduledNotePosted: notificationRecieveConfig,
+				scheduledNotePostFailed: notificationRecieveConfig,
 				receiveFollowRequest: notificationRecieveConfig,
 				followRequestAccepted: notificationRecieveConfig,
 				roleAssigned: notificationRecieveConfig,
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index 7caea8eedc..e48aa69d0f 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -6,17 +6,10 @@
 import ms from 'ms';
 import { In } from 'typeorm';
 import { Inject, Injectable } from '@nestjs/common';
-import type { MiUser } from '@/models/User.js';
-import type { UsersRepository, NotesRepository, BlockingsRepository, DriveFilesRepository, ChannelsRepository } from '@/models/_.js';
-import type { MiDriveFile } from '@/models/DriveFile.js';
-import type { MiNote } from '@/models/Note.js';
-import type { MiChannel } from '@/models/Channel.js';
 import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
 import { Endpoint } from '@/server/api/endpoint-base.js';
 import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
 import { NoteCreateService } from '@/core/NoteCreateService.js';
-import { DI } from '@/di-symbols.js';
-import { isQuote, isRenote } from '@/misc/is-renote.js';
 import { IdentifiableError } from '@/misc/identifiable-error.js';
 import { ApiError } from '../../error.js';
 
@@ -223,168 +216,28 @@ export const paramDef = {
 @Injectable()
 export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 	constructor(
-		@Inject(DI.usersRepository)
-		private usersRepository: UsersRepository,
-
-		@Inject(DI.notesRepository)
-		private notesRepository: NotesRepository,
-
-		@Inject(DI.blockingsRepository)
-		private blockingsRepository: BlockingsRepository,
-
-		@Inject(DI.driveFilesRepository)
-		private driveFilesRepository: DriveFilesRepository,
-
-		@Inject(DI.channelsRepository)
-		private channelsRepository: ChannelsRepository,
-
 		private noteEntityService: NoteEntityService,
 		private noteCreateService: NoteCreateService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
-			let visibleUsers: MiUser[] = [];
-			if (ps.visibleUserIds) {
-				visibleUsers = await this.usersRepository.findBy({
-					id: In(ps.visibleUserIds),
-				});
-			}
-
-			let files: MiDriveFile[] = [];
-			const fileIds = ps.fileIds ?? ps.mediaIds ?? null;
-			if (fileIds != null) {
-				files = await this.driveFilesRepository.createQueryBuilder('file')
-					.where('file.userId = :userId AND file.id IN (:...fileIds)', {
-						userId: me.id,
-						fileIds,
-					})
-					.orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
-					.setParameters({ fileIds })
-					.getMany();
-
-				if (files.length !== fileIds.length) {
-					throw new ApiError(meta.errors.noSuchFile);
-				}
-			}
-
-			let renote: MiNote | null = null;
-			if (ps.renoteId != null) {
-				// Fetch renote to note
-				renote = await this.notesRepository.findOne({
-					where: { id: ps.renoteId },
-					relations: ['user', 'renote', 'reply'],
-				});
-
-				if (renote == null) {
-					throw new ApiError(meta.errors.noSuchRenoteTarget);
-				} else if (isRenote(renote) && !isQuote(renote)) {
-					throw new ApiError(meta.errors.cannotReRenote);
-				}
-
-				// Check blocking
-				if (renote.userId !== me.id) {
-					const blockExist = await this.blockingsRepository.exists({
-						where: {
-							blockerId: renote.userId,
-							blockeeId: me.id,
-						},
-					});
-					if (blockExist) {
-						throw new ApiError(meta.errors.youHaveBeenBlocked);
-					}
-				}
-
-				if (renote.visibility === 'followers' && renote.userId !== me.id) {
-					// 他人のfollowers noteはreject
-					throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
-				} else if (renote.visibility === 'specified') {
-					// specified / direct noteはreject
-					throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
-				}
-
-				if (renote.channelId && renote.channelId !== ps.channelId) {
-					// チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
-					// リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
-					const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId });
-					if (renoteChannel == null) {
-						// リノートしたいノートが書き込まれているチャンネルが無い
-						throw new ApiError(meta.errors.noSuchChannel);
-					} else if (!renoteChannel.allowRenoteToExternal) {
-						// リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合
-						throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel);
-					}
-				}
-			}
-
-			let reply: MiNote | null = null;
-			if (ps.replyId != null) {
-				// Fetch reply
-				reply = await this.notesRepository.findOne({
-					where: { id: ps.replyId },
-					relations: ['user'],
-				});
-
-				if (reply == null) {
-					throw new ApiError(meta.errors.noSuchReplyTarget);
-				} else if (isRenote(reply) && !isQuote(reply)) {
-					throw new ApiError(meta.errors.cannotReplyToPureRenote);
-				} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) {
-					throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
-				} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
-					throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
-				}
-
-				// Check blocking
-				if (reply.userId !== me.id) {
-					const blockExist = await this.blockingsRepository.exists({
-						where: {
-							blockerId: reply.userId,
-							blockeeId: me.id,
-						},
-					});
-					if (blockExist) {
-						throw new ApiError(meta.errors.youHaveBeenBlocked);
-					}
-				}
-			}
-
-			if (ps.poll) {
-				if (typeof ps.poll.expiresAt === 'number') {
-					if (ps.poll.expiresAt < Date.now()) {
-						throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
-					}
-				} else if (typeof ps.poll.expiredAfter === 'number') {
-					ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter;
-				}
-			}
-
-			let channel: MiChannel | null = null;
-			if (ps.channelId != null) {
-				channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false });
-
-				if (channel == null) {
-					throw new ApiError(meta.errors.noSuchChannel);
-				}
-			}
-
-			// 投稿を作成
 			try {
-				const note = await this.noteCreateService.create(me, {
+				const note = await this.noteCreateService.fetchAndCreate(me, {
 					createdAt: new Date(),
-					files: files,
+					fileIds: ps.fileIds ?? ps.mediaIds ?? [],
 					poll: ps.poll ? {
 						choices: ps.poll.choices,
 						multiple: ps.poll.multiple ?? false,
-						expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
-					} : undefined,
-					text: ps.text ?? undefined,
-					reply,
-					renote,
-					cw: ps.cw,
+						expiresAt: ps.poll.expiredAfter ? new Date(Date.now() + ps.poll.expiredAfter) : ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
+					} : null,
+					text: ps.text ?? null,
+					replyId: ps.replyId ?? null,
+					renoteId: ps.renoteId ?? null,
+					cw: ps.cw ?? null,
 					localOnly: ps.localOnly,
 					reactionAcceptance: ps.reactionAcceptance,
 					visibility: ps.visibility,
-					visibleUsers,
-					channel,
+					visibleUserIds: ps.visibleUserIds ?? [],
+					channelId: ps.channelId ?? null,
 					apMentions: ps.noExtractMentions ? [] : undefined,
 					apHashtags: ps.noExtractHashtags ? [] : undefined,
 					apEmojis: ps.noExtractEmojis ? [] : undefined,
@@ -393,16 +246,46 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				return {
 					createdNote: await this.noteEntityService.pack(note, me),
 				};
-			} catch (e) {
+			} catch (err) {
 				// TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい
-				if (e instanceof IdentifiableError) {
-					if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
+				if (err instanceof IdentifiableError) {
+					if (err.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
 						throw new ApiError(meta.errors.containsProhibitedWords);
-					} else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
+					} else if (err.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
 						throw new ApiError(meta.errors.containsTooManyMentions);
+					} else if (err.id === '801c046c-5bf5-4234-ad2b-e78fc20a2ac7') {
+						throw new ApiError(meta.errors.noSuchFile);
+					} else if (err.id === '53983c56-e163-45a6-942f-4ddc485d4290') {
+						throw new ApiError(meta.errors.noSuchRenoteTarget);
+					} else if (err.id === 'bde24c37-121f-4e7d-980d-cec52f599f02') {
+						throw new ApiError(meta.errors.cannotReRenote);
+					} else if (err.id === '2b4fe776-4414-4a2d-ae39-f3418b8fd4d3') {
+						throw new ApiError(meta.errors.youHaveBeenBlocked);
+					} else if (err.id === '90b9d6f0-893a-4fef-b0f1-e9a33989f71a') {
+						throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
+					} else if (err.id === '48d7a997-da5c-4716-b3c3-92db3f37bf7d') {
+						throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
+					} else if (err.id === 'b060f9a6-8909-4080-9e0b-94d9fa6f6a77') {
+						throw new ApiError(meta.errors.noSuchChannel);
+					} else if (err.id === '7e435f4a-780d-4cfc-a15a-42519bd6fb67') {
+						throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel);
+					} else if (err.id === '60142edb-1519-408e-926d-4f108d27bee0') {
+						throw new ApiError(meta.errors.noSuchReplyTarget);
+					} else if (err.id === 'f089e4e2-c0e7-4f60-8a23-e5a6bf786b36') {
+						throw new ApiError(meta.errors.cannotReplyToPureRenote);
+					} else if (err.id === '11cd37b3-a411-4f77-8633-c580ce6a8dce') {
+						throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
+					} else if (err.id === 'ced780a1-2012-4caf-bc7e-a95a291294cb') {
+						throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
+					} else if (err.id === 'b0df6025-f2e8-44b4-a26a-17ad99104612') {
+						throw new ApiError(meta.errors.youHaveBeenBlocked);
+					} else if (err.id === '0c11c11e-0c8d-48e7-822c-76ccef660068') {
+						throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
+					} else if (err.id === 'bfa3905b-25f5-4894-b430-da331a490e4b') {
+						throw new ApiError(meta.errors.noSuchChannel);
 					}
 				}
-				throw e;
+				throw err;
 			}
 		});
 	}
diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/create.ts b/packages/backend/src/server/api/endpoints/notes/drafts/create.ts
index 1c28ec22d0..8f2fbf9197 100644
--- a/packages/backend/src/server/api/endpoints/notes/drafts/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/drafts/create.ts
@@ -124,6 +124,12 @@ export const meta = {
 			id: '9ee33bbe-fde3-4c71-9b51-e50492c6b9c8',
 		},
 
+		tooManyScheduledNotes: {
+			message: 'You cannot create scheduled notes any more.',
+			code: 'TOO_MANY_SCHEDULED_NOTES',
+			id: '22ae69eb-09e3-4541-a850-773cfa45e693',
+		},
+
 		cannotRenoteToExternal: {
 			message: 'Cannot Renote to External.',
 			code: 'CANNOT_RENOTE_TO_EXTERNAL',
@@ -162,7 +168,7 @@ export const paramDef = {
 		fileIds: {
 			type: 'array',
 			uniqueItems: true,
-			minItems: 1,
+			minItems: 0,
 			maxItems: 16,
 			items: { type: 'string', format: 'misskey:id' },
 		},
@@ -183,8 +189,10 @@ export const paramDef = {
 			},
 			required: ['choices'],
 		},
+		scheduledAt: { type: 'integer', nullable: true },
+		isActuallyScheduled: { type: 'boolean', default: false },
 	},
-	required: [],
+	required: ['visibility', 'visibleUserIds', 'cw', 'hashtag', 'localOnly', 'reactionAcceptance', 'replyId', 'renoteId', 'channelId', 'text', 'fileIds', 'poll', 'scheduledAt', 'isActuallyScheduled'],
 } as const;
 
 @Injectable()
@@ -196,22 +204,23 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 		super(meta, paramDef, async (ps, me) => {
 			const draft = await this.noteDraftService.create(me, {
 				fileIds: ps.fileIds,
-				poll: ps.poll ? {
-					choices: ps.poll.choices,
-					multiple: ps.poll.multiple ?? false,
-					expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
-					expiredAfter: ps.poll.expiredAfter ?? null,
-				} : undefined,
-				text: ps.text ?? null,
-				replyId: ps.replyId ?? undefined,
-				renoteId: ps.renoteId ?? undefined,
-				cw: ps.cw ?? null,
-				...(ps.hashtag ? { hashtag: ps.hashtag } : {}),
+				pollChoices: ps.poll?.choices ?? [],
+				pollMultiple: ps.poll?.multiple ?? false,
+				pollExpiresAt: ps.poll?.expiresAt ? new Date(ps.poll.expiresAt) : null,
+				pollExpiredAfter: ps.poll?.expiredAfter ?? null,
+				hasPoll: ps.poll != null,
+				text: ps.text,
+				replyId: ps.replyId,
+				renoteId: ps.renoteId,
+				cw: ps.cw,
+				hashtag: ps.hashtag,
 				localOnly: ps.localOnly,
 				reactionAcceptance: ps.reactionAcceptance,
 				visibility: ps.visibility,
-				visibleUserIds: ps.visibleUserIds ?? [],
-				channelId: ps.channelId ?? undefined,
+				visibleUserIds: ps.visibleUserIds,
+				channelId: ps.channelId,
+				scheduledAt: ps.scheduledAt ? new Date(ps.scheduledAt) : null,
+				isActuallyScheduled: ps.isActuallyScheduled,
 			}).catch((err) => {
 				if (err instanceof IdentifiableError) {
 					switch (err.id) {
@@ -241,6 +250,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 							throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
 						case '215dbc76-336c-4d2a-9605-95766ba7dab0':
 							throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
+						case 'c3275f19-4558-4c59-83e1-4f684b5fab66':
+							throw new ApiError(meta.errors.tooManyScheduledNotes);
 						default:
 							throw err;
 					}
diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/list.ts b/packages/backend/src/server/api/endpoints/notes/drafts/list.ts
index f24f9b8fb2..0774f09228 100644
--- a/packages/backend/src/server/api/endpoints/notes/drafts/list.ts
+++ b/packages/backend/src/server/api/endpoints/notes/drafts/list.ts
@@ -41,6 +41,7 @@ export const paramDef = {
 		untilId: { type: 'string', format: 'misskey:id' },
 		sinceDate: { type: 'integer' },
 		untilDate: { type: 'integer' },
+		scheduled: { type: 'boolean', nullable: true },
 	},
 	required: [],
 } as const;
@@ -58,6 +59,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			const query = this.queryService.makePaginationQuery<MiNoteDraft>(this.noteDraftsRepository.createQueryBuilder('drafts'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
 				.andWhere('drafts.userId = :meId', { meId: me.id });
 
+			if (ps.scheduled === true) {
+				query.andWhere('drafts.isActuallyScheduled = true');
+			} else if (ps.scheduled === false) {
+				query.andWhere('drafts.isActuallyScheduled = false');
+			}
+
 			const drafts = await query
 				.limit(ps.limit)
 				.getMany();
diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/update.ts b/packages/backend/src/server/api/endpoints/notes/drafts/update.ts
index ee221fb765..9a2e2ca415 100644
--- a/packages/backend/src/server/api/endpoints/notes/drafts/update.ts
+++ b/packages/backend/src/server/api/endpoints/notes/drafts/update.ts
@@ -159,6 +159,12 @@ export const meta = {
 			code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY',
 			id: '215dbc76-336c-4d2a-9605-95766ba7dab0',
 		},
+
+		tooManyScheduledNotes: {
+			message: 'You cannot create scheduled notes any more.',
+			code: 'TOO_MANY_SCHEDULED_NOTES',
+			id: '02f5df79-08ae-4a33-8524-f1503c8f6212',
+		},
 	},
 
 	limit: {
@@ -171,14 +177,14 @@ export const paramDef = {
 	type: 'object',
 	properties: {
 		draftId: { type: 'string', nullable: false, format: 'misskey:id' },
-		visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' },
+		visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'] },
 		visibleUserIds: { type: 'array', uniqueItems: true, items: {
 			type: 'string', format: 'misskey:id',
 		} },
 		cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 },
 		hashtag: { type: 'string', nullable: true, maxLength: 200 },
-		localOnly: { type: 'boolean', default: false },
-		reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
+		localOnly: { type: 'boolean' },
+		reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'] },
 		replyId: { type: 'string', format: 'misskey:id', nullable: true },
 		renoteId: { type: 'string', format: 'misskey:id', nullable: true },
 		channelId: { type: 'string', format: 'misskey:id', nullable: true },
@@ -194,7 +200,7 @@ export const paramDef = {
 		fileIds: {
 			type: 'array',
 			uniqueItems: true,
-			minItems: 1,
+			minItems: 0,
 			maxItems: 16,
 			items: { type: 'string', format: 'misskey:id' },
 		},
@@ -215,6 +221,8 @@ export const paramDef = {
 			},
 			required: ['choices'],
 		},
+		scheduledAt: { type: 'integer', nullable: true },
+		isActuallyScheduled: { type: 'boolean' },
 	},
 	required: ['draftId'],
 } as const;
@@ -228,22 +236,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 		super(meta, paramDef, async (ps, me) => {
 			const draft = await this.noteDraftService.update(me, ps.draftId, {
 				fileIds: ps.fileIds,
-				poll: ps.poll ? {
-					choices: ps.poll.choices,
-					multiple: ps.poll.multiple ?? false,
-					expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
-					expiredAfter: ps.poll.expiredAfter ?? null,
-				} : undefined,
-				text: ps.text ?? null,
-				replyId: ps.replyId ?? undefined,
-				renoteId: ps.renoteId ?? undefined,
-				cw: ps.cw ?? null,
-				...(ps.hashtag ? { hashtag: ps.hashtag } : {}),
+				pollChoices: ps.poll?.choices,
+				pollMultiple: ps.poll?.multiple,
+				pollExpiresAt: ps.poll?.expiresAt ? new Date(ps.poll.expiresAt) : null,
+				pollExpiredAfter: ps.poll?.expiredAfter,
+				text: ps.text,
+				replyId: ps.replyId,
+				renoteId: ps.renoteId,
+				cw: ps.cw,
+				hashtag: ps.hashtag,
 				localOnly: ps.localOnly,
 				reactionAcceptance: ps.reactionAcceptance,
 				visibility: ps.visibility,
-				visibleUserIds: ps.visibleUserIds ?? [],
-				channelId: ps.channelId ?? undefined,
+				visibleUserIds: ps.visibleUserIds,
+				channelId: ps.channelId,
+				scheduledAt: ps.scheduledAt ? new Date(ps.scheduledAt) : null,
+				isActuallyScheduled: ps.isActuallyScheduled,
 			}).catch((err) => {
 				if (err instanceof IdentifiableError) {
 					switch (err.id) {
@@ -285,6 +293,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 							throw new ApiError(meta.errors.containsProhibitedWords);
 						case '4de0363a-3046-481b-9b0f-feff3e211025':
 							throw new ApiError(meta.errors.containsTooManyMentions);
+						case 'bacdf856-5c51-4159-b88a-804fa5103be5':
+							throw new ApiError(meta.errors.tooManyScheduledNotes);
 						default:
 							throw err;
 					}
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index b20f2a2179..24654b0017 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -12,6 +12,8 @@
  * quote - 投稿が引用Renoteされた
  * reaction - 投稿にリアクションされた
  * pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した
+ * scheduledNotePosted - 予約したノートが投稿された
+ * scheduledNotePostFailed - 予約したノートの投稿に失敗した
  * receiveFollowRequest - フォローリクエストされた
  * followRequestAccepted - 自分の送ったフォローリクエストが承認された
  * roleAssigned - ロールが付与された
@@ -32,6 +34,8 @@ export const notificationTypes = [
 	'quote',
 	'reaction',
 	'pollEnded',
+	'scheduledNotePosted',
+	'scheduledNotePostFailed',
 	'receiveFollowRequest',
 	'followRequestAccepted',
 	'roleAssigned',
diff --git a/packages/frontend/src/components/MkNoteDraftsDialog.vue b/packages/frontend/src/components/MkNoteDraftsDialog.vue
index 5b8211b715..3f0a5a5247 100644
--- a/packages/frontend/src/components/MkNoteDraftsDialog.vue
+++ b/packages/frontend/src/components/MkNoteDraftsDialog.vue
@@ -15,101 +15,151 @@ SPDX-License-Identifier: AGPL-3.0-only
 	@esc="cancel()"
 >
 	<template #header>
-		{{ i18n.ts.drafts }} ({{ currentDraftsCount }}/{{ $i?.policies.noteDraftLimit }})
+		{{ i18n.ts.draftsAndScheduledNotes }} ({{ currentDraftsCount }}/{{ $i?.policies.noteDraftLimit }})
 	</template>
-	<div class="_spacer">
-		<MkPagination :paginator="paginator" withControl>
-			<template #empty>
-				<MkResult type="empty" :text="i18n.ts._drafts.noDrafts"/>
-			</template>
-
-			<template #default="{ items }">
-				<div class="_gaps_s">
-					<div
-						v-for="draft in (items as unknown as Misskey.entities.NoteDraft[])"
-						:key="draft.id"
-						v-panel
-						:class="[$style.draft]"
-					>
-						<div :class="$style.draftBody" class="_gaps_s">
-							<div :class="$style.draftInfo">
-								<div :class="$style.draftMeta">
-									<div v-if="draft.reply" class="_nowrap">
-										<i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span">
-											<template #user>
-												<Mfm v-if="draft.reply.user.name != null" :text="draft.reply.user.name" :plain="true" :nowrap="true"/>
-												<MkAcct v-else :user="draft.reply.user"/>
-											</template>
-										</I18n>
-									</div>
-									<div v-else-if="draft.replyId" class="_nowrap">
-										<i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span">
-											<template #user>
-												{{ i18n.ts.deletedNote }}
-											</template>
-										</I18n>
-									</div>
-									<div v-if="draft.renote && draft.text != null" class="_nowrap">
-										<i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span">
-											<template #user>
-												<Mfm v-if="draft.renote.user.name != null" :text="draft.renote.user.name" :plain="true" :nowrap="true"/>
-												<MkAcct v-else :user="draft.renote.user"/>
-											</template>
-										</I18n>
-									</div>
-									<div v-else-if="draft.renoteId" class="_nowrap">
-										<i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span">
-											<template #user>
-												{{ i18n.ts.deletedNote }}
-											</template>
-										</I18n>
+
+	<MkStickyContainer>
+		<template #header>
+			<MkTabs
+				v-model:tab="tab"
+				centered
+				:class="$style.tabs"
+				:tabs="[
+					{
+						key: 'drafts',
+						title: i18n.ts.drafts,
+						icon: 'ti ti-pencil-question',
+					},
+					{
+						key: 'scheduled',
+						title: i18n.ts.scheduled,
+						icon: 'ti ti-calendar-clock',
+					},
+				]"
+			/>
+		</template>
+
+		<div class="_spacer">
+			<MkPagination :key="tab" :paginator="tab === 'scheduled' ? scheduledPaginator : draftsPaginator" withControl>
+				<template #empty>
+					<MkResult type="empty" :text="i18n.ts._drafts.noDrafts"/>
+				</template>
+
+				<template #default="{ items }">
+					<div class="_gaps_s">
+						<div
+							v-for="draft in (items as unknown as Misskey.entities.NoteDraft[])"
+							:key="draft.id"
+							v-panel
+							:class="[$style.draft]"
+						>
+							<div :class="$style.draftBody" class="_gaps_s">
+								<MkInfo v-if="draft.scheduledAt != null && draft.isActuallyScheduled">
+									<I18n :src="i18n.ts.scheduledToPostOnX" tag="span">
+										<template #x>
+											<MkTime :time="draft.scheduledAt" :mode="'detail'" style="font-weight: bold;"/>
+										</template>
+									</I18n>
+								</MkInfo>
+								<div :class="$style.draftInfo">
+									<div :class="$style.draftMeta">
+										<div v-if="draft.reply" class="_nowrap">
+											<i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span">
+												<template #user>
+													<Mfm v-if="draft.reply.user.name != null" :text="draft.reply.user.name" :plain="true" :nowrap="true"/>
+													<MkAcct v-else :user="draft.reply.user"/>
+												</template>
+											</I18n>
+										</div>
+										<div v-else-if="draft.replyId" class="_nowrap">
+											<i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span">
+												<template #user>
+													{{ i18n.ts.deletedNote }}
+												</template>
+											</I18n>
+										</div>
+										<div v-if="draft.renote && draft.text != null" class="_nowrap">
+											<i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span">
+												<template #user>
+													<Mfm v-if="draft.renote.user.name != null" :text="draft.renote.user.name" :plain="true" :nowrap="true"/>
+													<MkAcct v-else :user="draft.renote.user"/>
+												</template>
+											</I18n>
+										</div>
+										<div v-else-if="draft.renoteId" class="_nowrap">
+											<i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span">
+												<template #user>
+													{{ i18n.ts.deletedNote }}
+												</template>
+											</I18n>
+										</div>
+										<div v-if="draft.channel" class="_nowrap">
+											<i class="ti ti-device-tv"></i> {{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }}
+										</div>
 									</div>
-									<div v-if="draft.channel" class="_nowrap">
-										<i class="ti ti-device-tv"></i> {{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }}
+								</div>
+								<div :class="$style.draftContent">
+									<Mfm :text="getNoteSummary(draft, { showRenote: false, showReply: false })" :plain="true" :author="draft.user"/>
+								</div>
+								<div :class="$style.draftFooter">
+									<div :class="$style.draftVisibility">
+										<span :title="i18n.ts._visibility[draft.visibility]">
+											<i v-if="draft.visibility === 'public'" class="ti ti-world"></i>
+											<i v-else-if="draft.visibility === 'home'" class="ti ti-home"></i>
+											<i v-else-if="draft.visibility === 'followers'" class="ti ti-lock"></i>
+											<i v-else-if="draft.visibility === 'specified'" class="ti ti-mail"></i>
+										</span>
+										<span v-if="draft.localOnly" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
 									</div>
+									<MkTime :time="draft.createdAt" :class="$style.draftCreatedAt" mode="detail" colored/>
 								</div>
 							</div>
-							<div :class="$style.draftContent">
-								<Mfm :text="getNoteSummary(draft, { showRenote: false, showReply: false })" :plain="true" :author="draft.user"/>
-							</div>
-							<div :class="$style.draftFooter">
-								<div :class="$style.draftVisibility">
-									<span :title="i18n.ts._visibility[draft.visibility]">
-										<i v-if="draft.visibility === 'public'" class="ti ti-world"></i>
-										<i v-else-if="draft.visibility === 'home'" class="ti ti-home"></i>
-										<i v-else-if="draft.visibility === 'followers'" class="ti ti-lock"></i>
-										<i v-else-if="draft.visibility === 'specified'" class="ti ti-mail"></i>
-									</span>
-									<span v-if="draft.localOnly" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
-								</div>
-								<MkTime :time="draft.createdAt" :class="$style.draftCreatedAt" mode="detail" colored/>
+
+							<div :class="$style.draftActions" class="_buttons">
+								<template v-if="draft.scheduledAt != null && draft.isActuallyScheduled">
+									<MkButton
+										:class="$style.itemButton"
+										small
+										@click="cancelSchedule(draft)"
+									>
+										<i class="ti ti-calendar-x"></i> {{ i18n.ts._drafts.cancelSchedule }}
+									</MkButton>
+									<!-- TODO
+									<MkButton
+										:class="$style.itemButton"
+										small
+										@click="reSchedule(draft)"
+									>
+										<i class="ti ti-calendar-time"></i> {{ i18n.ts._drafts.reSchedule }}
+									</MkButton>
+									-->
+								</template>
+								<MkButton
+									v-else
+									:class="$style.itemButton"
+									small
+									@click="restoreDraft(draft)"
+								>
+									<i class="ti ti-corner-up-left"></i> {{ i18n.ts._drafts.restore }}
+								</MkButton>
+								<MkButton
+									v-tooltip="i18n.ts._drafts.delete"
+									danger
+									small
+									:iconOnly="true"
+									:class="$style.itemButton"
+									style="margin-left: auto;"
+									@click="deleteDraft(draft)"
+								>
+									<i class="ti ti-trash"></i>
+								</MkButton>
 							</div>
 						</div>
-						<div :class="$style.draftActions" class="_buttons">
-							<MkButton
-								:class="$style.itemButton"
-								small
-								@click="restoreDraft(draft)"
-							>
-								<i class="ti ti-corner-up-left"></i>
-								{{ i18n.ts._drafts.restore }}
-							</MkButton>
-							<MkButton
-								v-tooltip="i18n.ts._drafts.delete"
-								danger
-								small
-								:iconOnly="true"
-								:class="$style.itemButton"
-								@click="deleteDraft(draft)"
-							>
-								<i class="ti ti-trash"></i>
-							</MkButton>
-						</div>
 					</div>
-				</div>
-			</template>
-		</MkPagination>
-	</div>
+				</template>
+			</MkPagination>
+		</div>
+	</MkStickyContainer>
 </MkModalWindow>
 </template>
 
@@ -125,6 +175,12 @@ import * as os from '@/os.js';
 import { $i } from '@/i.js';
 import { misskeyApi } from '@/utility/misskey-api';
 import { Paginator } from '@/utility/paginator.js';
+import MkTabs from '@/components/MkTabs.vue';
+import MkInfo from '@/components/MkInfo.vue';
+
+const props = defineProps<{
+	scheduled?: boolean;
+}>();
 
 const emit = defineEmits<{
 	(ev: 'restore', draft: Misskey.entities.NoteDraft): void;
@@ -132,8 +188,20 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const paginator = markRaw(new Paginator('notes/drafts/list', {
+const tab = ref<'drafts' | 'scheduled'>(props.scheduled ? 'scheduled' : 'drafts');
+
+const draftsPaginator = markRaw(new Paginator('notes/drafts/list', {
+	limit: 10,
+	params: {
+		scheduled: false,
+	},
+}));
+
+const scheduledPaginator = markRaw(new Paginator('notes/drafts/list', {
 	limit: 10,
+	params: {
+		scheduled: true,
+	},
 }));
 
 const currentDraftsCount = ref(0);
@@ -162,7 +230,17 @@ async function deleteDraft(draft: Misskey.entities.NoteDraft) {
 	if (canceled) return;
 
 	os.apiWithDialog('notes/drafts/delete', { draftId: draft.id }).then(() => {
-		paginator.reload();
+		draftsPaginator.reload();
+	});
+}
+
+async function cancelSchedule(draft: Misskey.entities.NoteDraft) {
+	os.apiWithDialog('notes/drafts/update', {
+		draftId: draft.id,
+		isActuallyScheduled: false,
+		scheduledAt: null,
+	}).then(() => {
+		scheduledPaginator.reload();
 	});
 }
 </script>
@@ -220,4 +298,11 @@ async function deleteDraft(draft: Misskey.entities.NoteDraft) {
 	padding-top: 16px;
 	border-top: solid 1px var(--MI_THEME-divider);
 }
+
+.tabs {
+	background: color(from var(--MI_THEME-bg) srgb r g b / 0.75);
+	-webkit-backdrop-filter: var(--MI-blur, blur(15px));
+	backdrop-filter: var(--MI-blur, blur(15px));
+	border-bottom: solid 0.5px var(--MI_THEME-divider);
+}
 </style>
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index 21104b41df..45a74e3f02 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <div :class="$style.root">
 	<div :class="$style.head">
 		<MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/>
-		<MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'createToken'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
+		<MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'createToken', 'scheduledNotePosted', 'scheduledNotePostFailed'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
 		<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
 		<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
 		<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
@@ -23,6 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 				[$style.t_mention]: notification.type === 'mention',
 				[$style.t_quote]: notification.type === 'quote',
 				[$style.t_pollEnded]: notification.type === 'pollEnded',
+				[$style.t_scheduledNotePosted]: notification.type === 'scheduledNotePosted',
+				[$style.t_scheduledNotePostFailed]: notification.type === 'scheduledNotePostFailed',
 				[$style.t_achievementEarned]: notification.type === 'achievementEarned',
 				[$style.t_exportCompleted]: notification.type === 'exportCompleted',
 				[$style.t_login]: notification.type === 'login',
@@ -39,6 +41,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<i v-else-if="notification.type === 'mention'" class="ti ti-at"></i>
 			<i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i>
 			<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
+			<i v-else-if="notification.type === 'scheduledNotePosted'" class="ti ti-send"></i>
+			<i v-else-if="notification.type === 'scheduledNotePostFailed'" class="ti ti-alert-triangle"></i>
 			<i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i>
 			<i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i>
 			<i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i>
@@ -60,6 +64,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<div :class="$style.tail">
 		<header :class="$style.header">
 			<span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span>
+			<span v-else-if="notification.type === 'scheduledNotePosted'">{{ i18n.ts._notification.scheduledNotePosted }}</span>
+			<span v-else-if="notification.type === 'scheduledNotePostFailed'">{{ i18n.ts._notification.scheduledNotePostFailed }}</span>
 			<span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span>
 			<span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span>
 			<span v-else-if="notification.type === 'chatRoomInvitationReceived'">{{ i18n.ts._notification.chatRoomInvitationReceived }}</span>
@@ -103,6 +109,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
 				<i class="ti ti-quote" :class="$style.quote"></i>
 			</MkA>
+			<MkA v-else-if="notification.type === 'scheduledNotePosted'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+				<i class="ti ti-quote" :class="$style.quote"></i>
+				<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
+				<i class="ti ti-quote" :class="$style.quote"></i>
+			</MkA>
 			<div v-else-if="notification.type === 'roleAssigned'" :class="$style.text">
 				{{ notification.role.name }}
 			</div>
@@ -338,6 +349,16 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
 	pointer-events: none;
 }
 
+.t_scheduledNotePosted {
+	background: var(--eventOther);
+	pointer-events: none;
+}
+
+.t_scheduledNotePostFailed {
+	background: var(--eventOther);
+	pointer-events: none;
+}
+
 .t_achievementEarned {
 	background: var(--eventAchievement);
 	pointer-events: none;
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 17f93a4ec8..c1b950a6c8 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -15,9 +15,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<div :class="$style.headerLeft">
 			<button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button>
 			<button v-click-anime v-tooltip="i18n.ts.switchAccount" :class="$style.account" class="_button" @click="openAccountMenu">
-				<MkAvatar :user="postAccount ?? $i" :class="$style.avatar"/>
+				<img :class="$style.avatar" :src="(postAccount ?? $i).avatarUrl" style="border-radius: 100%;"/>
 			</button>
-			<button v-if="$i.policies.noteDraftLimit > 0" v-tooltip="(postAccount != null && postAccount.id !== $i.id) ? null : i18n.ts.draft" class="_button" :class="$style.draftButton" :disabled="postAccount != null && postAccount.id !== $i.id" @click="showDraftMenu"><i class="ti ti-pencil-minus"></i></button>
+			<button v-if="$i.policies.noteDraftLimit > 0" v-tooltip="(postAccount != null && postAccount.id !== $i.id) ? null : i18n.ts.draftsAndScheduledNotes" class="_button" :class="$style.draftButton" :disabled="postAccount != null && postAccount.id !== $i.id" @click="showDraftMenu"><i class="ti ti-list"></i></button>
 		</div>
 		<div :class="$style.headerRight">
 			<template v-if="!(targetChannel != null && fixed)">
@@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<template v-if="posted"></template>
 					<template v-else-if="posting"><MkEllipsis/></template>
 					<template v-else>{{ submitText }}</template>
-					<i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : replyTargetNote ? 'ti ti-arrow-back-up' : renoteTargetNote ? 'ti ti-quote' : 'ti ti-send'"></i>
+					<i style="margin-left: 6px;" :class="submitIcon"></i>
 				</div>
 			</button>
 		</div>
@@ -61,6 +61,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button>
 		</div>
 	</div>
+	<MkInfo v-if="scheduledAt != null" :class="$style.scheduledAt">
+		<I18n :src="i18n.ts.scheduleToPostOnX" tag="span">
+			<template #x>
+				<MkTime :time="scheduledAt" :mode="'detail'" style="font-weight: bold;"/>
+			</template>
+		</I18n> - <button class="_textButton" @click="cancelSchedule()">{{ i18n.ts.cancel }}</button>
+	</MkInfo>
 	<MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
 	<div v-show="useCw" :class="$style.cwOuter">
 		<input ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd">
@@ -199,6 +206,7 @@ if (props.initialVisibleUsers) {
 	props.initialVisibleUsers.forEach(u => pushVisibleUser(u));
 }
 const reactionAcceptance = ref(store.s.reactionAcceptance);
+const scheduledAt = ref<number | null>(null);
 const draghover = ref(false);
 const quoteId = ref<string | null>(null);
 const hasNotSpecifiedMentions = ref(false);
@@ -262,11 +270,17 @@ const placeholder = computed((): string => {
 });
 
 const submitText = computed((): string => {
-	return renoteTargetNote.value
-		? i18n.ts.quote
-		: replyTargetNote.value
-			? i18n.ts.reply
-			: i18n.ts.note;
+	return scheduledAt.value != null
+		? i18n.ts.schedule
+		: renoteTargetNote.value
+			? i18n.ts.quote
+			: replyTargetNote.value
+				? i18n.ts.reply
+				: i18n.ts.note;
+});
+
+const submitIcon = computed((): string => {
+	return posted.value ? 'ti ti-check' : scheduledAt.value != null ? 'ti ti-calendar-time' : replyTargetNote.value ? 'ti ti-arrow-back-up' : renoteTargetNote.value ? 'ti ti-quote' : 'ti ti-send';
 });
 
 const textLength = computed((): number => {
@@ -414,6 +428,7 @@ function watchForDraft() {
 	watch(localOnly, () => saveDraft());
 	watch(quoteId, () => saveDraft());
 	watch(reactionAcceptance, () => saveDraft());
+	watch(scheduledAt, () => saveDraft());
 }
 
 function checkMissingMention() {
@@ -605,7 +620,13 @@ function showOtherSettings() {
 		action: () => {
 			toggleReactionAcceptance();
 		},
-	}, { type: 'divider' }, {
+	}, ...($i.policies.scheduledNoteLimit > 0 ? [{
+		icon: 'ti ti-calendar-time',
+		text: i18n.ts.schedulePost + '...',
+		action: () => {
+			schedule();
+		},
+	}] : []), { type: 'divider' }, {
 		type: 'switch',
 		icon: 'ti ti-eye',
 		text: i18n.ts.preview,
@@ -654,6 +675,7 @@ function clear() {
 	files.value = [];
 	poll.value = null;
 	quoteId.value = null;
+	scheduledAt.value = null;
 }
 
 function onKeydown(ev: KeyboardEvent) {
@@ -809,6 +831,7 @@ function saveDraft() {
 			...( visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}),
 			quoteId: quoteId.value,
 			reactionAcceptance: reactionAcceptance.value,
+			scheduledAt: scheduledAt.value,
 		},
 	};
 
@@ -823,7 +846,9 @@ function deleteDraft() {
 	miLocalStorage.setItem('drafts', JSON.stringify(draftData));
 }
 
-async function saveServerDraft(clearLocal = false) {
+async function saveServerDraft(options: {
+	isActuallyScheduled?: boolean;
+} = {}) {
 	return await os.apiWithDialog(serverDraftId.value == null ? 'notes/drafts/create' : 'notes/drafts/update', {
 		...(serverDraftId.value == null ? {} : { draftId: serverDraftId.value }),
 		text: text.value,
@@ -831,19 +856,15 @@ async function saveServerDraft(clearLocal = false) {
 		visibility: visibility.value,
 		localOnly: localOnly.value,
 		hashtag: hashtags.value,
-		...(files.value.length > 0 ? { fileIds: files.value.map(f => f.id) } : {}),
+		fileIds: files.value.map(f => f.id),
 		poll: poll.value,
-		...(visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}),
-		renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : undefined,
-		replyId: replyTargetNote.value ? replyTargetNote.value.id : undefined,
-		channelId: targetChannel.value ? targetChannel.value.id : undefined,
+		visibleUserIds: visibleUsers.value.map(x => x.id),
+		renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : null,
+		replyId: replyTargetNote.value ? replyTargetNote.value.id : null,
+		channelId: targetChannel.value ? targetChannel.value.id : null,
 		reactionAcceptance: reactionAcceptance.value,
-	}).then(() => {
-		if (clearLocal) {
-			clear();
-			deleteDraft();
-		}
-	}).catch((err) => {
+		scheduledAt: scheduledAt.value,
+		isActuallyScheduled: options.isActuallyScheduled ?? false,
 	});
 }
 
@@ -878,6 +899,21 @@ async function post(ev?: MouseEvent) {
 		}
 	}
 
+	if (scheduledAt.value != null) {
+		if (uploader.items.value.some(x => x.uploaded == null)) {
+			await uploadFiles();
+
+			// アップロード失敗したものがあったら中止
+			if (uploader.items.value.some(x => x.uploaded == null)) {
+				return;
+			}
+		}
+
+		await postAsScheduled();
+		clear();
+		return;
+	}
+
 	if (props.mock) return;
 
 	if (visibility.value === 'public' && (
@@ -1049,6 +1085,14 @@ async function post(ev?: MouseEvent) {
 	});
 }
 
+async function postAsScheduled() {
+	if (props.mock) return;
+
+	await saveServerDraft({
+		isActuallyScheduled: true,
+	});
+}
+
 function cancel() {
 	emit('cancel');
 }
@@ -1143,8 +1187,10 @@ function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent)
 }
 
 function showDraftMenu(ev: MouseEvent) {
-	function showDraftsDialog() {
-		const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNoteDraftsDialog.vue')), {}, {
+	function showDraftsDialog(scheduled: boolean) {
+		const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNoteDraftsDialog.vue')), {
+			scheduled,
+		}, {
 			restore: async (draft: Misskey.entities.NoteDraft) => {
 				text.value = draft.text ?? '';
 				useCw.value = draft.cw != null;
@@ -1175,6 +1221,7 @@ function showDraftMenu(ev: MouseEvent) {
 				renoteTargetNote.value = draft.renote;
 				replyTargetNote.value = draft.reply;
 				reactionAcceptance.value = draft.reactionAcceptance;
+				scheduledAt.value = draft.scheduledAt ?? null;
 				if (draft.channel) targetChannel.value = draft.channel as unknown as Misskey.entities.Channel;
 
 				visibleUsers.value = [];
@@ -1215,11 +1262,32 @@ function showDraftMenu(ev: MouseEvent) {
 		text: i18n.ts._drafts.listDrafts,
 		icon: 'ti ti-cloud-download',
 		action: () => {
-			showDraftsDialog();
+			showDraftsDialog(false);
+		},
+	}, { type: 'divider' }, {
+		type: 'button',
+		text: i18n.ts._drafts.listScheduledNotes,
+		icon: 'ti ti-clock-down',
+		action: () => {
+			showDraftsDialog(true);
 		},
 	}], (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
 }
 
+async function schedule() {
+	const { canceled, result } = await os.inputDatetime({
+		title: i18n.ts.schedulePost,
+	});
+	if (canceled) return;
+	if (result.getTime() <= Date.now()) return;
+
+	scheduledAt.value = result.getTime();
+}
+
+function cancelSchedule() {
+	scheduledAt.value = null;
+}
+
 onMounted(() => {
 	if (props.autofocus) {
 		focus();
@@ -1255,6 +1323,7 @@ onMounted(() => {
 				}
 				quoteId.value = draft.data.quoteId;
 				reactionAcceptance.value = draft.data.reactionAcceptance;
+				scheduledAt.value = draft.data.scheduledAt ?? null;
 			}
 		}
 
@@ -1519,6 +1588,10 @@ html[data-color-scheme=light] .preview {
 	margin: 0 20px 16px 20px;
 }
 
+.scheduledAt {
+	margin: 0 20px 16px 20px;
+}
+
 .cw,
 .hashtags,
 .text {
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index 6c5f04c6b5..aafa1c4b21 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -76,7 +76,7 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints>(
 		} else if (err.code === 'ROLE_PERMISSION_DENIED') {
 			title = i18n.ts.permissionDeniedError;
 			text = i18n.ts.permissionDeniedErrorDescription;
-		} else if (err.code.startsWith('TOO_MANY')) {
+		} else if (err.code.startsWith('TOO_MANY')) { // TODO: バックエンドに kind: client/contentsLimitExceeded みたいな感じで送るように統一してもらってそれで判定する
 			title = i18n.ts.youCannotCreateAnymore;
 			text = `${i18n.ts.error}: ${err.id}`;
 		} else if (err.message.startsWith('Unexpected token')) {
@@ -460,7 +460,7 @@ export function inputNumber(props: {
 	});
 }
 
-export function inputDate(props: {
+export function inputDatetime(props: {
 	title?: string;
 	text?: string;
 	placeholder?: string | null;
@@ -475,13 +475,13 @@ export function inputDate(props: {
 			title: props.title,
 			text: props.text,
 			input: {
-				type: 'date',
+				type: 'datetime-local',
 				placeholder: props.placeholder,
 				default: props.default ?? null,
 			},
 		}, {
 			done: result => {
-				resolve(result ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true });
+				resolve(result != null && result.result != null ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true });
 			},
 			closed: () => dispose(),
 		});
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index e10eb1163e..03df01a930 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -802,6 +802,25 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</div>
 			</MkFolder>
 
+			<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduledNoteLimit, 'scheduledNoteLimit'])">
+				<template #label>{{ i18n.ts._role._options.scheduledNoteLimit }}</template>
+				<template #suffix>
+					<span v-if="role.policies.scheduledNoteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+					<span v-else>{{ role.policies.scheduledNoteLimit.value }}</span>
+					<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.scheduledNoteLimit)"></i></span>
+				</template>
+				<div class="_gaps">
+					<MkSwitch v-model="role.policies.scheduledNoteLimit.useDefault" :readonly="readonly">
+						<template #label>{{ i18n.ts._role.useBaseValue }}</template>
+					</MkSwitch>
+					<MkInput v-model="role.policies.scheduledNoteLimit.value" :disabled="role.policies.scheduledNoteLimit.useDefault" type="number" :readonly="readonly">
+					</MkInput>
+					<MkRange v-model="role.policies.scheduledNoteLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+						<template #label>{{ i18n.ts._role.priority }}</template>
+					</MkRange>
+				</div>
+			</MkFolder>
+
 			<MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])">
 				<template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template>
 				<template #suffix>
@@ -831,6 +850,7 @@ import { watch, ref, computed } from 'vue';
 import { throttle } from 'throttle-debounce';
 import * as Misskey from 'misskey-js';
 import RolesEditorFormula from './RolesEditorFormula.vue';
+import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkColorInput from '@/components/MkColorInput.vue';
 import MkSelect from '@/components/MkSelect.vue';
@@ -842,7 +862,6 @@ import FormSlot from '@/components/form/slot.vue';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
 import { deepClone } from '@/utility/clone.js';
-import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue';
 
 const emit = defineEmits<{
 	(ev: 'update:modelValue', v: any): void;
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 7a49860b2d..eca0284be3 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -304,6 +304,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 						</MkInput>
 					</MkFolder>
 
+					<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduledNoteLimit, 'scheduledNoteLimit'])">
+						<template #label>{{ i18n.ts._role._options.scheduledNoteLimit }}</template>
+						<template #suffix>{{ policies.scheduledNoteLimit }}</template>
+						<MkInput v-model="policies.scheduledNoteLimit" type="number" :min="0">
+						</MkInput>
+					</MkFolder>
+
 					<MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])">
 						<template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template>
 						<template #suffix>{{ policies.watermarkAvailable ? i18n.ts.yes : i18n.ts.no }}</template>
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index d07710fc67..fab205b939 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -3226,7 +3226,7 @@ type Notification_2 = components['schemas']['Notification'];
 type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json'];
 
 // @public (undocumented)
-export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "chatRoomInvitationReceived", "achievementEarned", "exportCompleted", "test", "login", "createToken"];
+export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollEnded", "scheduledNotePosted", "scheduledNotePostFailed", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "chatRoomInvitationReceived", "achievementEarned", "exportCompleted", "test", "login", "createToken"];
 
 // @public (undocumented)
 export function nyaize(text: string): string;
@@ -3339,7 +3339,7 @@ type QueueStats = {
 type QueueStatsLog = QueueStats[];
 
 // @public (undocumented)
-export const queueTypes: readonly ["system", "endedPollNotification", "deliver", "inbox", "db", "relationship", "objectStorage", "userWebhookDeliver", "systemWebhookDeliver"];
+export const queueTypes: readonly ["system", "endedPollNotification", "postScheduledNote", "deliver", "inbox", "db", "relationship", "objectStorage", "userWebhookDeliver", "systemWebhookDeliver"];
 
 // @public (undocumented)
 type RenoteMuteCreateRequest = operations['renote-mute___create']['requestBody']['content']['application/json'];
@@ -3441,7 +3441,7 @@ type RoleLite = components['schemas']['RoleLite'];
 type RolePolicies = components['schemas']['RolePolicies'];
 
 // @public (undocumented)
-export const rolePolicies: readonly ["gtlAvailable", "ltlAvailable", "canPublicNote", "mentionLimit", "canInvite", "inviteLimit", "inviteLimitCycle", "inviteExpirationTime", "canManageCustomEmojis", "canManageAvatarDecorations", "canSearchNotes", "canSearchUsers", "canUseTranslator", "canHideAds", "driveCapacityMb", "maxFileSizeMb", "alwaysMarkNsfw", "canUpdateBioMedia", "pinLimit", "antennaLimit", "wordMuteLimit", "webhookLimit", "clipLimit", "noteEachClipsLimit", "userListLimit", "userEachUserListsLimit", "rateLimitFactor", "avatarDecorationLimit", "canImportAntennas", "canImportBlocking", "canImportFollowing", "canImportMuting", "canImportUserLists", "chatAvailability", "uploadableFileTypes", "noteDraftLimit", "watermarkAvailable"];
+export const rolePolicies: readonly ["gtlAvailable", "ltlAvailable", "canPublicNote", "mentionLimit", "canInvite", "inviteLimit", "inviteLimitCycle", "inviteExpirationTime", "canManageCustomEmojis", "canManageAvatarDecorations", "canSearchNotes", "canSearchUsers", "canUseTranslator", "canHideAds", "driveCapacityMb", "maxFileSizeMb", "alwaysMarkNsfw", "canUpdateBioMedia", "pinLimit", "antennaLimit", "wordMuteLimit", "webhookLimit", "clipLimit", "noteEachClipsLimit", "userListLimit", "userEachUserListsLimit", "rateLimitFactor", "avatarDecorationLimit", "canImportAntennas", "canImportBlocking", "canImportFollowing", "canImportMuting", "canImportUserLists", "chatAvailability", "uploadableFileTypes", "noteDraftLimit", "scheduledNoteLimit", "watermarkAvailable"];
 
 // @public (undocumented)
 type RolesListResponse = operations['roles___list']['responses']['200']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 22a3733fcb..2a059bdea6 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -4159,6 +4159,24 @@ export type components = {
                     /** Format: misskey:id */
                     userListId: string;
                 };
+                scheduledNotePosted?: {
+                    /** @enum {string} */
+                    type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
+                } | {
+                    /** @enum {string} */
+                    type: 'list';
+                    /** Format: misskey:id */
+                    userListId: string;
+                };
+                scheduledNotePostFailed?: {
+                    /** @enum {string} */
+                    type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
+                } | {
+                    /** @enum {string} */
+                    type: 'list';
+                    /** Format: misskey:id */
+                    userListId: string;
+                };
                 receiveFollowRequest?: {
                     /** @enum {string} */
                     type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
@@ -4406,42 +4424,31 @@ export type components = {
             /** Format: date-time */
             createdAt: string;
             text: string | null;
-            cw?: string | null;
+            cw: string | null;
             /** Format: id */
             userId: string;
             user: components['schemas']['UserLite'];
-            /**
-             * Format: id
-             * @example xxxxxxxxxx
-             */
-            replyId?: string | null;
-            /**
-             * Format: id
-             * @example xxxxxxxxxx
-             */
-            renoteId?: string | null;
-            /** @description The reply target note contents if exists. If the reply target has been deleted since the draft was created, this will be null while replyId is not null. */
+            /** Format: id */
+            replyId: string | null;
+            /** Format: id */
+            renoteId: string | null;
             reply?: components['schemas']['Note'] | null;
-            /** @description The renote target note contents if exists. If the renote target has been deleted since the draft was created, this will be null while renoteId is not null. */
             renote?: components['schemas']['Note'] | null;
             /** @enum {string} */
             visibility: 'public' | 'home' | 'followers' | 'specified';
-            visibleUserIds?: string[];
-            fileIds?: string[];
+            visibleUserIds: string[];
+            fileIds: string[];
             files?: components['schemas']['DriveFile'][];
-            hashtag?: string;
-            poll?: {
+            hashtag: string | null;
+            poll: {
                 /** Format: date-time */
                 expiresAt?: string | null;
                 expiredAfter?: number | null;
                 multiple: boolean;
                 choices: string[];
             } | null;
-            /**
-             * Format: id
-             * @example xxxxxxxxxx
-             */
-            channelId?: string | null;
+            /** Format: id */
+            channelId: string | null;
             channel?: {
                 id: string;
                 name: string;
@@ -4450,9 +4457,11 @@ export type components = {
                 allowRenoteToExternal: boolean;
                 userId: string | null;
             } | null;
-            localOnly?: boolean;
+            localOnly: boolean;
             /** @enum {string|null} */
             reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null;
+            scheduledAt: number | null;
+            isActuallyScheduled: boolean;
         };
         NoteReaction: {
             /** Format: id */
@@ -4561,6 +4570,22 @@ export type components = {
             /** Format: id */
             userId: string;
             note: components['schemas']['Note'];
+        } | {
+            /** Format: id */
+            id: string;
+            /** Format: date-time */
+            createdAt: string;
+            /** @enum {string} */
+            type: 'scheduledNotePosted';
+            note: components['schemas']['Note'];
+        } | {
+            /** Format: id */
+            id: string;
+            /** Format: date-time */
+            createdAt: string;
+            /** @enum {string} */
+            type: 'scheduledNotePostFailed';
+            noteDraft: components['schemas']['NoteDraft'];
         } | {
             /** Format: id */
             id: string;
@@ -5253,6 +5278,7 @@ export type components = {
             /** @enum {string} */
             chatAvailability: 'available' | 'readonly' | 'unavailable';
             noteDraftLimit: number;
+            scheduledNoteLimit: number;
             watermarkAvailable: boolean;
         };
         ReversiGameLite: {
@@ -9553,7 +9579,7 @@ export interface operations {
             content: {
                 'application/json': {
                     /** @enum {string} */
-                    queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+                    queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
                     /** @enum {string} */
                     state: '*' | 'completed' | 'wait' | 'active' | 'paused' | 'prioritized' | 'delayed' | 'failed';
                 };
@@ -9740,7 +9766,7 @@ export interface operations {
             content: {
                 'application/json': {
                     /** @enum {string} */
-                    queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+                    queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
                     state: ('active' | 'wait' | 'delayed' | 'completed' | 'failed' | 'paused')[];
                     search?: string;
                 };
@@ -9808,7 +9834,7 @@ export interface operations {
             content: {
                 'application/json': {
                     /** @enum {string} */
-                    queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+                    queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
                 };
             };
         };
@@ -9871,7 +9897,7 @@ export interface operations {
             content: {
                 'application/json': {
                     /** @enum {string} */
-                    queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+                    queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
                 };
             };
         };
@@ -9884,7 +9910,7 @@ export interface operations {
                 content: {
                     'application/json': {
                         /** @enum {string} */
-                        name: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+                        name: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
                         qualifiedName: string;
                         counts: {
                             [key: string]: number;
@@ -9974,7 +10000,7 @@ export interface operations {
                 content: {
                     'application/json': {
                         /** @enum {string} */
-                        name: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+                        name: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
                         counts: {
                             [key: string]: number;
                         };
@@ -10038,7 +10064,7 @@ export interface operations {
             content: {
                 'application/json': {
                     /** @enum {string} */
-                    queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+                    queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
                     jobId: string;
                 };
             };
@@ -10102,7 +10128,7 @@ export interface operations {
             content: {
                 'application/json': {
                     /** @enum {string} */
-                    queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+                    queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
                     jobId: string;
                 };
             };
@@ -10166,7 +10192,7 @@ export interface operations {
             content: {
                 'application/json': {
                     /** @enum {string} */
-                    queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+                    queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
                     jobId: string;
                 };
             };
@@ -10233,7 +10259,7 @@ export interface operations {
             content: {
                 'application/json': {
                     /** @enum {string} */
-                    queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+                    queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
                     jobId: string;
                 };
             };
@@ -11653,6 +11679,24 @@ export interface operations {
                                 /** Format: misskey:id */
                                 userListId: string;
                             };
+                            scheduledNotePosted?: {
+                                /** @enum {string} */
+                                type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
+                            } | {
+                                /** @enum {string} */
+                                type: 'list';
+                                /** Format: misskey:id */
+                                userListId: string;
+                            };
+                            scheduledNotePostFailed?: {
+                                /** @enum {string} */
+                                type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
+                            } | {
+                                /** @enum {string} */
+                                type: 'list';
+                                /** Format: misskey:id */
+                                userListId: string;
+                            };
                             receiveFollowRequest?: {
                                 /** @enum {string} */
                                 type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
@@ -25954,8 +25998,8 @@ export interface operations {
                     untilDate?: number;
                     /** @default true */
                     markAsRead?: boolean;
-                    includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
-                    excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
+                    includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
+                    excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
                 };
             };
         };
@@ -26039,8 +26083,8 @@ export interface operations {
                     untilDate?: number;
                     /** @default true */
                     markAsRead?: boolean;
-                    includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
-                    excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
+                    includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
+                    excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
                 };
             };
         };
@@ -27314,6 +27358,24 @@ export interface operations {
                             /** Format: misskey:id */
                             userListId: string;
                         };
+                        scheduledNotePosted?: {
+                            /** @enum {string} */
+                            type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
+                        } | {
+                            /** @enum {string} */
+                            type: 'list';
+                            /** Format: misskey:id */
+                            userListId: string;
+                        };
+                        scheduledNotePostFailed?: {
+                            /** @enum {string} */
+                            type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
+                        } | {
+                            /** @enum {string} */
+                            type: 'list';
+                            /** Format: misskey:id */
+                            userListId: string;
+                        };
                         receiveFollowRequest?: {
                             /** @enum {string} */
                             type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
@@ -29143,31 +29205,34 @@ export interface operations {
                      * @default public
                      * @enum {string}
                      */
-                    visibility?: 'public' | 'home' | 'followers' | 'specified';
-                    visibleUserIds?: string[];
-                    cw?: string | null;
-                    hashtag?: string | null;
+                    visibility: 'public' | 'home' | 'followers' | 'specified';
+                    visibleUserIds: string[];
+                    cw: string | null;
+                    hashtag: string | null;
                     /** @default false */
-                    localOnly?: boolean;
+                    localOnly: boolean;
                     /**
                      * @default null
                      * @enum {string|null}
                      */
-                    reactionAcceptance?: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote';
+                    reactionAcceptance: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote';
                     /** Format: misskey:id */
-                    replyId?: string | null;
+                    replyId: string | null;
                     /** Format: misskey:id */
-                    renoteId?: string | null;
+                    renoteId: string | null;
                     /** Format: misskey:id */
-                    channelId?: string | null;
-                    text?: string | null;
-                    fileIds?: string[];
-                    poll?: {
+                    channelId: string | null;
+                    text: string | null;
+                    fileIds: string[];
+                    poll: {
                         choices: string[];
                         multiple?: boolean;
                         expiresAt?: number | null;
                         expiredAfter?: number | null;
                     } | null;
+                    scheduledAt: number | null;
+                    /** @default false */
+                    isActuallyScheduled: boolean;
                 };
             };
         };
@@ -29314,6 +29379,7 @@ export interface operations {
                     untilId?: string;
                     sinceDate?: number;
                     untilDate?: number;
+                    scheduled?: boolean | null;
                 };
             };
         };
@@ -29380,20 +29446,13 @@ export interface operations {
                 'application/json': {
                     /** Format: misskey:id */
                     draftId: string;
-                    /**
-                     * @default public
-                     * @enum {string}
-                     */
+                    /** @enum {string} */
                     visibility?: 'public' | 'home' | 'followers' | 'specified';
                     visibleUserIds?: string[];
                     cw?: string | null;
                     hashtag?: string | null;
-                    /** @default false */
                     localOnly?: boolean;
-                    /**
-                     * @default null
-                     * @enum {string|null}
-                     */
+                    /** @enum {string|null} */
                     reactionAcceptance?: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote';
                     /** Format: misskey:id */
                     replyId?: string | null;
@@ -29409,6 +29468,8 @@ export interface operations {
                         expiresAt?: number | null;
                         expiredAfter?: number | null;
                     } | null;
+                    scheduledAt?: number | null;
+                    isActuallyScheduled?: boolean;
                 };
             };
         };
diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts
index 9afd1f8be6..148c5ed831 100644
--- a/packages/misskey-js/src/consts.ts
+++ b/packages/misskey-js/src/consts.ts
@@ -26,6 +26,8 @@ export const notificationTypes = [
 	'quote',
 	'reaction',
 	'pollEnded',
+	'scheduledNotePosted',
+	'scheduledNotePostFailed',
 	'receiveFollowRequest',
 	'followRequestAccepted',
 	'groupInvited',
@@ -227,12 +229,14 @@ export const rolePolicies = [
 	'chatAvailability',
 	'uploadableFileTypes',
 	'noteDraftLimit',
+	'scheduledNoteLimit',
 	'watermarkAvailable',
 ] as const;
 
 export const queueTypes = [
 	'system',
 	'endedPollNotification',
+	'postScheduledNote',
 	'deliver',
 	'inbox',
 	'db',
-- 
cgit v1.3.1-freya


From 0b642cf44626f6b0265fccce73427280fe7a5288 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Tue, 7 Oct 2025 09:47:26 +0900
Subject: chore(frontend): add caption for maxFileSize

---
 locales/index.d.ts                                 | 4 ++++
 locales/ja-JP.yml                                  | 1 +
 packages/frontend/src/pages/admin/roles.editor.vue | 3 +++
 packages/frontend/src/pages/admin/roles.vue        | 3 +++
 4 files changed, 11 insertions(+)

(limited to 'packages/frontend/src/pages/admin/roles.editor.vue')

diff --git a/locales/index.d.ts b/locales/index.d.ts
index 7dbc83f77b..6f36cba861 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -7869,6 +7869,10 @@ export interface Locale extends ILocale {
              * アップロード可能な最大ファイルサイズ
              */
             "maxFileSize": string;
+            /**
+             * リバースプロキシやCDNなど、前段で別の設定値が存在する場合があります。
+             */
+            "maxFileSize_caption": string;
             /**
              * ファイルにNSFWを常に付与
              */
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 2d0349b33e..ec27da085f 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2040,6 +2040,7 @@ _role:
     canManageAvatarDecorations: "アバターデコレーションの管理"
     driveCapacity: "ドライブ容量"
     maxFileSize: "アップロード可能な最大ファイルサイズ"
+    maxFileSize_caption: "リバースプロキシやCDNなど、前段で別の設定値が存在する場合があります。"
     alwaysMarkNsfw: "ファイルにNSFWを常に付与"
     canUpdateBioMedia: "アイコンとバナーの更新を許可"
     pinMax: "ノートのピン留めの最大数"
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 03df01a930..5f8950f07e 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -420,6 +420,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 					</MkSwitch>
 					<MkInput v-model="role.policies.maxFileSizeMb.value" :disabled="role.policies.maxFileSizeMb.useDefault" type="number" :readonly="readonly">
 						<template #suffix>MB</template>
+						<template #caption>
+							<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._role._options.maxFileSize_caption }}</div>
+						</template>
 					</MkInput>
 					<MkRange v-model="role.policies.maxFileSizeMb.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
 						<template #label>{{ i18n.ts._role.priority }}</template>
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index eca0284be3..e65a3c5ba8 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -155,6 +155,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 						<template #suffix>{{ policies.maxFileSizeMb }}MB</template>
 						<MkInput v-model="policies.maxFileSizeMb" type="number">
 							<template #suffix>MB</template>
+							<template #caption>
+								<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._role._options.maxFileSize_caption }}</div>
+							</template>
 						</MkInput>
 					</MkFolder>
 
-- 
cgit v1.3.1-freya

