From 385969e9f56a39a1e5547b94901d155e1e811263 Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Fri, 12 Jul 2024 16:25:44 +0900
Subject: fix(frontend): フォーカスの挙動を修正 (#14158)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* fix(frontend): 直前のパターンを記録するように

* fix(frontend): フォーカス/タブ移動に関する挙動を調整 (#226)

Cherry-pick commit e8c030673326871edf3623cf2b8675d68f9e1b13

Co-authored-by: taiyme <53635909+taiyme@users.noreply.github.com>

* focusのデザイン修正

* move scripts

* Modalにfocus trapを追加

* 記録するホットキーはレートリミット式にする

* escキーのハンドリングをMkModalに統一

* fix

* enterで子メニューを開けるように

* lint

* fix focus trap

* improve switch accessibility

* 一部のmodalのフォーカストラップが外れない問題を修正

* fix

* fix

* Revert "記録するホットキーはレートリミット式にする"

This reverts commit 40a7509286a87911ad4cc06d9482e8a2e5d0e7e8.

* Revert "fix(frontend): 直前のパターンを記録するように"

This reverts commit 5372b2594023952cff34aa62253ed4efef15b5dd.

* Revert "Revert "fix(frontend): 直前のパターンを記録するように""

This reverts commit a9bb52e799e110927ad92cd8f26af980819334e1.

* Revert "Revert "記録するホットキーはレートリミット式にする""

This reverts commit bdac34273e0bc5f13604c7e2f9fa6b1321a0df3d.

* 試験的にCypressでのFocustrapを無効化

* fix

* fix focus-trap

* Update Changelog

* :v:

* fix focustrap invocation logic

* スクロールがsticky headerを考慮するように

* :art:

* スタイルの微調整

* :art:

* remove deprecated key aliases

* focusElementが足りなかったので修正

* preview系にfocus時スタイルが足りなかったので修正

* `returnFocusElement` -> `returnFocusTo`

* lint

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

* Apply suggestions from code review

Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com>

* keydownイベントをまとめる

* use correct pesudo-element selector

* fix

* rename

---------

Co-authored-by: taiyme <53635909+taiyme@users.noreply.github.com>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
 packages/frontend/src/components/MkDialog.vue | 16 ++--------------
 1 file changed, 2 insertions(+), 14 deletions(-)

(limited to 'packages/frontend/src/components/MkDialog.vue')

diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue
index c52404a319..5c3c6aa51d 100644
--- a/packages/frontend/src/components/MkDialog.vue
+++ b/packages/frontend/src/components/MkDialog.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')">
+<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')" @esc="cancel()">
 	<div :class="$style.root">
 		<div v-if="icon" :class="$style.icon">
 			<i :class="icon"></i>
@@ -51,7 +51,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onBeforeUnmount, onMounted, ref, shallowRef, computed } from 'vue';
+import { ref, shallowRef, computed } from 'vue';
 import MkModal from '@/components/MkModal.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
@@ -156,10 +156,6 @@ function onBgClick() {
 	if (props.cancelableByBgClick) cancel();
 }
 */
-function onKeydown(evt: KeyboardEvent) {
-	if (evt.key === 'Escape') cancel();
-}
-
 function onInputKeydown(evt: KeyboardEvent) {
 	if (evt.key === 'Enter' && okButtonDisabledReason.value === null) {
 		evt.preventDefault();
@@ -167,14 +163,6 @@ function onInputKeydown(evt: KeyboardEvent) {
 		ok();
 	}
 }
-
-onMounted(() => {
-	document.addEventListener('keydown', onKeydown);
-});
-
-onBeforeUnmount(() => {
-	document.removeEventListener('keydown', onKeydown);
-});
 </script>
 
 <style lang="scss" module>
-- 
cgit v1.2.3-freya


From 738b3ea43b059b103deca0b1a33071ae256ef79f Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Tue, 30 Jul 2024 13:11:06 +0900
Subject: enhance(frontend):
 デッキのアンテナ・リスト選択画面からそれぞれを新規作成できるように (#14104)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* enhance(frontend): デッキのアンテナ・リスト選択画面からそれぞれを新規作成できるように

* Update Changelog

* fix

* fix

* lint

* add story

* typo

ねぼけていた

* Update antenna-column.vue

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
 CHANGELOG.md                                       |   1 +
 locales/index.d.ts                                 |  12 ++
 locales/ja-JP.yml                                  |   3 +
 .../src/components/MkAntennaEditor.stories.impl.ts |  62 ++++++++
 .../frontend/src/components/MkAntennaEditor.vue    | 175 +++++++++++++++++++++
 .../MkAntennaEditorDialog.stories.impl.ts          |  63 ++++++++
 .../src/components/MkAntennaEditorDialog.vue       |  63 ++++++++
 packages/frontend/src/components/MkDialog.vue      |  20 ++-
 packages/frontend/src/os.ts                        |  31 ++--
 packages/frontend/src/pages/my-antennas/create.vue |  32 ++--
 packages/frontend/src/pages/my-antennas/edit.vue   |  17 +-
 packages/frontend/src/pages/my-antennas/editor.vue | 148 -----------------
 packages/frontend/src/scripts/merge.ts             |   2 +-
 packages/frontend/src/ui/deck.vue                  |  29 ++--
 packages/frontend/src/ui/deck/antenna-column.vue   |  38 ++++-
 packages/frontend/src/ui/deck/channel-column.vue   |   3 +-
 packages/frontend/src/ui/deck/deck-store.ts        |  21 ++-
 packages/frontend/src/ui/deck/direct-column.vue    |   2 +-
 packages/frontend/src/ui/deck/list-column.vue      |  41 +++--
 packages/frontend/src/ui/deck/mentions-column.vue  |   2 +-
 .../frontend/src/ui/deck/notifications-column.vue  |   2 +-
 .../frontend/src/ui/deck/role-timeline-column.vue  |   4 +-
 packages/frontend/src/ui/deck/tl-column.vue        |   3 +-
 23 files changed, 535 insertions(+), 239 deletions(-)
 create mode 100644 packages/frontend/src/components/MkAntennaEditor.stories.impl.ts
 create mode 100644 packages/frontend/src/components/MkAntennaEditor.vue
 create mode 100644 packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts
 create mode 100644 packages/frontend/src/components/MkAntennaEditorDialog.vue
 delete mode 100644 packages/frontend/src/pages/my-antennas/editor.vue

(limited to 'packages/frontend/src/components/MkDialog.vue')

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8da94f3749..f3ea5ca2d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@
 - Enhance: AiScriptを0.19.0にアップデート
 - Enhance: Allow negative delay for MFM animation elements (`tada`, `jelly`, `twitch`, `shake`, `spin`, `jump`, `bounce`, `rainbow`)
 - Enhance: センシティブなメディアを開く際に確認ダイアログを出せるように
+- Enhance: デッキのアンテナ・リスト選択画面からそれぞれを新規作成できるように
 - Fix: `/about#federation` ページなどで各インスタンスのチャートが表示されなくなっていた問題を修正
 - Fix: ユーザーページの追加情報のラベルを投稿者のサーバーの絵文字で表示する (#13968)
 - Fix: リバーシの対局を正しく共有できないことがある問題を修正
diff --git a/locales/index.d.ts b/locales/index.d.ts
index d54b752312..848050583b 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -632,6 +632,10 @@ export interface Locale extends ILocale {
      * アンテナを編集
      */
     "editAntenna": string;
+    /**
+     * アンテナを作成
+     */
+    "createAntenna": string;
     /**
      * ウィジェットを選択
      */
@@ -5024,6 +5028,14 @@ export interface Locale extends ILocale {
      * センシティブなメディアです。表示しますか？
      */
     "sensitiveMediaRevealConfirm": string;
+    /**
+     * 作成したリスト
+     */
+    "createdLists": string;
+    /**
+     * 作成したアンテナ
+     */
+    "createdAntennas": string;
     "_delivery": {
         /**
          * 配信状態
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index d8531070fe..22228f9254 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -154,6 +154,7 @@ editList: "リストを編集"
 selectChannel: "チャンネルを選択"
 selectAntenna: "アンテナを選択"
 editAntenna: "アンテナを編集"
+createAntenna: "アンテナを作成"
 selectWidget: "ウィジェットを選択"
 editWidgets: "ウィジェットを編集"
 editWidgetsExit: "編集を終了"
@@ -1252,6 +1253,8 @@ inquiry: "お問い合わせ"
 tryAgain: "もう一度お試しください。"
 confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示するとき確認する"
 sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか？"
+createdLists: "作成したリスト"
+createdAntennas: "作成したアンテナ"
 
 _delivery:
   status: "配信状態"
diff --git a/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts b/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts
new file mode 100644
index 0000000000..1749e07a4e
--- /dev/null
+++ b/packages/frontend/src/components/MkAntennaEditor.stories.impl.ts
@@ -0,0 +1,62 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { action } from '@storybook/addon-actions';
+import { StoryObj } from '@storybook/vue3';
+import { HttpResponse, http } from 'msw';
+import { commonHandlers } from '../../.storybook/mocks.js';
+import MkAntennaEditor from './MkAntennaEditor.vue';
+export const Default = {
+	render(args) {
+		return {
+			components: {
+				MkAntennaEditor,
+			},
+			setup() {
+				return {
+					args,
+				};
+			},
+			computed: {
+				props() {
+					return {
+						...this.args,
+					};
+				},
+				events() {
+					return {
+						created: action('created'),
+						updated: action('updated'),
+						deleted: action('deleted'),
+					};
+				},
+			},
+			template: '<MkAntennaEditor v-bind="props" v-on="events" />',
+		};
+	},
+	args: {
+	},
+	parameters: {
+		layout: 'fullscreen',
+		msw: {
+			handlers: [
+				...commonHandlers,
+				http.post('/api/antennas/create', async ({ request }) => {
+					action('POST /api/antennas/create')(await request.json());
+					return HttpResponse.json({});
+				}),
+				http.post('/api/antennas/update', async ({ request }) => {
+					action('POST /api/antennas/update')(await request.json());
+					return HttpResponse.json({});
+				}),
+				http.post('/api/antennas/delete', async ({ request }) => {
+					action('POST /api/antennas/delete')(await request.json());
+					return HttpResponse.json();
+				}),
+			],
+		},
+	},
+} satisfies StoryObj<typeof MkAntennaEditor>;
diff --git a/packages/frontend/src/components/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue
new file mode 100644
index 0000000000..cb7ee3d6ca
--- /dev/null
+++ b/packages/frontend/src/components/MkAntennaEditor.vue
@@ -0,0 +1,175 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkSpacer :contentMax="700">
+	<div>
+		<div class="_gaps_m">
+			<MkInput v-model="name">
+				<template #label>{{ i18n.ts.name }}</template>
+			</MkInput>
+			<MkSelect v-model="src">
+				<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">
+				<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>
+				<template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template>
+			</MkTextarea>
+			<MkSwitch v-model="excludeBots">{{ i18n.ts.antennaExcludeBots }}</MkSwitch>
+			<MkSwitch v-model="withReplies">{{ i18n.ts.withReplies }}</MkSwitch>
+			<MkTextarea v-model="keywords">
+				<template #label>{{ i18n.ts.antennaKeywords }}</template>
+				<template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template>
+			</MkTextarea>
+			<MkTextarea v-model="excludeKeywords">
+				<template #label>{{ i18n.ts.antennaExcludeKeywords }}</template>
+				<template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template>
+			</MkTextarea>
+			<MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch>
+			<MkSwitch v-model="caseSensitive">{{ i18n.ts.caseSensitive }}</MkSwitch>
+			<MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch>
+		</div>
+		<div :class="$style.actions">
+			<div class="_buttons">
+				<MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+				<MkButton v-if="initialAntenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
+			</div>
+		</div>
+	</div>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { watch, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/MkInput.vue';
+import MkTextarea from '@/components/MkTextarea.vue';
+import MkSelect from '@/components/MkSelect.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import * as os from '@/os.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { i18n } from '@/i18n.js';
+import { deepMerge } from '@/scripts/merge.js';
+import type { DeepPartial } from '@/scripts/merge.js';
+
+type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & {
+	id?: string;
+	createdAt?: string;
+	updatedAt?: string;
+};
+
+const props = defineProps<{
+	antenna?: DeepPartial<PartialAllowedAntenna>;
+}>();
+
+const initialAntenna = deepMerge<PartialAllowedAntenna>(props.antenna ?? {}, {
+	name: '',
+	src: 'all',
+	userListId: null,
+	users: [],
+	keywords: [],
+	excludeKeywords: [],
+	excludeBots: false,
+	withReplies: false,
+	caseSensitive: false,
+	localOnly: false,
+	withFile: false,
+	isActive: true,
+	hasUnreadNote: false,
+	notify: false,
+});
+
+const emit = defineEmits<{
+	(ev: 'created', newAntenna: Misskey.entities.Antenna): void,
+	(ev: 'updated', editedAntenna: Misskey.entities.Antenna): void,
+	(ev: 'deleted'): void,
+}>();
+
+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'));
+const caseSensitive = ref<boolean>(initialAntenna.caseSensitive);
+const localOnly = ref<boolean>(initialAntenna.localOnly);
+const excludeBots = ref<boolean>(initialAntenna.excludeBots);
+const withReplies = ref<boolean>(initialAntenna.withReplies);
+const withFile = ref<boolean>(initialAntenna.withFile);
+const userLists = ref<Misskey.entities.UserList[] | null>(null);
+
+watch(() => src.value, async () => {
+	if (src.value === 'list' && userLists.value === null) {
+		userLists.value = await misskeyApi('users/lists/list');
+	}
+});
+
+async function saveAntenna() {
+	const antennaData = {
+		name: name.value,
+		src: src.value,
+		userListId: userListId.value,
+		excludeBots: excludeBots.value,
+		withReplies: withReplies.value,
+		withFile: withFile.value,
+		caseSensitive: caseSensitive.value,
+		localOnly: localOnly.value,
+		users: users.value.trim().split('\n').map(x => x.trim()),
+		keywords: keywords.value.trim().split('\n').map(x => x.trim().split(' ')),
+		excludeKeywords: excludeKeywords.value.trim().split('\n').map(x => x.trim().split(' ')),
+	};
+
+	if (initialAntenna.id == null) {
+		const res = await os.apiWithDialog('antennas/create', antennaData);
+		emit('created', res);
+	} else {
+		const res = await os.apiWithDialog('antennas/update', { ...antennaData, antennaId: initialAntenna.id });
+		emit('updated', res);
+	}
+}
+
+async function deleteAntenna() {
+	if (initialAntenna.id == null) return;
+
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		text: i18n.tsx.removeAreYouSure({ x: initialAntenna.name }),
+	});
+	if (canceled) return;
+
+	await misskeyApi('antennas/delete', {
+		antennaId: initialAntenna.id,
+	});
+
+	os.success();
+	emit('deleted');
+}
+
+function addUser() {
+	os.selectUser({ includeSelf: true }).then(user => {
+		users.value = users.value.trim();
+		users.value += '\n@' + Misskey.acct.toString(user as any);
+		users.value = users.value.trim();
+	});
+}
+</script>
+
+<style lang="scss" module>
+.actions {
+	margin-top: 16px;
+	padding: 24px 0;
+	border-top: solid 0.5px var(--divider);
+}
+</style>
diff --git a/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts b/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts
new file mode 100644
index 0000000000..1c6ca83b47
--- /dev/null
+++ b/packages/frontend/src/components/MkAntennaEditorDialog.stories.impl.ts
@@ -0,0 +1,63 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { action } from '@storybook/addon-actions';
+import { StoryObj } from '@storybook/vue3';
+import { HttpResponse, http } from 'msw';
+import { commonHandlers } from '../../.storybook/mocks.js';
+import MkAntennaEditorDialog from './MkAntennaEditorDialog.vue';
+export const Default = {
+	render(args) {
+		return {
+			components: {
+				MkAntennaEditorDialog,
+			},
+			setup() {
+				return {
+					args,
+				};
+			},
+			computed: {
+				props() {
+					return {
+						...this.args,
+					};
+				},
+				events() {
+					return {
+						created: action('created'),
+						updated: action('updated'),
+						deleted: action('deleted'),
+						closed: action('closed'),
+					};
+				},
+			},
+			template: '<MkAntennaEditorDialog v-bind="props" v-on="events" />',
+		};
+	},
+	args: {
+	},
+	parameters: {
+		layout: 'centered',
+		msw: {
+			handlers: [
+				...commonHandlers,
+				http.post('/api/antennas/create', async ({ request }) => {
+					action('POST /api/antennas/create')(await request.json());
+					return HttpResponse.json({});
+				}),
+				http.post('/api/antennas/update', async ({ request }) => {
+					action('POST /api/antennas/update')(await request.json());
+					return HttpResponse.json({});
+				}),
+				http.post('/api/antennas/delete', async ({ request }) => {
+					action('POST /api/antennas/delete')(await request.json());
+					return HttpResponse.json();
+				}),
+			],
+		},
+	},
+} satisfies StoryObj<typeof MkAntennaEditorDialog>;
diff --git a/packages/frontend/src/components/MkAntennaEditorDialog.vue b/packages/frontend/src/components/MkAntennaEditorDialog.vue
new file mode 100644
index 0000000000..6d815d29f3
--- /dev/null
+++ b/packages/frontend/src/components/MkAntennaEditorDialog.vue
@@ -0,0 +1,63 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkModalWindow
+	ref="dialog"
+	:withOkButton="false"
+	:width="500"
+	:height="550"
+	@close="close()"
+	@closed="emit('closed')"
+>
+	<template #header>{{ antenna == null ? i18n.ts.createAntenna : i18n.ts.editAntenna }}</template>
+	<XAntennaEditor
+		:antenna="antenna"
+		@created="onAntennaCreated"
+		@updated="onAntennaUpdated"
+		@deleted="onAntennaDeleted"
+	/>
+</MkModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { shallowRef } from 'vue';
+import * as Misskey from 'misskey-js';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import XAntennaEditor from '@/components/MkAntennaEditor.vue';
+import { i18n } from '@/i18n.js';
+
+defineProps<{
+	antenna?: Misskey.entities.Antenna;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'created', newAntenna: Misskey.entities.Antenna): void,
+	(ev: 'updated', editedAntenna: Misskey.entities.Antenna): void,
+	(ev: 'deleted'): void,
+	(ev: 'closed'): void,
+}>();
+
+const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+
+function onAntennaCreated(newAntenna: Misskey.entities.Antenna) {
+	emit('created', newAntenna);
+	dialog.value?.close();
+}
+
+function onAntennaUpdated(editedAntenna: Misskey.entities.Antenna) {
+	emit('updated', editedAntenna);
+	dialog.value?.close();
+}
+
+function onAntennaDeleted() {
+	emit('deleted');
+	dialog.value?.close();
+}
+
+function close() {
+	dialog.value?.close();
+}
+</script>
diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue
index 5c3c6aa51d..16cf5b1b75 100644
--- a/packages/frontend/src/components/MkDialog.vue
+++ b/packages/frontend/src/components/MkDialog.vue
@@ -36,7 +36,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</MkInput>
 		<MkSelect v-if="select" v-model="selectedValue" autofocus>
 			<template v-if="select.items">
-				<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
+				<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>
 		<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
@@ -67,11 +72,16 @@ type Input = {
 	maxLength?: number;
 };
 
+type SelectItem = {
+	value: any;
+	text: string;
+};
+
 type Select = {
-	items: {
-		value: any;
-		text: string;
-	}[];
+	items: (SelectItem | {
+		sectionTitle: string;
+		items: SelectItem[];
+	})[];
 	default: string | null;
 };
 
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index 3085f33e21..a8dd99c854 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -447,15 +447,20 @@ export function authenticateDialog(): Promise<{
 	});
 }
 
+type SelectItem<C> = {
+	value: C;
+	text: string;
+};
+
 // default が指定されていたら result は null になり得ないことを保証する overload function
 export function select<C = any>(props: {
 	title?: string;
 	text?: string;
 	default: string;
-	items: {
-		value: C;
-		text: string;
-	}[];
+	items: (SelectItem<C> | {
+		sectionTitle: string;
+		items: SelectItem<C>[];
+	} | undefined)[];
 }): Promise<{
 	canceled: true; result: undefined;
 } | {
@@ -465,10 +470,10 @@ export function select<C = any>(props: {
 	title?: string;
 	text?: string;
 	default?: string | null;
-	items: {
-		value: C;
-		text: string;
-	}[];
+	items: (SelectItem<C> | {
+		sectionTitle: string;
+		items: SelectItem<C>[];
+	} | undefined)[];
 }): Promise<{
 	canceled: true; result: undefined;
 } | {
@@ -478,10 +483,10 @@ export function select<C = any>(props: {
 	title?: string;
 	text?: string;
 	default?: string | null;
-	items: {
-		value: C;
-		text: string;
-	}[];
+	items: (SelectItem<C> | {
+		sectionTitle: string;
+		items: SelectItem<C>[];
+	} | undefined)[];
 }): Promise<{
 	canceled: true; result: undefined;
 } | {
@@ -492,7 +497,7 @@ export function select<C = any>(props: {
 			title: props.title,
 			text: props.text,
 			select: {
-				items: props.items,
+				items: props.items.filter(x => x !== undefined),
 				default: props.default ?? null,
 			},
 		}, {
diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue
index 2d026d2fa9..2b8518747f 100644
--- a/packages/frontend/src/pages/my-antennas/create.vue
+++ b/packages/frontend/src/pages/my-antennas/create.vue
@@ -4,43 +4,33 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div>
-	<XAntenna :antenna="draft" @created="onAntennaCreated"/>
-</div>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+
+	<MkAntennaEditor @created="onAntennaCreated"/>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
-import { ref } from 'vue';
-import XAntenna from './editor.vue';
+import { computed } from 'vue';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
 import { antennasCache } from '@/cache.js';
 import { useRouter } from '@/router/supplier.js';
+import MkAntennaEditor from '@/components/MkAntennaEditor.vue';
 
 const router = useRouter();
 
-const draft = ref({
-	name: '',
-	src: 'all',
-	userListId: null,
-	users: [],
-	keywords: [],
-	excludeKeywords: [],
-	excludeBots: false,
-	withReplies: false,
-	caseSensitive: false,
-	localOnly: false,
-	withFile: false,
-	notify: false,
-});
-
 function onAntennaCreated() {
 	antennasCache.delete();
 	router.push('/my/antennas');
 }
 
+const headerActions = computed(() => []);
+const headerTabs = computed(() => []);
+
 definePageMetadata(() => ({
-	title: i18n.ts.manageAntennas,
+	title: i18n.ts.createAntenna,
 	icon: 'ti ti-antenna',
 }));
 </script>
diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue
index 9471be8575..9f927cd1a0 100644
--- a/packages/frontend/src/pages/my-antennas/edit.vue
+++ b/packages/frontend/src/pages/my-antennas/edit.vue
@@ -4,15 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div class="">
-	<XAntenna v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/>
-</div>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+
+	<MkAntennaEditor v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
-import { ref } from 'vue';
+import { ref, computed } from 'vue';
 import * as Misskey from 'misskey-js';
-import XAntenna from './editor.vue';
+import MkAntennaEditor from '@/components/MkAntennaEditor.vue';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { definePageMetadata } from '@/scripts/page-metadata.js';
@@ -36,8 +38,11 @@ misskeyApi('antennas/show', { antennaId: props.antennaId }).then((antennaRespons
 	antenna.value = antennaResponse;
 });
 
+const headerActions = computed(() => []);
+const headerTabs = computed(() => []);
+
 definePageMetadata(() => ({
-	title: i18n.ts.manageAntennas,
+	title: i18n.ts.editAntenna,
 	icon: 'ti ti-antenna',
 }));
 </script>
diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue
deleted file mode 100644
index 02e8f98265..0000000000
--- a/packages/frontend/src/pages/my-antennas/editor.vue
+++ /dev/null
@@ -1,148 +0,0 @@
-<!--
-SPDX-FileCopyrightText: syuilo and misskey-project
-SPDX-License-Identifier: AGPL-3.0-only
--->
-
-<template>
-<MkSpacer :contentMax="700">
-	<div>
-		<div class="_gaps_m">
-			<MkInput v-model="name">
-				<template #label>{{ i18n.ts.name }}</template>
-			</MkInput>
-			<MkSelect v-model="src">
-				<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">
-				<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>
-				<template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template>
-			</MkTextarea>
-			<MkSwitch v-model="excludeBots">{{ i18n.ts.antennaExcludeBots }}</MkSwitch>
-			<MkSwitch v-model="withReplies">{{ i18n.ts.withReplies }}</MkSwitch>
-			<MkTextarea v-model="keywords">
-				<template #label>{{ i18n.ts.antennaKeywords }}</template>
-				<template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template>
-			</MkTextarea>
-			<MkTextarea v-model="excludeKeywords">
-				<template #label>{{ i18n.ts.antennaExcludeKeywords }}</template>
-				<template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template>
-			</MkTextarea>
-			<MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch>
-			<MkSwitch v-model="caseSensitive">{{ i18n.ts.caseSensitive }}</MkSwitch>
-			<MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch>
-		</div>
-		<div :class="$style.actions">
-			<div class="_buttons">
-				<MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
-				<MkButton v-if="antenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
-			</div>
-		</div>
-	</div>
-</MkSpacer>
-</template>
-
-<script lang="ts" setup>
-import { watch, ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import MkButton from '@/components/MkButton.vue';
-import MkInput from '@/components/MkInput.vue';
-import MkTextarea from '@/components/MkTextarea.vue';
-import MkSelect from '@/components/MkSelect.vue';
-import MkSwitch from '@/components/MkSwitch.vue';
-import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
-import { i18n } from '@/i18n.js';
-
-const props = defineProps<{
-	antenna: Misskey.entities.Antenna
-}>();
-
-const emit = defineEmits<{
-	(ev: 'created'): void,
-	(ev: 'updated'): void,
-	(ev: 'deleted'): void,
-}>();
-
-const name = ref<string>(props.antenna.name);
-const src = ref<Misskey.entities.AntennasCreateRequest['src']>(props.antenna.src);
-const userListId = ref<string | null>(props.antenna.userListId);
-const users = ref<string>(props.antenna.users.join('\n'));
-const keywords = ref<string>(props.antenna.keywords.map(x => x.join(' ')).join('\n'));
-const excludeKeywords = ref<string>(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n'));
-const caseSensitive = ref<boolean>(props.antenna.caseSensitive);
-const localOnly = ref<boolean>(props.antenna.localOnly);
-const excludeBots = ref<boolean>(props.antenna.excludeBots);
-const withReplies = ref<boolean>(props.antenna.withReplies);
-const withFile = ref<boolean>(props.antenna.withFile);
-const userLists = ref<Misskey.entities.UserList[] | null>(null);
-
-watch(() => src.value, async () => {
-	if (src.value === 'list' && userLists.value === null) {
-		userLists.value = await misskeyApi('users/lists/list');
-	}
-});
-
-async function saveAntenna() {
-	const antennaData = {
-		name: name.value,
-		src: src.value,
-		userListId: userListId.value,
-		excludeBots: excludeBots.value,
-		withReplies: withReplies.value,
-		withFile: withFile.value,
-		caseSensitive: caseSensitive.value,
-		localOnly: localOnly.value,
-		users: users.value.trim().split('\n').map(x => x.trim()),
-		keywords: keywords.value.trim().split('\n').map(x => x.trim().split(' ')),
-		excludeKeywords: excludeKeywords.value.trim().split('\n').map(x => x.trim().split(' ')),
-	};
-
-	if (props.antenna.id == null) {
-		await os.apiWithDialog('antennas/create', antennaData);
-		emit('created');
-	} else {
-		await os.apiWithDialog('antennas/update', { ...antennaData, antennaId: props.antenna.id });
-		emit('updated');
-	}
-}
-
-async function deleteAntenna() {
-	const { canceled } = await os.confirm({
-		type: 'warning',
-		text: i18n.tsx.removeAreYouSure({ x: props.antenna.name }),
-	});
-	if (canceled) return;
-
-	await misskeyApi('antennas/delete', {
-		antennaId: props.antenna.id,
-	});
-
-	os.success();
-	emit('deleted');
-}
-
-function addUser() {
-	os.selectUser({ includeSelf: true }).then(user => {
-		users.value = users.value.trim();
-		users.value += '\n@' + Misskey.acct.toString(user as any);
-		users.value = users.value.trim();
-	});
-}
-</script>
-
-<style lang="scss" module>
-.actions {
-	margin-top: 16px;
-	padding: 24px 0;
-	border-top: solid 0.5px var(--divider);
-}
-</style>
diff --git a/packages/frontend/src/scripts/merge.ts b/packages/frontend/src/scripts/merge.ts
index 4e39a0fa06..9794a300da 100644
--- a/packages/frontend/src/scripts/merge.ts
+++ b/packages/frontend/src/scripts/merge.ts
@@ -6,7 +6,7 @@
 import { deepClone } from './clone.js';
 import type { Cloneable } from './clone.js';
 
-type DeepPartial<T> = {
+export type DeepPartial<T> = {
 	[P in keyof T]?: T[P] extends Record<string | number | symbol, unknown> ? DeepPartial<T[P]> : T[P];
 };
 
diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
index bdb62dca15..af46b0641d 100644
--- a/packages/frontend/src/ui/deck.vue
+++ b/packages/frontend/src/ui/deck.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					:ref="id"
 					:key="id"
 					:class="$style.column"
-					:column="columns.find(c => c.id === id)"
+					:column="columns.find(c => c.id === id)!"
 					:isStacked="ids.length > 1"
 					@headerWheel="onWheel"
 				/>
@@ -95,7 +95,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, defineAsyncComponent, ref, watch, shallowRef } from 'vue';
 import { v4 as uuid } from 'uuid';
 import XCommon from './_common_/common.vue';
-import { deckStore, addColumn as addColumnToStore, loadDeck, getProfiles, deleteProfile as deleteProfile_ } from './deck/deck-store.js';
+import { deckStore, columnTypes, addColumn as addColumnToStore, loadDeck, getProfiles, deleteProfile as deleteProfile_ } from './deck/deck-store.js';
+import type { ColumnType } from './deck/deck-store.js';
 import XSidebar from '@/ui/_common_/navbar.vue';
 import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
 import MkButton from '@/components/MkButton.vue';
@@ -152,10 +153,12 @@ window.addEventListener('resize', () => {
 const snapScroll = deviceKind === 'smartphone' || deviceKind === 'tablet';
 const drawerMenuShowing = ref(false);
 
+/*
 const route = 'TODO';
 watch(route, () => {
 	drawerMenuShowing.value = false;
 });
+*/
 
 const columns = deckStore.reactiveState.columns;
 const layout = deckStore.reactiveState.layout;
@@ -174,32 +177,20 @@ function showSettings() {
 const columnsEl = shallowRef<HTMLElement>();
 
 const addColumn = async (ev) => {
-	const columns = [
-		'main',
-		'widgets',
-		'notifications',
-		'tl',
-		'antenna',
-		'list',
-		'channel',
-		'mentions',
-		'direct',
-		'roleTimeline',
-	];
-
 	const { canceled, result: column } = await os.select({
 		title: i18n.ts._deck.addColumn,
-		items: columns.map(column => ({
+		items: columnTypes.map(column => ({
 			value: column, text: i18n.ts._deck._columns[column],
 		})),
 	});
-	if (canceled) return;
+	if (canceled || column == null) return;
 
 	addColumnToStore({
 		type: column,
 		id: uuid(),
 		name: i18n.ts._deck._columns[column],
 		width: 330,
+		soundSetting: { type: null, volume: 1 },
 	});
 };
 
@@ -211,7 +202,7 @@ const onContextmenu = (ev) => {
 };
 
 function onWheel(ev: WheelEvent) {
-	if (ev.deltaX === 0) {
+	if (ev.deltaX === 0 && columnsEl.value != null) {
 		columnsEl.value.scrollLeft += ev.deltaY;
 	}
 }
@@ -242,7 +233,7 @@ function changeProfile(ev: MouseEvent) {
 					title: i18n.ts._deck.profile,
 					minLength: 1,
 				});
-				if (canceled) return;
+				if (canceled || name == null) return;
 
 				deckStore.set('profile', name);
 				unisonReload();
diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue
index c3dc1e4fce..987bd4db55 100644
--- a/packages/frontend/src/ui/deck/antenna-column.vue
+++ b/packages/frontend/src/ui/deck/antenna-column.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="() => timeline.reloadTimeline()">
+<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
 	<template #header>
 		<i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name }}</span>
 	</template>
@@ -14,7 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, ref, shallowRef, watch } from 'vue';
+import { onMounted, ref, shallowRef, watch, defineAsyncComponent } from 'vue';
+import type { entities as MisskeyEntities } from 'misskey-js';
 import XColumn from './column.vue';
 import { updateColumn, Column } from './deck-store.js';
 import MkTimeline from '@/components/MkTimeline.vue';
@@ -22,6 +23,7 @@ import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { MenuItem } from '@/types/menu.js';
+import { antennasCache } from '@/cache.js';
 import { SoundStore } from '@/store.js';
 import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
 import * as sound from '@/scripts/sound.js';
@@ -46,14 +48,36 @@ watch(soundSetting, v => {
 
 async function setAntenna() {
 	const antennas = await misskeyApi('antennas/list');
-	const { canceled, result: antenna } = await os.select({
+	const { canceled, result: antenna } = await os.select<MisskeyEntities.Antenna | '_CREATE_'>({
 		title: i18n.ts.selectAntenna,
-		items: antennas.map(x => ({
-			value: x, text: x.name,
-		})),
+		items: [
+			{ value: '_CREATE_', text: i18n.ts.createNew },
+			(antennas.length > 0 ? {
+				sectionTitle: i18n.ts.createdAntennas,
+				items: antennas.map(x => ({
+					value: x, text: x.name,
+				})),
+			} : undefined),
+		],
 		default: props.column.antennaId,
 	});
-	if (canceled) return;
+	if (canceled || antenna == null) return;
+
+	if (antenna === '_CREATE_') {
+		const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAntennaEditorDialog.vue')), {}, {
+			created: (newAntenna: MisskeyEntities.Antenna) => {
+				antennasCache.delete();
+				updateColumn(props.column.id, {
+					antennaId: newAntenna.id,
+				});
+			},
+			closed: () => {
+				dispose();
+			},
+		});
+		return;
+	}
+
 	updateColumn(props.column.id, {
 		antennaId: antenna.id,
 	});
diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue
index 7c5b13eaf1..42c07056e7 100644
--- a/packages/frontend/src/ui/deck/channel-column.vue
+++ b/packages/frontend/src/ui/deck/channel-column.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="() => timeline.reloadTimeline()">
+<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
 	<template #header>
 		<i class="ti ti-device-tv"></i><span style="margin-left: 8px;">{{ column.name }}</span>
 	</template>
@@ -68,6 +68,7 @@ async function setChannel() {
 }
 
 async function post() {
+	if (props.column.channelId == null) return;
 	if (!channel.value || channel.value.id !== props.column.channelId) {
 		channel.value = await misskeyApi('channels/show', {
 			channelId: props.column.channelId,
diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts
index bb3c04cd5c..139621cf57 100644
--- a/packages/frontend/src/ui/deck/deck-store.ts
+++ b/packages/frontend/src/ui/deck/deck-store.ts
@@ -17,9 +17,24 @@ type ColumnWidget = {
 	data: Record<string, any>;
 };
 
+export const columnTypes = [
+	'main',
+	'widgets',
+	'notifications',
+	'tl',
+	'antenna',
+	'list',
+	'channel',
+	'mentions',
+	'direct',
+	'roleTimeline',
+] as const;
+
+export type ColumnType = typeof columnTypes[number];
+
 export type Column = {
 	id: string;
-	type: 'main' | 'widgets' | 'notifications' | 'tl' | 'antenna' | 'channel' | 'list' | 'mentions' | 'direct';
+	type: ColumnType;
 	name: string | null;
 	width: number;
 	widgets?: ColumnWidget[];
@@ -265,7 +280,7 @@ export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) {
 	const columns = deepClone(deckStore.state.columns);
 	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
 	const column = deepClone(deckStore.state.columns[columnIndex]);
-	if (column == null) return;
+	if (column == null || column.widgets == null) return;
 	column.widgets = column.widgets.filter(w => w.id !== widget.id);
 	columns[columnIndex] = column;
 	deckStore.set('columns', columns);
@@ -287,7 +302,7 @@ export function updateColumnWidget(id: Column['id'], widgetId: string, widgetDat
 	const columns = deepClone(deckStore.state.columns);
 	const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
 	const column = deepClone(deckStore.state.columns[columnIndex]);
-	if (column == null) return;
+	if (column == null || column.widgets == null) return;
 	column.widgets = column.widgets.map(w => w.id === widgetId ? {
 		...w,
 		data: widgetData,
diff --git a/packages/frontend/src/ui/deck/direct-column.vue b/packages/frontend/src/ui/deck/direct-column.vue
index e011de0e3b..d12a18f760 100644
--- a/packages/frontend/src/ui/deck/direct-column.vue
+++ b/packages/frontend/src/ui/deck/direct-column.vue
@@ -34,7 +34,7 @@ const tlComponent = ref<InstanceType<typeof MkNotes>>();
 
 function reloadTimeline() {
 	return new Promise<void>((res) => {
-		tlComponent.value.pagingComponent?.reload().then(() => {
+		tlComponent.value?.pagingComponent?.reload().then(() => {
 			res();
 		});
 	});
diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue
index 5369112494..9aa8f06476 100644
--- a/packages/frontend/src/ui/deck/list-column.vue
+++ b/packages/frontend/src/ui/deck/list-column.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="() => timeline.reloadTimeline()">
+<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
 	<template #header>
 		<i class="ti ti-list"></i><span style="margin-left: 8px;">{{ column.name }}</span>
 	</template>
@@ -15,6 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { watch, shallowRef, ref } from 'vue';
+import type { entities as MisskeyEntities } from 'misskey-js';
 import XColumn from './column.vue';
 import { updateColumn, Column } from './deck-store.js';
 import MkTimeline from '@/components/MkTimeline.vue';
@@ -23,6 +24,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
 import { MenuItem } from '@/types/menu.js';
 import { SoundStore } from '@/store.js';
+import { userListsCache } from '@/cache.js';
 import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
 import * as sound from '@/scripts/sound.js';
 
@@ -51,17 +53,38 @@ watch(soundSetting, v => {
 
 async function setList() {
 	const lists = await misskeyApi('users/lists/list');
-	const { canceled, result: list } = await os.select({
+	const { canceled, result: list } = await os.select<MisskeyEntities.UserList | '_CREATE_'>({
 		title: i18n.ts.selectList,
-		items: lists.map(x => ({
-			value: x, text: x.name,
-		})),
+		items: [
+			{ value: '_CREATE_', text: i18n.ts.createNew },
+			(lists.length > 0 ? {
+				sectionTitle: i18n.ts.createdLists,
+				items: lists.map(x => ({
+					value: x, text: x.name,
+				})),
+			} : undefined),
+		],
 		default: props.column.listId,
 	});
-	if (canceled) return;
-	updateColumn(props.column.id, {
-		listId: list.id,
-	});
+	if (canceled || list == null) return;
+
+	if (list === '_CREATE_') {
+		const { canceled, result: name } = await os.inputText({
+			title: i18n.ts.enterListName,
+		});
+		if (canceled || name == null || name === '') return;
+
+		const res = await os.apiWithDialog('users/lists/create', { name: name });
+		userListsCache.delete();
+
+		updateColumn(props.column.id, {
+			listId: res.id,
+		});
+	} else {
+		updateColumn(props.column.id, {
+			listId: list.id,
+		});
+	}
 }
 
 function editList() {
diff --git a/packages/frontend/src/ui/deck/mentions-column.vue b/packages/frontend/src/ui/deck/mentions-column.vue
index 81926dd7cb..7b25a55ec3 100644
--- a/packages/frontend/src/ui/deck/mentions-column.vue
+++ b/packages/frontend/src/ui/deck/mentions-column.vue
@@ -26,7 +26,7 @@ const tlComponent = ref<InstanceType<typeof MkNotes>>();
 
 function reloadTimeline() {
 	return new Promise<void>((res) => {
-		tlComponent.value.pagingComponent?.reload().then(() => {
+		tlComponent.value?.pagingComponent?.reload().then(() => {
 			res();
 		});
 	});
diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue
index 23b0fd4f7b..19ccfc1f7c 100644
--- a/packages/frontend/src/ui/deck/notifications-column.vue
+++ b/packages/frontend/src/ui/deck/notifications-column.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<XColumn :column="column" :isStacked="isStacked" :menu="menu" :refresher="() => notificationsComponent.reload()">
+<XColumn :column="column" :isStacked="isStacked" :menu="menu" :refresher="async () => { await notificationsComponent?.reload() }">
 	<template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name }}</template>
 
 	<XNotifications ref="notificationsComponent" :excludeTypes="props.column.excludeTypes"/>
diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue
index 32ab7527b4..a375e9c574 100644
--- a/packages/frontend/src/ui/deck/role-timeline-column.vue
+++ b/packages/frontend/src/ui/deck/role-timeline-column.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="() => timeline.reloadTimeline()">
+<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
 	<template #header>
 		<i class="ti ti-badge"></i><span style="margin-left: 8px;">{{ column.name }}</span>
 	</template>
@@ -53,7 +53,7 @@ async function setRole() {
 		})),
 		default: props.column.roleId,
 	});
-	if (canceled) return;
+	if (canceled || role == null) return;
 	updateColumn(props.column.id, {
 		roleId: role.id,
 	});
diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue
index a967335edf..b4bc8bb748 100644
--- a/packages/frontend/src/ui/deck/tl-column.vue
+++ b/packages/frontend/src/ui/deck/tl-column.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="() => timeline.reloadTimeline()">
+<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
 	<template #header>
 		<i v-if="column.tl === 'home'" class="ti ti-home"></i>
 		<i v-else-if="column.tl === 'local'" class="ti ti-planet"></i>
@@ -113,6 +113,7 @@ async function setType() {
 		}
 		return;
 	}
+	if (src == null) return;
 	updateColumn(props.column.id, {
 		tl: src,
 	});
-- 
cgit v1.2.3-freya

