From caab1ec7c3fbd6b8924a23afb1d934838b28e8ad Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Wed, 12 Mar 2025 13:04:41 +0900
Subject: 🎨
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 packages/frontend/src/components/MkButton.vue | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

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

diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue
index 311facb4aa..667e624853 100644
--- a/packages/frontend/src/components/MkButton.vue
+++ b/packages/frontend/src/components/MkButton.vue
@@ -220,28 +220,28 @@ function onMousedown(evt: MouseEvent): void {
 		background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB));
 
 		&:not(:disabled):hover {
-			background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5)));
+			background: linear-gradient(90deg, hsl(from var(--MI_THEME-buttonGradateA) h s calc(l + 5)), hsl(from var(--MI_THEME-buttonGradateB) h s calc(l + 5)));
 		}
 
 		&:not(:disabled):active {
-			background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5)));
+			background: linear-gradient(90deg, hsl(from var(--MI_THEME-buttonGradateA) h s calc(l + 5)), hsl(from var(--MI_THEME-buttonGradateB) h s calc(l + 5)));
 		}
 	}
 
 	&.danger {
 		font-weight: bold;
-		color: #ff2a2a;
+		color: var(--MI_THEME-error);
 
 		&.primary {
 			color: #fff;
-			background: #ff2a2a;
+			background: var(--MI_THEME-error);
 
 			&:not(:disabled):hover {
-				background: #ff4242;
+				background: hsl(from var(--MI_THEME-error) h s calc(l + 10));
 			}
 
 			&:not(:disabled):active {
-				background: #d42e2e;
+				background: hsl(from var(--MI_THEME-error) h s calc(l - 10));
 			}
 		}
 	}
-- 
cgit v1.2.3-freya


From 10b67e1b3a8d03c25b8e2c008f11ce45ef3d915c Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Thu, 13 Mar 2025 16:56:47 +0900
Subject: enhance(frontend): improve emoji picker settings

---
 locales/index.d.ts                                 |  26 ++
 locales/ja-JP.yml                                  |   8 +
 packages/frontend/src/boot/main-boot.ts            |  11 +
 packages/frontend/src/components/MkButton.vue      |  10 +-
 packages/frontend/src/pages/about-misskey.vue      |   4 +-
 packages/frontend/src/pages/settings/deck.vue      |   8 +-
 .../src/pages/settings/emoji-palette.palette.vue   | 166 ++++++++++++
 .../frontend/src/pages/settings/emoji-palette.vue  | 251 ++++++++++++++++++
 .../frontend/src/pages/settings/emoji-picker.vue   | 288 ---------------------
 packages/frontend/src/pages/settings/index.vue     |  10 +-
 packages/frontend/src/preferences/def.ts           |  29 ++-
 packages/frontend/src/router/definition.ts         |   6 +-
 packages/frontend/src/store.ts                     |  16 +-
 .../src/utility/autogen/settings-search-index.ts   |  66 ++++-
 packages/frontend/src/utility/emoji-picker.ts      |  13 +-
 packages/frontend/src/utility/reaction-picker.ts   |  13 +-
 16 files changed, 601 insertions(+), 324 deletions(-)
 create mode 100644 packages/frontend/src/pages/settings/emoji-palette.palette.vue
 create mode 100644 packages/frontend/src/pages/settings/emoji-palette.vue
 delete mode 100644 packages/frontend/src/pages/settings/emoji-picker.vue

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

diff --git a/locales/index.d.ts b/locales/index.d.ts
index 7efca999b9..b814bb70e1 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -5334,6 +5334,32 @@ export interface Locale extends ILocale {
      * 同期の有効化をキャンセル
      */
     "preferenceSyncConflictChoiceCancel": string;
+    /**
+     * ペースト
+     */
+    "paste": string;
+    /**
+     * 絵文字パレット
+     */
+    "emojiPalette": string;
+    "_emojiPalette": {
+        /**
+         * パレット
+         */
+        "palettes": string;
+        /**
+         * パレットのデバイス間同期を有効にする
+         */
+        "enableSyncBetweenDevicesForPalettes": string;
+        /**
+         * メインで使用するパレット
+         */
+        "paletteForMain": string;
+        /**
+         * リアクションで使用するパレット
+         */
+        "paletteForReaction": string;
+    };
     "_settings": {
         /**
          * ドライブの管理と設定、使用量の確認、ファイルをアップロードする際の設定を行えます。
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 96d2439247..b51a839715 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1329,6 +1329,14 @@ preferenceSyncConflictText: "同期が有効にされた設定項目は設定値
 preferenceSyncConflictChoiceServer: "サーバーの設定値"
 preferenceSyncConflictChoiceDevice: "デバイスの設定値"
 preferenceSyncConflictChoiceCancel: "同期の有効化をキャンセル"
+paste: "ペースト"
+emojiPalette: "絵文字パレット"
+
+_emojiPalette:
+  palettes: "パレット"
+  enableSyncBetweenDevicesForPalettes: "パレットのデバイス間同期を有効にする"
+  paletteForMain: "メインで使用するパレット"
+  paletteForReaction: "リアクションで使用するパレット"
 
 _settings:
   driveBanner: "ドライブの管理と設定、使用量の確認、ファイルをアップロードする際の設定を行えます。"
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
index 86efd48c4e..510e0509f4 100644
--- a/packages/frontend/src/boot/main-boot.ts
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -176,6 +176,17 @@ export async function mainBoot() {
 				prefer.commit('lightTheme', ColdDeviceStorage.get('lightTheme'));
 				prefer.commit('darkTheme', ColdDeviceStorage.get('darkTheme'));
 				prefer.commit('syncDeviceDarkMode', ColdDeviceStorage.get('syncDeviceDarkMode'));
+				prefer.commit('emojiPalettes', [{
+					id: 'reactions',
+					name: '',
+					emojis: store.s.reactions,
+				}, {
+					id: 'pinnedEmojis',
+					name: '',
+					emojis: store.s.pinnedEmojis,
+				}]);
+				prefer.commit('emojiPaletteForMain', 'pinnedEmojis');
+				prefer.commit('emojiPaletteForReaction', 'reactions');
 				prefer.commit('overridedDeviceKind', store.s.overridedDeviceKind);
 				prefer.commit('widgets', store.s.widgets);
 				prefer.commit('keepCw', store.s.keepCw);
diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue
index 667e624853..5b32ec0fc8 100644
--- a/packages/frontend/src/components/MkButton.vue
+++ b/packages/frontend/src/components/MkButton.vue
@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <button
 	v-if="!link"
 	ref="el" class="_button"
-	:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]"
+	:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly }]"
 	:type="type"
 	:name="name"
 	:value="value"
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </button>
 <MkA
 	v-else class="_button"
-	:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]"
+	:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly }]"
 	:to="to ?? '#'"
 	:behavior="linkBehavior"
 	@mousedown="onMousedown"
@@ -57,6 +57,7 @@ const props = defineProps<{
 	name?: string;
 	value?: string;
 	disabled?: boolean;
+	iconOnly?: boolean;
 }>();
 
 const emit = defineEmits<{
@@ -147,6 +148,11 @@ function onMousedown(evt: MouseEvent): void {
 		background: var(--MI_THEME-buttonHoverBg);
 	}
 
+	&.iconOnly {
+		padding: 7px;
+		min-width: auto;
+	}
+
 	&.small {
 		font-size: 90%;
 		padding: 6px 12px;
diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue
index 55d703b9ea..5395429d7f 100644
--- a/packages/frontend/src/pages/about-misskey.vue
+++ b/packages/frontend/src/pages/about-misskey.vue
@@ -143,11 +143,11 @@ import MkInfo from '@/components/MkInfo.vue';
 import { physics } from '@/utility/physics.js';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
-import { store } from '@/store.js';
 import * as os from '@/os.js';
 import { definePage } from '@/page.js';
 import { claimAchievement, claimedAchievements } from '@/utility/achievements.js';
 import { $i } from '@/account.js';
+import { prefer } from '@/preferences.js';
 
 const patronsWithIcon = [{
 	name: 'カイヤン',
@@ -406,7 +406,7 @@ const easterEggEngine = ref<{ stop: () => void } | null>(null);
 const containerEl = shallowRef<HTMLElement>();
 
 function iconLoaded() {
-	const emojis = store.s.reactions;
+	const emojis = prefer.s.emojiPalettes[0].emojis;
 	const containerWidth = containerEl.value.offsetWidth;
 	for (let i = 0; i < 32; i++) {
 		easterEggEmojis.value.push({
diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue
index 03c0cb16f8..9b2b40374e 100644
--- a/packages/frontend/src/pages/settings/deck.vue
+++ b/packages/frontend/src/pages/settings/deck.vue
@@ -6,7 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <SearchMarker path="/settings/deck" :label="i18n.ts.deck" :keywords="['deck', 'ui']" icon="ti ti-columns">
 	<div class="_gaps_m">
-		<MkSwitch :modelValue="profilesSyncEnabled" @update:modelValue="changeProfilesSyncEnabled">{{ i18n.ts._deck.enableSyncBetweenDevicesForProfiles }}</MkSwitch>
+		<SearchMarker :keywords="['sync', 'profiles', 'devices']">
+			<MkSwitch :modelValue="profilesSyncEnabled" @update:modelValue="changeProfilesSyncEnabled">
+				<template #label><SearchLabel>{{ i18n.ts._deck.enableSyncBetweenDevicesForProfiles }}</SearchLabel></template>
+			</MkSwitch>
+		</SearchMarker>
 
 		<SearchMarker :keywords="['ui', 'root', 'page']">
 			<MkPreferenceContainer k="deck.useSimpleUiForNonRootPages">
@@ -35,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<SearchMarker :keywords="['column', 'align']">
 			<MkPreferenceContainer k="deck.columnAlign">
 				<MkRadios v-model="columnAlign">
-					<template #label>{{ i18n.ts._deck.columnAlign }}</template>
+					<template #label><SearchLabel>{{ i18n.ts._deck.columnAlign }}</SearchLabel></template>
 					<option value="left">{{ i18n.ts.left }}</option>
 					<option value="center">{{ i18n.ts.center }}</option>
 				</MkRadios>
diff --git a/packages/frontend/src/pages/settings/emoji-palette.palette.vue b/packages/frontend/src/pages/settings/emoji-palette.palette.vue
new file mode 100644
index 0000000000..33d1d7c9fa
--- /dev/null
+++ b/packages/frontend/src/pages/settings/emoji-palette.palette.vue
@@ -0,0 +1,166 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkFolder :defaultOpen="true">
+	<template #icon><i class="ti ti-palette"></i></template>
+	<template #label>{{ palette.name === '' ? '(' + i18n.ts.noName + ')' : palette.name }}</template>
+	<template #footer>
+		<div class="_buttons">
+			<MkButton @click="rename"><i class="ti ti-pencil"></i> {{ i18n.ts.rename }}</MkButton>
+			<MkButton @click="copy"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
+			<MkButton danger @click="paste"><i class="ti ti-clipboard"></i> {{ i18n.ts.paste }}</MkButton>
+			<MkButton danger iconOnly style="margin-left: auto;" @click="del"><i class="ti ti-trash"></i></MkButton>
+		</div>
+	</template>
+
+	<div>
+		<div v-panel style="border-radius: 6px;">
+			<Sortable
+				v-model="emojis"
+				:class="$style.emojis"
+				:itemKey="item => item"
+				:animation="150"
+				:delay="100"
+				:delayOnTouchOnly="true"
+				:group="{ name: 'SortableEmojiPalettes' }"
+			>
+				<template #item="{element}">
+					<button class="_button" :class="$style.emojisItem" @click="remove(element, $event)">
+						<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/>
+						<MkEmoji v-else :emoji="element" :normal="true"/>
+					</button>
+				</template>
+				<template #footer>
+					<button class="_button" :class="$style.emojisAdd" @click="pick">
+						<i class="ti ti-plus"></i>
+					</button>
+				</template>
+			</Sortable>
+		</div>
+		<div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div>
+	</div>
+</MkFolder>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+import Sortable from 'vuedraggable';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os.js';
+import { i18n } from '@/i18n.js';
+import { deepClone } from '@/utility/clone.js';
+import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue';
+import MkEmoji from '@/components/global/MkEmoji.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+
+const props = defineProps<{
+	palette: {
+		id: string;
+		name: string;
+		emojis: string[];
+	};
+}>();
+
+const emit = defineEmits<{
+	(ev: 'updateEmojis', emojis: string[]): void,
+	(ev: 'updateName', name: string): void,
+	(ev: 'del'): void,
+}>();
+
+const emojis = ref<string[]>(deepClone(props.palette.emojis));
+
+watch(emojis, () => {
+	emit('updateEmojis', emojis.value);
+}, { deep: true });
+
+function remove(reaction: string, ev: MouseEvent) {
+	os.popupMenu([{
+		text: i18n.ts.remove,
+		action: () => {
+			emojis.value = emojis.value.filter(x => x !== reaction);
+		},
+	}], getHTMLElement(ev));
+}
+
+function pick(ev: MouseEvent) {
+	os.pickEmoji(getHTMLElement(ev), {
+		showPinned: false,
+	}).then(it => {
+		const emoji = it;
+		if (!emojis.value.includes(emoji)) {
+			emojis.value.push(emoji);
+		}
+	});
+}
+
+function getHTMLElement(ev: MouseEvent): HTMLElement {
+	const target = ev.currentTarget ?? ev.target;
+	return target as HTMLElement;
+}
+
+function rename() {
+	os.inputText({
+		title: i18n.ts.rename,
+		default: props.palette.name,
+	}).then(({ canceled, result: name }) => {
+		if (canceled) return;
+		if (name != null) {
+			emit('updateName', name);
+		}
+	});
+}
+
+function copy() {
+	copyToClipboard(emojis.value.join(' '));
+}
+
+function paste() {
+	// TODO: validate
+	navigator.clipboard.readText().then(text => {
+		emojis.value = text.split(' ');
+	});
+}
+
+function del(ev: MouseEvent) {
+	os.popupMenu([{
+		text: i18n.ts.delete,
+		action: () => {
+			emit('del');
+		},
+	}], ev.currentTarget ?? ev.target);
+}
+</script>
+
+<style lang="scss" module>
+.tab {
+	margin: calc(var(--MI-margin) / 2) 0;
+	padding: calc(var(--MI-margin) / 2) 0;
+	background: var(--MI_THEME-bg);
+}
+
+.emojis {
+	padding: 12px;
+	font-size: 1.1em;
+}
+
+.emojisItem {
+	display: inline-block;
+	padding: 8px;
+	cursor: move;
+}
+
+.emojisAdd {
+	display: inline-block;
+	padding: 8px;
+}
+
+.editorCaption {
+	font-size: 0.85em;
+	padding: 8px 0 0 0;
+	color: var(--MI_THEME-fgTransparentWeak);
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/emoji-palette.vue b/packages/frontend/src/pages/settings/emoji-palette.vue
new file mode 100644
index 0000000000..398228e226
--- /dev/null
+++ b/packages/frontend/src/pages/settings/emoji-palette.vue
@@ -0,0 +1,251 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<SearchMarker path="/settings/emoji-palette" :label="i18n.ts.emojiPalette" :keywords="['emoji', 'palette']" icon="ti ti-mood-happy">
+	<div class="_gaps_m">
+		<FormSection first>
+			<template #label>{{ i18n.ts._emojiPalette.palettes }}</template>
+
+			<div class="_gaps_s">
+				<XPalette
+					v-for="palette in prefer.r.emojiPalettes.value"
+					:key="palette.id"
+					:palette="palette"
+					@updateEmojis="emojis => updatePaletteEmojis(palette.id, emojis)"
+					@updateName="name => updatePaletteName(palette.id, name)"
+					@del="delPalette(palette.id)"
+				/>
+				<MkButton primary rounded style="margin: auto;" @click="addPalette"><i class="ti ti-plus"></i></MkButton>
+			</div>
+		</FormSection>
+
+		<FormSection>
+			<div class="_gaps_m">
+				<SearchMarker :keywords="['sync', 'palettes', 'devices']">
+					<MkSwitch :modelValue="palettesSyncEnabled" @update:modelValue="changePalettesSyncEnabled">
+						<template #label><SearchLabel>{{ i18n.ts._emojiPalette.enableSyncBetweenDevicesForPalettes }}</SearchLabel></template>
+					</MkSwitch>
+				</SearchMarker>
+			</div>
+		</FormSection>
+
+		<FormSection>
+			<div class="_gaps_m">
+				<SearchMarker :keywords="['main', 'palette']">
+					<MkPreferenceContainer k="emojiPaletteForMain">
+						<MkSelect v-model="emojiPaletteForMain">
+							<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">
+							<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>
+			</div>
+		</FormSection>
+
+		<SearchMarker :keywords="['emoji', 'picker', 'display']">
+			<FormSection>
+				<template #label><SearchLabel>{{ i18n.ts.emojiPickerDisplay }}</SearchLabel></template>
+
+				<div class="_gaps_m">
+					<SearchMarker :keywords="['emoji', 'picker', 'scale', 'size']">
+						<MkPreferenceContainer k="emojiPickerScale">
+							<MkRadios v-model="emojiPickerScale">
+								<template #label><SearchLabel>{{ i18n.ts.size }}</SearchLabel></template>
+								<option :value="1">{{ i18n.ts.small }}</option>
+								<option :value="2">{{ i18n.ts.medium }}</option>
+								<option :value="3">{{ i18n.ts.large }}</option>
+							</MkRadios>
+						</MkPreferenceContainer>
+					</SearchMarker>
+
+					<SearchMarker :keywords="['emoji', 'picker', 'width', 'column', 'size']">
+						<MkPreferenceContainer k="emojiPickerWidth">
+							<MkRadios v-model="emojiPickerWidth">
+								<template #label><SearchLabel>{{ i18n.ts.numberOfColumn }}</SearchLabel></template>
+								<option :value="1">5</option>
+								<option :value="2">6</option>
+								<option :value="3">7</option>
+								<option :value="4">8</option>
+								<option :value="5">9</option>
+							</MkRadios>
+						</MkPreferenceContainer>
+					</SearchMarker>
+
+					<SearchMarker :keywords="['emoji', 'picker', 'height', 'size']">
+						<MkPreferenceContainer k="emojiPickerHeight">
+							<MkRadios v-model="emojiPickerHeight">
+								<template #label><SearchLabel>{{ i18n.ts.height }}</SearchLabel></template>
+								<option :value="1">{{ i18n.ts.small }}</option>
+								<option :value="2">{{ i18n.ts.medium }}</option>
+								<option :value="3">{{ i18n.ts.large }}</option>
+								<option :value="4">{{ i18n.ts.large }}+</option>
+							</MkRadios>
+						</MkPreferenceContainer>
+					</SearchMarker>
+
+					<SearchMarker :keywords="['emoji', 'picker', 'style']">
+						<MkPreferenceContainer k="emojiPickerStyle">
+							<MkSelect v-model="emojiPickerStyle">
+								<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>
+
+					<MkButton @click="previewPicker"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
+				</div>
+			</FormSection>
+		</SearchMarker>
+	</div>
+</SearchMarker>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, watch } from 'vue';
+import { v4 as uuid } from 'uuid';
+import XPalette from './emoji-palette.palette.vue';
+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 * as os from '@/os.js';
+import { i18n } from '@/i18n.js';
+import { definePage } from '@/page.js';
+import MkFolder from '@/components/MkFolder.vue';
+import { prefer } from '@/preferences.js';
+import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import { emojiPicker } from '@/utility/emoji-picker.js';
+
+const emojiPaletteForReaction = prefer.model('emojiPaletteForReaction');
+const emojiPaletteForMain = prefer.model('emojiPaletteForMain');
+const emojiPickerScale = prefer.model('emojiPickerScale');
+const emojiPickerWidth = prefer.model('emojiPickerWidth');
+const emojiPickerHeight = prefer.model('emojiPickerHeight');
+const emojiPickerStyle = prefer.model('emojiPickerStyle');
+
+const palettesSyncEnabled = ref(prefer.isSyncEnabled('emojiPalettes'));
+
+function changePalettesSyncEnabled(value: boolean) {
+	if (value) {
+		prefer.enableSync('emojiPalettes').then((res) => {
+			if (res == null) return;
+			if (res.enabled) palettesSyncEnabled.value = true;
+		});
+	} else {
+		prefer.disableSync('emojiPalettes');
+		palettesSyncEnabled.value = false;
+	}
+}
+
+function addPalette() {
+	prefer.commit('emojiPalettes', [
+		...prefer.s.emojiPalettes,
+		{
+			id: uuid(),
+			name: '',
+			emojis: [],
+		},
+	]);
+}
+
+function updatePaletteEmojis(id: string, emojis: string[]) {
+	prefer.commit('emojiPalettes', prefer.s.emojiPalettes.map(palette => {
+		if (palette.id === id) {
+			return {
+				...palette,
+				emojis,
+			};
+		} else {
+			return palette;
+		}
+	}));
+}
+
+function updatePaletteName(id: string, name: string) {
+	prefer.commit('emojiPalettes', prefer.s.emojiPalettes.map(palette => {
+		if (palette.id === id) {
+			return {
+				...palette,
+				name,
+			};
+		} else {
+			return palette;
+		}
+	}));
+}
+
+function delPalette(id: string) {
+	if (prefer.s.emojiPalettes.length === 1) {
+		addPalette();
+	}
+	prefer.commit('emojiPalettes', prefer.s.emojiPalettes.filter(palette => palette.id !== id));
+	if (prefer.s.emojiPaletteForMain === id) {
+		prefer.commit('emojiPaletteForMain', null);
+	}
+	if (prefer.s.emojiPaletteForReaction === id) {
+		prefer.commit('emojiPaletteForReaction', null);
+	}
+}
+
+function getHTMLElement(ev: MouseEvent): HTMLElement {
+	const target = ev.currentTarget ?? ev.target;
+	return target as HTMLElement;
+}
+
+function previewPicker(ev: MouseEvent) {
+	emojiPicker.show(getHTMLElement(ev));
+}
+
+definePage(() => ({
+	title: i18n.ts.emojiPalette,
+	icon: 'ti ti-mood-happy',
+}));
+</script>
+
+<style lang="scss" module>
+.tab {
+	margin: calc(var(--MI-margin) / 2) 0;
+	padding: calc(var(--MI-margin) / 2) 0;
+	background: var(--MI_THEME-bg);
+}
+
+.emojis {
+  padding: 12px;
+  font-size: 1.1em;
+}
+
+.emojisItem {
+  display: inline-block;
+  padding: 8px;
+  cursor: move;
+}
+
+.emojisAdd {
+  display: inline-block;
+  padding: 8px;
+}
+
+.editorCaption {
+	font-size: 0.85em;
+	padding: 8px 0 0 0;
+	color: var(--MI_THEME-fgTransparentWeak);
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/emoji-picker.vue b/packages/frontend/src/pages/settings/emoji-picker.vue
deleted file mode 100644
index d8f27078ae..0000000000
--- a/packages/frontend/src/pages/settings/emoji-picker.vue
+++ /dev/null
@@ -1,288 +0,0 @@
-<!--
-SPDX-FileCopyrightText: syuilo and misskey-project
-SPDX-License-Identifier: AGPL-3.0-only
--->
-
-<template>
-<div class="_gaps_m">
-	<MkFolder :defaultOpen="true">
-		<template #icon><i class="ti ti-pin"></i></template>
-		<template #label>{{ i18n.ts.pinned }} ({{ i18n.ts.reaction }})</template>
-		<template #caption>{{ i18n.ts.pinnedEmojisForReactionSettingDescription }}</template>
-
-		<div class="_gaps">
-			<div>
-				<div v-panel style="border-radius: 6px;">
-					<Sortable
-						v-model="pinnedEmojisForReaction"
-						:class="$style.emojis"
-						:itemKey="item => item"
-						:animation="150"
-						:delay="100"
-						:delayOnTouchOnly="true"
-					>
-						<template #item="{element}">
-							<button class="_button" :class="$style.emojisItem" @click="removeReaction(element, $event)">
-								<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/>
-								<MkEmoji v-else :emoji="element" :normal="true"/>
-							</button>
-						</template>
-						<template #footer>
-							<button class="_button" :class="$style.emojisAdd" @click="chooseReaction">
-								<i class="ti ti-plus"></i>
-							</button>
-						</template>
-					</Sortable>
-				</div>
-				<div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div>
-			</div>
-
-			<div class="_buttons">
-				<MkButton inline @click="previewReaction"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
-				<MkButton inline danger @click="setDefaultReaction"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
-				<MkButton inline danger @click="overwriteFromPinnedEmojis"><i class="ti ti-copy"></i> {{ i18n.ts.overwriteFromPinnedEmojis }}</MkButton>
-			</div>
-		</div>
-	</MkFolder>
-
-	<MkFolder>
-		<template #icon><i class="ti ti-pin"></i></template>
-		<template #label>{{ i18n.ts.pinned }} ({{ i18n.ts.general }})</template>
-		<template #caption>{{ i18n.ts.pinnedEmojisSettingDescription }}</template>
-
-		<div class="_gaps">
-			<div>
-				<div v-panel style="border-radius: 6px;">
-					<Sortable
-						v-model="pinnedEmojis"
-						:class="$style.emojis"
-						:itemKey="item => item"
-						:animation="150"
-						:delay="100"
-						:delayOnTouchOnly="true"
-					>
-						<template #item="{element}">
-							<button class="_button" :class="$style.emojisItem" @click="removeEmoji(element, $event)">
-								<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/>
-								<MkEmoji v-else :emoji="element" :normal="true"/>
-							</button>
-						</template>
-						<template #footer>
-							<button class="_button" :class="$style.emojisAdd" @click="chooseEmoji">
-								<i class="ti ti-plus"></i>
-							</button>
-						</template>
-					</Sortable>
-				</div>
-				<div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div>
-			</div>
-
-			<div class="_buttons">
-				<MkButton inline @click="previewEmoji"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
-				<MkButton inline danger @click="setDefaultEmoji"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
-				<MkButton inline danger @click="overwriteFromPinnedEmojisForReaction"><i class="ti ti-copy"></i> {{ i18n.ts.overwriteFromPinnedEmojisForReaction }}</MkButton>
-			</div>
-		</div>
-	</MkFolder>
-
-	<FormSection>
-		<template #label>{{ i18n.ts.emojiPickerDisplay }}</template>
-
-		<div class="_gaps_m">
-			<MkPreferenceContainer k="emojiPickerScale">
-				<MkRadios v-model="emojiPickerScale">
-					<template #label>{{ i18n.ts.size }}</template>
-					<option :value="1">{{ i18n.ts.small }}</option>
-					<option :value="2">{{ i18n.ts.medium }}</option>
-					<option :value="3">{{ i18n.ts.large }}</option>
-				</MkRadios>
-			</MkPreferenceContainer>
-
-			<MkPreferenceContainer k="emojiPickerWidth">
-				<MkRadios v-model="emojiPickerWidth">
-					<template #label>{{ i18n.ts.numberOfColumn }}</template>
-					<option :value="1">5</option>
-					<option :value="2">6</option>
-					<option :value="3">7</option>
-					<option :value="4">8</option>
-					<option :value="5">9</option>
-				</MkRadios>
-			</MkPreferenceContainer>
-
-			<MkPreferenceContainer k="emojiPickerHeight">
-				<MkRadios v-model="emojiPickerHeight">
-					<template #label>{{ i18n.ts.height }}</template>
-					<option :value="1">{{ i18n.ts.small }}</option>
-					<option :value="2">{{ i18n.ts.medium }}</option>
-					<option :value="3">{{ i18n.ts.large }}</option>
-					<option :value="4">{{ i18n.ts.large }}+</option>
-				</MkRadios>
-			</MkPreferenceContainer>
-
-			<MkPreferenceContainer k="emojiPickerStyle">
-				<MkSelect v-model="emojiPickerStyle">
-					<template #label>{{ i18n.ts.style }}</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>
-		</div>
-	</FormSection>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { computed, ref, watch } from 'vue';
-import Sortable from 'vuedraggable';
-import type { Ref } from 'vue';
-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 * as os from '@/os.js';
-import { store } from '@/store.js';
-import { i18n } from '@/i18n.js';
-import { definePage } from '@/page.js';
-import { deepClone } from '@/utility/clone.js';
-import { reactionPicker } from '@/utility/reaction-picker.js';
-import { emojiPicker } from '@/utility/emoji-picker.js';
-import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue';
-import MkEmoji from '@/components/global/MkEmoji.vue';
-import MkFolder from '@/components/MkFolder.vue';
-import { prefer } from '@/preferences.js';
-import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
-
-const pinnedEmojisForReaction: Ref<string[]> = ref(deepClone(store.s.reactions));
-const pinnedEmojis: Ref<string[]> = ref(deepClone(store.s.pinnedEmojis));
-
-const emojiPickerScale = prefer.model('emojiPickerScale');
-const emojiPickerWidth = prefer.model('emojiPickerWidth');
-const emojiPickerHeight = prefer.model('emojiPickerHeight');
-const emojiPickerStyle = prefer.model('emojiPickerStyle');
-
-const removeReaction = (reaction: string, ev: MouseEvent) => remove(pinnedEmojisForReaction, reaction, ev);
-const chooseReaction = (ev: MouseEvent) => pickEmoji(pinnedEmojisForReaction, ev);
-const setDefaultReaction = () => setDefault(pinnedEmojisForReaction);
-
-const removeEmoji = (reaction: string, ev: MouseEvent) => remove(pinnedEmojis, reaction, ev);
-const chooseEmoji = (ev: MouseEvent) => pickEmoji(pinnedEmojis, ev);
-const setDefaultEmoji = () => setDefault(pinnedEmojis);
-
-function previewReaction(ev: MouseEvent) {
-	reactionPicker.show(getHTMLElement(ev), null);
-}
-
-function previewEmoji(ev: MouseEvent) {
-	emojiPicker.show(getHTMLElement(ev));
-}
-
-async function overwriteFromPinnedEmojis() {
-	const { canceled } = await os.confirm({
-		type: 'warning',
-		text: i18n.ts.overwriteContentConfirm,
-	});
-
-	if (canceled) {
-		return;
-	}
-
-	pinnedEmojisForReaction.value = [...pinnedEmojis.value];
-}
-
-async function overwriteFromPinnedEmojisForReaction() {
-	const { canceled } = await os.confirm({
-		type: 'warning',
-		text: i18n.ts.overwriteContentConfirm,
-	});
-
-	if (canceled) {
-		return;
-	}
-
-	pinnedEmojis.value = [...pinnedEmojisForReaction.value];
-}
-
-function remove(itemsRef: Ref<string[]>, reaction: string, ev: MouseEvent) {
-	os.popupMenu([{
-		text: i18n.ts.remove,
-		action: () => {
-			itemsRef.value = itemsRef.value.filter(x => x !== reaction);
-		},
-	}], getHTMLElement(ev));
-}
-
-async function setDefault(itemsRef: Ref<string[]>) {
-	const { canceled } = await os.confirm({
-		type: 'warning',
-		text: i18n.ts.resetAreYouSure,
-	});
-	if (canceled) return;
-
-	itemsRef.value = deepClone(store.def.reactions.default);
-}
-
-async function pickEmoji(itemsRef: Ref<string[]>, ev: MouseEvent) {
-	os.pickEmoji(getHTMLElement(ev), {
-		showPinned: false,
-	}).then(it => {
-		const emoji = it;
-		if (!itemsRef.value.includes(emoji)) {
-			itemsRef.value.push(emoji);
-		}
-	});
-}
-
-function getHTMLElement(ev: MouseEvent): HTMLElement {
-	const target = ev.currentTarget ?? ev.target;
-	return target as HTMLElement;
-}
-
-watch(pinnedEmojisForReaction, () => {
-	store.set('reactions', pinnedEmojisForReaction.value);
-}, {
-	deep: true,
-});
-
-watch(pinnedEmojis, () => {
-	store.set('pinnedEmojis', pinnedEmojis.value);
-}, {
-	deep: true,
-});
-
-definePage(() => ({
-	title: i18n.ts.emojiPicker,
-	icon: 'ti ti-mood-happy',
-}));
-</script>
-
-<style lang="scss" module>
-.tab {
-	margin: calc(var(--MI-margin) / 2) 0;
-	padding: calc(var(--MI-margin) / 2) 0;
-	background: var(--MI_THEME-bg);
-}
-
-.emojis {
-  padding: 12px;
-  font-size: 1.1em;
-}
-
-.emojisItem {
-  display: inline-block;
-  padding: 8px;
-  cursor: move;
-}
-
-.emojisAdd {
-  display: inline-block;
-  padding: 8px;
-}
-
-.editorCaption {
-	font-size: 0.85em;
-	padding: 8px 0 0 0;
-	color: var(--MI_THEME-fgTransparentWeak);
-}
-</style>
diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue
index 7bbec82757..debcd4bd3e 100644
--- a/packages/frontend/src/pages/settings/index.vue
+++ b/packages/frontend/src/pages/settings/index.vue
@@ -86,11 +86,6 @@ const menuDef = computed<SuperMenuDef[]>(() => [{
 		text: i18n.ts.privacy,
 		to: '/settings/privacy',
 		active: currentPage.value?.route.name === 'privacy',
-	}, {
-		icon: 'ti ti-mood-happy',
-		text: i18n.ts.emojiPicker,
-		to: '/settings/emoji-picker',
-		active: currentPage.value?.route.name === 'emojiPicker',
 	}, {
 		icon: 'ti ti-bell',
 		text: i18n.ts.notifications,
@@ -118,6 +113,11 @@ const menuDef = computed<SuperMenuDef[]>(() => [{
 		text: i18n.ts.theme,
 		to: '/settings/theme',
 		active: currentPage.value?.route.name === 'theme',
+	}, {
+		icon: 'ti ti-mood-happy',
+		text: i18n.ts.emojiPalette,
+		to: '/settings/emoji-palette',
+		active: currentPage.value?.route.name === 'emoji-palette',
 	}, {
 		icon: 'ti ti-device-desktop',
 		text: i18n.ts.appearance,
diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts
index 47d0ab5cbc..6a926c4b26 100644
--- a/packages/frontend/src/preferences/def.ts
+++ b/packages/frontend/src/preferences/def.ts
@@ -29,6 +29,8 @@ export type SoundStore = {
 	volume: number;
 };
 
+// NOTE: デフォルト値は他の設定の状態に依存してはならない(依存していた場合、ユーザーがその設定項目単体で「初期値にリセット」した場合不具合の原因になる)
+
 export const PREF_DEF = {
 	pinnedUserLists: {
 		accountDependent: true,
@@ -56,6 +58,27 @@ export const PREF_DEF = {
 		default: [] as DeckProfile[],
 	},
 
+	emojiPalettes: {
+		serverDependent: true,
+		default: [{
+			id: 'a',
+			name: '',
+			emojis: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
+		}] as {
+			id: string;
+			name: string;
+			emojis: string[];
+		}[],
+	},
+	emojiPaletteForReaction: {
+		serverDependent: true,
+		default: null as string | null,
+	},
+	emojiPaletteForMain: {
+		serverDependent: true,
+		default: null as string | null,
+	},
+
 	overridedDeviceKind: {
 		default: null as DeviceKind | null,
 	},
@@ -180,13 +203,13 @@ export const PREF_DEF = {
 		default: 'remote' as 'none' | 'remote' | 'always',
 	},
 	emojiPickerScale: {
-		default: 1,
+		default: 2,
 	},
 	emojiPickerWidth: {
-		default: 1,
+		default: 2,
 	},
 	emojiPickerHeight: {
-		default: 2,
+		default: 3,
 	},
 	emojiPickerStyle: {
 		default: 'auto' as 'auto' | 'popup' | 'drawer',
diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts
index 93dd081127..752356497e 100644
--- a/packages/frontend/src/router/definition.ts
+++ b/packages/frontend/src/router/definition.ts
@@ -66,9 +66,9 @@ const routes: RouteDef[] = [{
 		name: 'privacy',
 		component: page(() => import('@/pages/settings/privacy.vue')),
 	}, {
-		path: '/emoji-picker',
-		name: 'emojiPicker',
-		component: page(() => import('@/pages/settings/emoji-picker.vue')),
+		path: '/emoji-palette',
+		name: 'emoji-palette',
+		component: page(() => import('@/pages/settings/emoji-palette.vue')),
 	}, {
 		path: '/drive',
 		name: 'drive',
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 738a57d233..6eebcd1ead 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -39,14 +39,6 @@ export const store = markRaw(new Storage('base', {
 		where: 'account',
 		default: null,
 	},
-	reactions: {
-		where: 'account',
-		default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
-	},
-	pinnedEmojis: {
-		where: 'account',
-		default: [],
-	},
 	reactionAcceptance: {
 		where: 'account',
 		default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null,
@@ -127,6 +119,14 @@ export const store = markRaw(new Storage('base', {
 	},
 
 	//#region TODO: そのうち消す (preferに移行済み)
+	reactions: {
+		where: 'account',
+		default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
+	},
+	pinnedEmojis: {
+		where: 'account',
+		default: [],
+	},
 	widgets: {
 		where: 'account',
 		default: [] as {
diff --git a/packages/frontend/src/utility/autogen/settings-search-index.ts b/packages/frontend/src/utility/autogen/settings-search-index.ts
index 52100ab639..4f1a94f266 100644
--- a/packages/frontend/src/utility/autogen/settings-search-index.ts
+++ b/packages/frontend/src/utility/autogen/settings-search-index.ts
@@ -536,6 +536,57 @@ export const searchIndexes: SearchIndexItem[] = [
 		path: '/settings/mute-block',
 		icon: 'ti ti-ban',
 	},
+	{
+		id: 'yR1OSyLiT',
+		children: [
+			{
+				id: 'yMJzyzOUk',
+				label: i18n.ts._emojiPalette.enableSyncBetweenDevicesForPalettes,
+				keywords: ['sync', 'palettes', 'devices'],
+			},
+			{
+				id: 'wCE09vgZr',
+				label: i18n.ts._emojiPalette.paletteForMain,
+				keywords: ['main', 'palette'],
+			},
+			{
+				id: 'uCzRPrSNx',
+				label: i18n.ts._emojiPalette.paletteForReaction,
+				keywords: ['reaction', 'palette'],
+			},
+			{
+				id: 'hgQr28WUk',
+				children: [
+					{
+						id: 'fY04NIHSQ',
+						label: i18n.ts.size,
+						keywords: ['emoji', 'picker', 'scale', 'size'],
+					},
+					{
+						id: '3j7vlaL7t',
+						label: i18n.ts.numberOfColumn,
+						keywords: ['emoji', 'picker', 'width', 'column', 'size'],
+					},
+					{
+						id: 'zPX8z1Bcy',
+						label: i18n.ts.height,
+						keywords: ['emoji', 'picker', 'height', 'size'],
+					},
+					{
+						id: '2CSkZa4tl',
+						label: i18n.ts.style,
+						keywords: ['emoji', 'picker', 'style'],
+					},
+				],
+				label: i18n.ts.emojiPickerDisplay,
+				keywords: ['emoji', 'picker', 'display'],
+			},
+		],
+		label: i18n.ts.emojiPalette,
+		keywords: ['emoji', 'palette'],
+		path: '/settings/emoji-palette',
+		icon: 'ti ti-mood-happy',
+	},
 	{
 		id: '3Tcxw4Fwl',
 		children: [
@@ -608,23 +659,28 @@ export const searchIndexes: SearchIndexItem[] = [
 		id: 'FfZdOs8y',
 		children: [
 			{
-				id: 'lVlkdP4zN',
+				id: 'B1ZU6Ur54',
+				label: i18n.ts._deck.enableSyncBetweenDevicesForProfiles,
+				keywords: ['sync', 'profiles', 'devices'],
+			},
+			{
+				id: 'iEF0gqNAo',
 				label: i18n.ts._deck.useSimpleUiForNonRootPages,
 				keywords: ['ui', 'root', 'page'],
 			},
 			{
-				id: 'avgxEYgsi',
+				id: 'BNdSeWxZn',
 				label: i18n.ts.defaultNavigationBehaviour,
 				keywords: ['default', 'navigation', 'behaviour', 'window'],
 			},
 			{
-				id: 'ma7OSw5JK',
+				id: 'zT9pGm8DF',
 				label: i18n.ts._deck.alwaysShowMainColumn,
 				keywords: ['always', 'show', 'main', 'column'],
 			},
 			{
-				id: 'jjTlUDhJH',
-				label: 'Unnamed marker',
+				id: '5dk2xv1vc',
+				label: i18n.ts._deck.columnAlign,
 				keywords: ['column', 'align'],
 			},
 		],
diff --git a/packages/frontend/src/utility/emoji-picker.ts b/packages/frontend/src/utility/emoji-picker.ts
index e7275b86f2..6279786b2d 100644
--- a/packages/frontend/src/utility/emoji-picker.ts
+++ b/packages/frontend/src/utility/emoji-picker.ts
@@ -3,10 +3,10 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { defineAsyncComponent, ref } from 'vue';
+import { defineAsyncComponent, ref, watch } from 'vue';
 import type { Ref } from 'vue';
 import { popup } from '@/os.js';
-import { store } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 /**
  * 絵文字ピッカーを表示する。
@@ -25,7 +25,14 @@ class EmojiPicker {
 	}
 
 	public async init() {
-		const emojisRef = store.r.pinnedEmojis;
+		const emojisRef = ref<string[]>([]);
+
+		watch([prefer.r.emojiPaletteForMain, prefer.r.emojiPalettes], () => {
+			emojisRef.value = prefer.s.emojiPaletteForMain == null ? prefer.s.emojiPalettes[0].emojis : prefer.s.emojiPalettes.find(palette => palette.id === prefer.s.emojiPaletteForMain)?.emojis ?? [];
+		}, {
+			immediate: true,
+		});
+
 		await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
 			src: this.src,
 			pinnedEmojis: emojisRef,
diff --git a/packages/frontend/src/utility/reaction-picker.ts b/packages/frontend/src/utility/reaction-picker.ts
index 200fb0b686..7c159fa2da 100644
--- a/packages/frontend/src/utility/reaction-picker.ts
+++ b/packages/frontend/src/utility/reaction-picker.ts
@@ -4,10 +4,10 @@
  */
 
 import * as Misskey from 'misskey-js';
-import { defineAsyncComponent, ref } from 'vue';
+import { defineAsyncComponent, ref, watch } from 'vue';
 import type { Ref } from 'vue';
 import { popup } from '@/os.js';
-import { store } from '@/store.js';
+import { prefer } from '@/preferences.js';
 
 class ReactionPicker {
 	private src: Ref<HTMLElement | null> = ref(null);
@@ -21,7 +21,14 @@ class ReactionPicker {
 	}
 
 	public async init() {
-		const reactionsRef = store.r.reactions;
+		const reactionsRef = ref<string[]>([]);
+
+		watch([prefer.r.emojiPaletteForReaction, prefer.r.emojiPalettes], () => {
+			reactionsRef.value = prefer.s.emojiPaletteForReaction == null ? prefer.s.emojiPalettes[0].emojis : prefer.s.emojiPalettes.find(palette => palette.id === prefer.s.emojiPaletteForReaction)?.emojis ?? [];
+		}, {
+			immediate: true,
+		});
+
 		await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
 			src: this.src,
 			pinnedEmojis: reactionsRef,
-- 
cgit v1.2.3-freya


From 7b323031b774745ee2146c89ead2a9ebe628d613 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Wed, 19 Mar 2025 18:46:03 +0900
Subject: refactor(frontend): use useTemplateRef for DOM referencing

---
 .../src/components/MkAbuseReportWindow.vue         |  4 ++--
 packages/frontend/src/components/MkAnimBg.vue      |  4 ++--
 .../src/components/MkAnnouncementDialog.vue        |  6 +++---
 .../src/components/MkAntennaEditorDialog.vue       |  4 ++--
 .../frontend/src/components/MkAutocomplete.vue     |  4 ++--
 packages/frontend/src/components/MkButton.vue      |  6 +++---
 packages/frontend/src/components/MkCaptcha.vue     |  4 ++--
 packages/frontend/src/components/MkChart.vue       | 14 +++++--------
 packages/frontend/src/components/MkCodeEditor.vue  |  4 ++--
 packages/frontend/src/components/MkColorInput.vue  |  4 ++--
 packages/frontend/src/components/MkContainer.vue   |  8 ++++----
 packages/frontend/src/components/MkContextMenu.vue |  4 ++--
 .../frontend/src/components/MkCropperDialog.vue    |  6 +++---
 .../src/components/MkCustomEmojiDetailedDialog.vue |  6 +++---
 packages/frontend/src/components/MkDialog.vue      |  4 ++--
 packages/frontend/src/components/MkDrive.vue       |  6 +++---
 .../src/components/MkDriveSelectDialog.vue         |  4 ++--
 .../src/components/MkEmbedCodeGenDialog.vue        |  8 ++++----
 packages/frontend/src/components/MkEmojiPicker.vue |  6 +++---
 .../src/components/MkEmojiPickerDialog.vue         |  6 +++---
 .../src/components/MkFileCaptionEditWindow.vue     |  4 ++--
 .../frontend/src/components/MkFoldableSection.vue  |  4 ++--
 packages/frontend/src/components/MkFolder.vue      |  4 ++--
 packages/frontend/src/components/MkFormDialog.vue  |  4 ++--
 packages/frontend/src/components/MkHeatmap.vue     |  6 +++---
 .../frontend/src/components/MkHorizontalSwipe.vue  |  4 ++--
 .../frontend/src/components/MkImgWithBlurhash.vue  |  8 ++++----
 packages/frontend/src/components/MkInput.vue       |  8 ++++----
 .../frontend/src/components/MkInstanceStats.vue    |  8 ++++----
 packages/frontend/src/components/MkLaunchPad.vue   |  4 ++--
 packages/frontend/src/components/MkMediaAudio.vue  |  6 +++---
 packages/frontend/src/components/MkMediaList.vue   |  4 ++--
 packages/frontend/src/components/MkMediaVideo.vue  |  6 +++---
 packages/frontend/src/components/MkMenu.child.vue  |  4 ++--
 packages/frontend/src/components/MkMenu.vue        |  8 ++++----
 packages/frontend/src/components/MkModal.vue       |  6 +++---
 packages/frontend/src/components/MkModalWindow.vue |  8 ++++----
 packages/frontend/src/components/MkNote.vue        | 16 +++++++--------
 .../frontend/src/components/MkNoteDetailed.vue     | 16 +++++++--------
 packages/frontend/src/components/MkNotes.vue       |  4 ++--
 .../src/components/MkNotificationSelectWindow.vue  |  8 ++++----
 .../frontend/src/components/MkNotifications.vue    |  4 ++--
 packages/frontend/src/components/MkOmit.vue        |  4 ++--
 packages/frontend/src/components/MkPageWindow.vue  |  4 ++--
 packages/frontend/src/components/MkPagination.vue  |  4 ++--
 .../frontend/src/components/MkPasswordDialog.vue   |  6 +++---
 packages/frontend/src/components/MkPopupMenu.vue   |  4 ++--
 packages/frontend/src/components/MkPostForm.vue    | 12 +++++------
 .../frontend/src/components/MkPostFormDialog.vue   |  7 +++----
 .../frontend/src/components/MkPullToRefresh.vue    |  6 +++---
 packages/frontend/src/components/MkRange.vue       |  6 +++---
 .../frontend/src/components/MkReactionIcon.vue     |  4 ++--
 .../src/components/MkReactionsViewer.reaction.vue  |  4 ++--
 .../frontend/src/components/MkRetentionHeatmap.vue |  6 +++---
 .../src/components/MkRetentionLineChart.vue        |  4 ++--
 .../frontend/src/components/MkSigninDialog.vue     |  4 ++--
 .../frontend/src/components/MkSignupDialog.vue     |  4 ++--
 packages/frontend/src/components/MkSparkle.vue     |  4 ++--
 .../src/components/MkSystemWebhookEditor.vue       |  8 ++++----
 packages/frontend/src/components/MkTagCloud.vue    |  8 ++++----
 packages/frontend/src/components/MkTextarea.vue    |  4 ++--
 packages/frontend/src/components/MkTimeline.vue    |  6 +++---
 .../src/components/MkTokenGenerateWindow.vue       |  4 ++--
 packages/frontend/src/components/MkTooltip.vue     |  4 ++--
 .../frontend/src/components/MkTutorialDialog.vue   |  6 +++---
 packages/frontend/src/components/MkUpdated.vue     |  6 +++---
 .../frontend/src/components/MkUserSelectDialog.vue |  4 ++--
 .../frontend/src/components/MkUserSetupDialog.vue  |  9 ++++----
 .../frontend/src/components/MkVisibilityPicker.vue |  4 ++--
 .../MkVisitorDashboard.ActiveUsersChart.vue        |  4 ++--
 .../frontend/src/components/MkWaitingDialog.vue    |  4 ++--
 packages/frontend/src/components/MkWindow.vue      |  4 ++--
 packages/frontend/src/components/global/MkA.vue    |  4 ++--
 packages/frontend/src/components/global/MkLazy.vue |  4 ++--
 .../src/components/global/MkPageHeader.tabs.vue    |  6 +++---
 .../src/components/global/MkPageHeader.vue         |  4 ++--
 .../src/components/global/MkStickyContainer.vue    |  3 +--
 .../frontend/src/components/grid/MkDataCell.vue    | 24 ++++++++++++----------
 packages/frontend/src/pages/about-misskey.vue      |  4 ++--
 packages/frontend/src/pages/admin/_header_.vue     |  6 +++---
 .../abuse-report/notification-recipient.editor.vue |  6 +++---
 packages/frontend/src/pages/admin/abuses.vue       |  4 ++--
 packages/frontend/src/pages/admin/invites.vue      |  6 +++---
 packages/frontend/src/pages/admin/modlog.vue       |  4 ++--
 .../src/pages/admin/overview.active-users.vue      |  4 ++--
 .../src/pages/admin/overview.ap-requests.vue       |  6 +++---
 packages/frontend/src/pages/admin/overview.pie.vue |  4 ++--
 .../src/pages/admin/overview.queue.chart.vue       |  4 ++--
 .../frontend/src/pages/admin/overview.queue.vue    | 10 ++++-----
 packages/frontend/src/pages/admin/overview.vue     |  4 ++--
 .../frontend/src/pages/admin/queue.chart.chart.vue |  4 ++--
 packages/frontend/src/pages/admin/queue.chart.vue  | 10 ++++-----
 packages/frontend/src/pages/admin/users.vue        |  4 ++--
 packages/frontend/src/pages/antenna-timeline.vue   |  8 ++++----
 .../frontend/src/pages/custom-emojis-manager.vue   |  4 ++--
 .../frontend/src/pages/drop-and-fusion.game.vue    |  6 +++---
 packages/frontend/src/pages/explore.users.vue      |  4 ++--
 packages/frontend/src/pages/explore.vue            |  4 ++--
 packages/frontend/src/pages/follow-requests.vue    |  4 ++--
 packages/frontend/src/pages/invite.vue             |  4 ++--
 packages/frontend/src/pages/my-clips/index.vue     |  4 ++--
 .../pages/page-editor/els/page-editor.el.text.vue  |  5 ++---
 .../frontend/src/pages/settings/2fa.qrdialog.vue   |  4 ++--
 .../pages/settings/avatar-decoration.dialog.vue    |  4 ++--
 packages/frontend/src/pages/settings/index.vue     |  4 ++--
 .../frontend/src/pages/settings/notifications.vue  |  4 ++--
 packages/frontend/src/pages/timeline.vue           |  6 +++---
 packages/frontend/src/pages/user-list-timeline.vue |  8 ++++----
 .../frontend/src/pages/user/activity.following.vue |  8 ++++----
 .../frontend/src/pages/user/activity.notes.vue     |  8 ++++----
 packages/frontend/src/pages/user/activity.pv.vue   |  8 ++++----
 .../frontend/src/pages/welcome.timeline.note.vue   |  4 ++--
 packages/frontend/src/pages/welcome.timeline.vue   |  6 +++---
 packages/frontend/src/ui/classic.sidebar.vue       |  4 ++--
 packages/frontend/src/ui/classic.vue               |  4 ++--
 packages/frontend/src/ui/deck.vue                  |  4 ++--
 packages/frontend/src/ui/deck/antenna-column.vue   |  4 ++--
 packages/frontend/src/ui/deck/channel-column.vue   |  4 ++--
 packages/frontend/src/ui/deck/column.vue           |  4 ++--
 packages/frontend/src/ui/deck/list-column.vue      |  4 ++--
 .../frontend/src/ui/deck/notifications-column.vue  |  6 +++---
 .../frontend/src/ui/deck/role-timeline-column.vue  |  4 ++--
 packages/frontend/src/ui/deck/tl-column.vue        |  4 ++--
 packages/frontend/src/ui/universal.vue             |  4 ++--
 packages/frontend/src/widgets/WidgetAichan.vue     |  4 ++--
 .../frontend/src/widgets/WidgetInstanceCloud.vue   |  8 ++++----
 packages/frontend/src/widgets/WidgetSlideshow.vue  |  8 ++++----
 127 files changed, 353 insertions(+), 359 deletions(-)

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

diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue
index a634a748e9..dbac5e9dd7 100644
--- a/packages/frontend/src/components/MkAbuseReportWindow.vue
+++ b/packages/frontend/src/components/MkAbuseReportWindow.vue
@@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script setup lang="ts">
-import { ref, shallowRef } from 'vue';
+import { ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkWindow from '@/components/MkWindow.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
@@ -47,7 +47,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const uiWindow = shallowRef<InstanceType<typeof MkWindow>>();
+const uiWindow = useTemplateRef('uiWindow');
 const comment = ref(props.initialComment ?? '');
 
 function send() {
diff --git a/packages/frontend/src/components/MkAnimBg.vue b/packages/frontend/src/components/MkAnimBg.vue
index 4bf6125af5..2938645557 100644
--- a/packages/frontend/src/components/MkAnimBg.vue
+++ b/packages/frontend/src/components/MkAnimBg.vue
@@ -8,10 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, shallowRef } from 'vue';
+import { onMounted, onUnmounted, useTemplateRef } from 'vue';
 import isChromatic from 'chromatic/isChromatic';
 
-const canvasEl = shallowRef<HTMLCanvasElement>();
+const canvasEl = useTemplateRef('canvasEl');
 
 const props = withDefaults(defineProps<{
 	scale?: number;
diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue
index 582bb137bc..6e5b29654b 100644
--- a/packages/frontend/src/components/MkAnnouncementDialog.vue
+++ b/packages/frontend/src/components/MkAnnouncementDialog.vue
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef } from 'vue';
+import { onMounted, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
@@ -37,8 +37,8 @@ const props = withDefaults(defineProps<{
 }>(), {
 });
 
-const rootEl = shallowRef<HTMLDivElement>();
-const modal = shallowRef<InstanceType<typeof MkModal>>();
+const rootEl = useTemplateRef('rootEl');
+const modal = useTemplateRef('modal');
 
 async function ok() {
 	if (props.announcement.needConfirmationToRead) {
diff --git a/packages/frontend/src/components/MkAntennaEditorDialog.vue b/packages/frontend/src/components/MkAntennaEditorDialog.vue
index 6d815d29f3..0ebf5abf4c 100644
--- a/packages/frontend/src/components/MkAntennaEditorDialog.vue
+++ b/packages/frontend/src/components/MkAntennaEditorDialog.vue
@@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef } from 'vue';
+import { useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import XAntennaEditor from '@/components/MkAntennaEditor.vue';
@@ -40,7 +40,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void,
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 
 function onAntennaCreated(newAntenna: Misskey.entities.Antenna) {
 	emit('created', newAntenna);
diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue
index 7436d4f1b2..5b747a2a1a 100644
--- a/packages/frontend/src/components/MkAutocomplete.vue
+++ b/packages/frontend/src/components/MkAutocomplete.vue
@@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts">
-import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
+import { markRaw, ref, useTemplateRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
 import sanitizeHtml from 'sanitize-html';
 import { emojilist, getEmojiName } from '@@/js/emojilist.js';
 import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@@/js/emoji-base.js';
@@ -139,7 +139,7 @@ const emit = defineEmits<{
 }>();
 
 const suggests = ref<Element>();
-const rootEl = shallowRef<HTMLDivElement>();
+const rootEl = useTemplateRef('rootEl');
 
 const fetching = ref(true);
 const users = ref<any[]>([]);
diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue
index 5b32ec0fc8..c81edc2a73 100644
--- a/packages/frontend/src/components/MkButton.vue
+++ b/packages/frontend/src/components/MkButton.vue
@@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onMounted, shallowRef } from 'vue';
+import { nextTick, onMounted, useTemplateRef } from 'vue';
 
 const props = defineProps<{
 	type?: 'button' | 'submit' | 'reset';
@@ -64,8 +64,8 @@ const emit = defineEmits<{
 	(ev: 'click', payload: MouseEvent): void;
 }>();
 
-const el = shallowRef<HTMLElement | null>(null);
-const ripples = shallowRef<HTMLElement | null>(null);
+const el = useTemplateRef('el');
+const ripples = useTemplateRef('ripples');
 
 onMounted(() => {
 	if (props.autofocus) {
diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue
index 30eafb7a5b..e4f953bda8 100644
--- a/packages/frontend/src/components/MkCaptcha.vue
+++ b/packages/frontend/src/components/MkCaptcha.vue
@@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue';
+import { ref, useTemplateRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue';
 import { store } from '@/store.js';
 
 // APIs provided by Captcha services
@@ -69,7 +69,7 @@ const emit = defineEmits<{
 
 const available = ref(false);
 
-const captchaEl = shallowRef<HTMLDivElement | undefined>();
+const captchaEl = useTemplateRef('captchaEl');
 const captchaWidgetId = ref<string | undefined>(undefined);
 const testcaptchaInput = ref('');
 const testcaptchaPassed = ref(false);
diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue
index d9f4558014..7e164362c1 100644
--- a/packages/frontend/src/components/MkChart.vue
+++ b/packages/frontend/src/components/MkChart.vue
@@ -45,12 +45,8 @@ export type ChartSrc =
 </script>
 
 <script lang="ts" setup>
-/* eslint-disable id-denylist --
-  Chart.js has a `data` attribute in most chart definitions, which triggers the
-  id-denylist violation when setting it. This is causing about 60+ lint issues.
-  As this is part of Chart.js's API it makes sense to disable the check here.
-*/
-import { onMounted, ref, shallowRef, watch } from 'vue';
+
+import { onMounted, ref, useTemplateRef, watch } from 'vue';
 import { Chart } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import { misskeyApiGet } from '@/utility/misskey-api.js';
@@ -96,7 +92,7 @@ const props = withDefaults(defineProps<{
 	nowForChromatic: undefined,
 });
 
-const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>();
+const legendEl = useTemplateRef('legendEl');
 
 const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
 const negate = arr => arr.map(x => -x);
@@ -134,7 +130,7 @@ let chartData: {
 	bytes?: boolean;
 } | null = null;
 
-const chartEl = shallowRef<HTMLCanvasElement | null>(null);
+const chartEl = useTemplateRef('chartEl');
 const fetching = ref(true);
 
 const getDate = (ago: number) => {
@@ -849,7 +845,7 @@ watch(() => [props.src, props.span], fetchAndRender);
 onMounted(() => {
 	fetchAndRender();
 });
-/* eslint-enable id-denylist */
+
 </script>
 
 <style lang="scss" module>
diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue
index 5bf2301e72..46fdf15b5d 100644
--- a/packages/frontend/src/components/MkCodeEditor.vue
+++ b/packages/frontend/src/components/MkCodeEditor.vue
@@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, watch, toRefs, shallowRef, nextTick } from 'vue';
+import { ref, watch, toRefs, useTemplateRef, nextTick } from 'vue';
 import { debounce } from 'throttle-debounce';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
@@ -61,7 +61,7 @@ const { modelValue } = toRefs(props);
 const v = ref<string>(modelValue.value ?? '');
 const focused = ref(false);
 const changed = ref(false);
-const inputEl = shallowRef<HTMLTextAreaElement>();
+const inputEl = useTemplateRef('inputEl');
 
 const focus = () => inputEl.value?.focus();
 
diff --git a/packages/frontend/src/components/MkColorInput.vue b/packages/frontend/src/components/MkColorInput.vue
index 55a32664de..50931cc318 100644
--- a/packages/frontend/src/components/MkColorInput.vue
+++ b/packages/frontend/src/components/MkColorInput.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef, toRefs } from 'vue';
+import { ref, useTemplateRef, toRefs } from 'vue';
 
 const props = defineProps<{
 	modelValue: string | null;
@@ -39,7 +39,7 @@ const emit = defineEmits<{
 
 const { modelValue } = toRefs(props);
 const v = ref(modelValue.value);
-const inputEl = shallowRef<HTMLElement>();
+const inputEl = useTemplateRef('inputEl');
 
 const onInput = () => {
 	emit('update:modelValue', v.value ?? '');
diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue
index 169f3df7a4..39ca39aad7 100644
--- a/packages/frontend/src/components/MkContainer.vue
+++ b/packages/frontend/src/components/MkContainer.vue
@@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
+import { onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
 import { prefer } from '@/preferences.js';
 import { i18n } from '@/i18n.js';
 
@@ -58,9 +58,9 @@ const props = withDefaults(defineProps<{
 	maxHeight: null,
 });
 
-const rootEl = shallowRef<HTMLElement>();
-const contentEl = shallowRef<HTMLElement>();
-const headerEl = shallowRef<HTMLElement>();
+const rootEl = useTemplateRef('rootEl');
+const contentEl = useTemplateRef('contentEl');
+const headerEl = useTemplateRef('headerEl');
 const showBody = ref(props.expanded);
 const ignoreOmit = ref(false);
 const omitted = ref(false);
diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue
index 46da0840fa..e47dba4bae 100644
--- a/packages/frontend/src/components/MkContextMenu.vue
+++ b/packages/frontend/src/components/MkContextMenu.vue
@@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onBeforeUnmount, shallowRef, ref } from 'vue';
+import { onMounted, onBeforeUnmount, useTemplateRef, ref } from 'vue';
 import MkMenu from './MkMenu.vue';
 import type { MenuItem } from '@/types/menu.js';
 import contains from '@/utility/contains.js';
@@ -34,7 +34,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const rootEl = shallowRef<HTMLDivElement>();
+const rootEl = useTemplateRef('rootEl');
 
 const zIndex = ref<number>(os.claimZIndex('high'));
 
diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue
index 4a89fb30ca..bd2e6a9cbc 100644
--- a/packages/frontend/src/components/MkCropperDialog.vue
+++ b/packages/frontend/src/components/MkCropperDialog.vue
@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef, ref } from 'vue';
+import { onMounted, useTemplateRef, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import Cropper from 'cropperjs';
 import tinycolor from 'tinycolor2';
@@ -56,8 +56,8 @@ const props = defineProps<{
 }>();
 
 const imgUrl = getProxiedImageUrl(props.file.url, undefined, true);
-const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
-const imgEl = shallowRef<HTMLImageElement>();
+const dialogEl = useTemplateRef('dialogEl');
+const imgEl = useTemplateRef('imgEl');
 let cropper: Cropper | null = null;
 const loading = ref(true);
 
diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue
index 86d6269c69..54fda6bf7c 100644
--- a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue
+++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue
@@ -57,14 +57,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import * as Misskey from 'misskey-js';
-import { shallowRef } from 'vue';
+import { useTemplateRef } from 'vue';
 import MkLink from '@/components/MkLink.vue';
 import { i18n } from '@/i18n.js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
 
 const props = defineProps<{
-  emoji: Misskey.entities.EmojiDetailed,
+	emoji: Misskey.entities.EmojiDetailed,
 }>();
 
 const emit = defineEmits<{
@@ -73,7 +73,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialogEl = useTemplateRef('dialogEl');
 
 function cancel() {
 	emit('cancel');
diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue
index 312c7ab58f..81d508c161 100644
--- a/packages/frontend/src/components/MkDialog.vue
+++ b/packages/frontend/src/components/MkDialog.vue
@@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef, computed } from 'vue';
+import { ref, useTemplateRef, computed } from 'vue';
 import MkModal from '@/components/MkModal.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
@@ -117,7 +117,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
+const modal = useTemplateRef('modal');
 
 const inputValue = ref<string | number | null>(props.input?.default ?? null);
 const selectedValue = ref(props.select?.default ?? null);
diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue
index 270ef51916..edbdf1cca1 100644
--- a/packages/frontend/src/components/MkDrive.vue
+++ b/packages/frontend/src/components/MkDrive.vue
@@ -96,7 +96,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
+import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkButton from './MkButton.vue';
 import type { MenuItem } from '@/types/menu.js';
@@ -129,8 +129,8 @@ const emit = defineEmits<{
 	(ev: 'open-folder', v: Misskey.entities.DriveFolder): void;
 }>();
 
-const loadMoreFiles = shallowRef<InstanceType<typeof MkButton>>();
-const fileInput = shallowRef<HTMLInputElement>();
+const loadMoreFiles = useTemplateRef('loadMoreFiles');
+const fileInput = useTemplateRef('fileInput');
 
 const folder = ref<Misskey.entities.DriveFolder | null>(null);
 const files = ref<Misskey.entities.DriveFile[]>([]);
diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveSelectDialog.vue
index f1ecc27123..1b9455e3f3 100644
--- a/packages/frontend/src/components/MkDriveSelectDialog.vue
+++ b/packages/frontend/src/components/MkDriveSelectDialog.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef } from 'vue';
+import { ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import XDrive from '@/components/MkDrive.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
@@ -43,7 +43,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 
 const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]);
 
diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
index e038b84ba7..a4e9547f90 100644
--- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
+++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
@@ -89,7 +89,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script setup lang="ts">
-import { shallowRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue';
+import { useTemplateRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue';
 import { url } from '@@/js/config.js';
 import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
 import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
@@ -121,7 +121,7 @@ const props = defineProps<{
 }>();
 
 //#region Modalの制御
-const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialogEl = useTemplateRef('dialogEl');
 
 function cancel() {
 	emit('cancel');
@@ -198,9 +198,9 @@ function doCopy() {
 //#endregion
 
 //#region プレビューのリサイズ
-const resizerRootEl = shallowRef<HTMLDivElement>();
+const resizerRootEl = useTemplateRef('resizerRootEl');
 const iframeLoading = ref(true);
-const iframeEl = shallowRef<HTMLIFrameElement>();
+const iframeEl = useTemplateRef('iframeEl');
 const iframeHeight = ref(0);
 const iframeScale = ref(1);
 const iframeStyle = computed(() => {
diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue
index c0883b1342..d4367f6ee8 100644
--- a/packages/frontend/src/components/MkEmojiPicker.vue
+++ b/packages/frontend/src/components/MkEmojiPicker.vue
@@ -115,7 +115,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef, computed, watch, onMounted } from 'vue';
+import { ref, useTemplateRef, computed, watch, onMounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import {
 	emojilist,
@@ -157,8 +157,8 @@ const emit = defineEmits<{
 	(ev: 'esc'): void;
 }>();
 
-const searchEl = shallowRef<HTMLInputElement>();
-const emojisEl = shallowRef<HTMLDivElement>();
+const searchEl = useTemplateRef('searchEl');
+const emojisEl = useTemplateRef('emojisEl');
 
 const {
 	emojiPickerScale,
diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue
index 6d7062b41c..4978933f73 100644
--- a/packages/frontend/src/components/MkEmojiPickerDialog.vue
+++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue
@@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import * as Misskey from 'misskey-js';
-import { shallowRef } from 'vue';
+import { useTemplateRef } from 'vue';
 import MkModal from '@/components/MkModal.vue';
 import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
 import { prefer } from '@/preferences.js';
@@ -64,8 +64,8 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
-const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>();
+const modal = useTemplateRef('modal');
+const picker = useTemplateRef('picker');
 
 function chosen(emoji: string) {
 	emit('done', emoji);
diff --git a/packages/frontend/src/components/MkFileCaptionEditWindow.vue b/packages/frontend/src/components/MkFileCaptionEditWindow.vue
index 76bb965101..120d5355b2 100644
--- a/packages/frontend/src/components/MkFileCaptionEditWindow.vue
+++ b/packages/frontend/src/components/MkFileCaptionEditWindow.vue
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef, ref } from 'vue';
+import { useTemplateRef, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
@@ -42,7 +42,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 
 const caption = ref(props.default);
 
diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue
index 554586b2f3..b9888d9b64 100644
--- a/packages/frontend/src/components/MkFoldableSection.vue
+++ b/packages/frontend/src/components/MkFoldableSection.vue
@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, ref, shallowRef, watch } from 'vue';
+import { onMounted, ref, useTemplateRef, watch } from 'vue';
 import { miLocalStorage } from '@/local-storage.js';
 import { prefer } from '@/preferences.js';
 import { getBgColor } from '@/utility/get-bg-color.js';
@@ -46,7 +46,7 @@ const props = withDefaults(defineProps<{
 	persistKey: null,
 });
 
-const rootEl = shallowRef<HTMLElement>();
+const rootEl = useTemplateRef('rootEl');
 const parentBg = ref<string | null>(null);
 // eslint-disable-next-line vue/no-setup-props-reactivity-loss
 const showBody = ref((props.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${props.persistKey}`) === 't') : props.expanded);
diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
index 1e2057d28e..b13972f66d 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onMounted, ref, shallowRef } from 'vue';
+import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
 import { prefer } from '@/preferences.js';
 import { getBgColor } from '@/utility/get-bg-color.js';
 
@@ -74,7 +74,7 @@ const props = withDefaults(defineProps<{
 	spacerMax: 22,
 });
 
-const rootEl = shallowRef<HTMLElement>();
+const rootEl = useTemplateRef('rootEl');
 const bgSame = ref(false);
 const opened = ref(props.defaultOpen);
 const openedAtLeastOnce = ref(props.defaultOpen);
diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue
index df39f34816..4756079e76 100644
--- a/packages/frontend/src/components/MkFormDialog.vue
+++ b/packages/frontend/src/components/MkFormDialog.vue
@@ -71,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { reactive, shallowRef } from 'vue';
+import { reactive, useTemplateRef } from 'vue';
 import MkInput from './MkInput.vue';
 import MkTextarea from './MkTextarea.vue';
 import MkSwitch from './MkSwitch.vue';
@@ -99,7 +99,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 const values = reactive({});
 
 for (const item in props.form) {
diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue
index 8a84bfd541..28bb936755 100644
--- a/packages/frontend/src/components/MkHeatmap.vue
+++ b/packages/frontend/src/components/MkHeatmap.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, nextTick, watch, shallowRef, ref } from 'vue';
+import { onMounted, nextTick, watch, useTemplateRef, ref } from 'vue';
 import { Chart } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import { misskeyApi } from '@/utility/misskey-api.js';
@@ -35,8 +35,8 @@ const props = withDefaults(defineProps<{
 	label: '',
 });
 
-const rootEl = shallowRef<HTMLDivElement | null>(null);
-const chartEl = shallowRef<HTMLCanvasElement | null>(null);
+const rootEl = useTemplateRef('rootEl');
+const chartEl = useTemplateRef('chartEl');
 const now = new Date();
 let chartInstance: Chart | null = null;
 const fetching = ref(true);
diff --git a/packages/frontend/src/components/MkHorizontalSwipe.vue b/packages/frontend/src/components/MkHorizontalSwipe.vue
index ad85660fc4..849136eb8f 100644
--- a/packages/frontend/src/components/MkHorizontalSwipe.vue
+++ b/packages/frontend/src/components/MkHorizontalSwipe.vue
@@ -26,12 +26,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 </div>
 </template>
 <script lang="ts" setup>
-import { ref, shallowRef, computed, nextTick, watch } from 'vue';
+import { ref, useTemplateRef, computed, nextTick, watch } from 'vue';
 import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
 import { isHorizontalSwipeSwiping as isSwiping } from '@/utility/touch.js';
 import { prefer } from '@/preferences.js';
 
-const rootEl = shallowRef<HTMLDivElement>();
+const rootEl = useTemplateRef('rootEl');
 
 const tabModel = defineModel<string>('tab');
 
diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue
index 6585784e42..b86aca19fd 100644
--- a/packages/frontend/src/components/MkImgWithBlurhash.vue
+++ b/packages/frontend/src/components/MkImgWithBlurhash.vue
@@ -83,7 +83,7 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol
 </script>
 
 <script lang="ts" setup>
-import { computed, nextTick, onMounted, onUnmounted, shallowRef, watch, ref } from 'vue';
+import { computed, nextTick, onMounted, onUnmounted, useTemplateRef, watch, ref } from 'vue';
 import { v4 as uuid } from 'uuid';
 import { render } from 'buraha';
 import { prefer } from '@/preferences.js';
@@ -120,9 +120,9 @@ const props = withDefaults(defineProps<{
 });
 
 const viewId = uuid();
-const canvas = shallowRef<HTMLCanvasElement>();
-const root = shallowRef<HTMLDivElement>();
-const img = shallowRef<HTMLImageElement>();
+const canvas = useTemplateRef('canvas');
+const root = useTemplateRef('root');
+const img = useTemplateRef('img');
 const loaded = ref(false);
 const canvasWidth = ref(64);
 const canvasHeight = ref(64);
diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue
index a66e64311a..49cb5cb544 100644
--- a/packages/frontend/src/components/MkInput.vue
+++ b/packages/frontend/src/components/MkInput.vue
@@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue';
+import { onMounted, onUnmounted, nextTick, ref, useTemplateRef, watch, computed, toRefs } from 'vue';
 import { debounce } from 'throttle-debounce';
 import { useInterval } from '@@/js/use-interval.js';
 import type { InputHTMLAttributes } from 'vue';
@@ -92,9 +92,9 @@ const focused = ref(false);
 const changed = ref(false);
 const invalid = ref(false);
 const filled = computed(() => v.value !== '' && v.value != null);
-const inputEl = shallowRef<HTMLInputElement>();
-const prefixEl = shallowRef<HTMLElement>();
-const suffixEl = shallowRef<HTMLElement>();
+const inputEl = useTemplateRef('inputEl');
+const prefixEl = useTemplateRef('prefixEl');
+const suffixEl = useTemplateRef('suffixEl');
 const height =
 	props.small ? 33 :
 	props.large ? 39 :
diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue
index 3113c6fad6..76bec9bc66 100644
--- a/packages/frontend/src/components/MkInstanceStats.vue
+++ b/packages/frontend/src/components/MkInstanceStats.vue
@@ -84,8 +84,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, ref, computed, shallowRef } from 'vue';
+import { onMounted, ref, computed, useTemplateRef } from 'vue';
 import { Chart } from 'chart.js';
+import type { HeatmapSource } from '@/components/MkHeatmap.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkChart from '@/components/MkChart.vue';
 import { useChartTooltip } from '@/use/use-chart-tooltip.js';
@@ -95,7 +96,6 @@ import { misskeyApiGet } from '@/utility/misskey-api.js';
 import { instance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
 import MkHeatmap from '@/components/MkHeatmap.vue';
-import type { HeatmapSource } from '@/components/MkHeatmap.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue';
 import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue';
@@ -109,8 +109,8 @@ const chartLimit = 500;
 const chartSpan = ref<'hour' | 'day'>('hour');
 const chartSrc = ref('active-users');
 const heatmapSrc = ref<HeatmapSource>('active-users');
-const subDoughnutEl = shallowRef<HTMLCanvasElement>();
-const pubDoughnutEl = shallowRef<HTMLCanvasElement>();
+const subDoughnutEl = useTemplateRef('subDoughnutEl');
+const pubDoughnutEl = useTemplateRef('pubDoughnutEl');
 
 const { handler: externalTooltipHandler1 } = useChartTooltip({
 	position: 'middle',
diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue
index 9a78eb67f2..3e5a88a170 100644
--- a/packages/frontend/src/components/MkLaunchPad.vue
+++ b/packages/frontend/src/components/MkLaunchPad.vue
@@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef } from 'vue';
+import { useTemplateRef } from 'vue';
 import MkModal from '@/components/MkModal.vue';
 import { navbarItemDef } from '@/navbar.js';
 import { deviceKind } from '@/utility/device-kind.js';
@@ -48,7 +48,7 @@ const preferedModalType = (deviceKind === 'desktop' && props.src != null) ? 'pop
 	deviceKind === 'smartphone' ? 'drawer' :
 	'dialog';
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
+const modal = useTemplateRef('modal');
 
 const menu = prefer.s.menu;
 
diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue
index 28860d0cf6..db6deb161a 100644
--- a/packages/frontend/src/components/MkMediaAudio.vue
+++ b/packages/frontend/src/components/MkMediaAudio.vue
@@ -88,7 +88,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue';
+import { useTemplateRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { MenuItem } from '@/types/menu.js';
 import type { Keymap } from '@/utility/hotkey.js';
@@ -151,8 +151,8 @@ function hasFocus() {
 	return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement);
 }
 
-const playerEl = shallowRef<HTMLDivElement>();
-const audioEl = shallowRef<HTMLAudioElement>();
+const playerEl = useTemplateRef('playerEl');
+const audioEl = useTemplateRef('audioEl');
 
 // eslint-disable-next-line vue/no-setup-props-reactivity-loss
 const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore'));
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index 8a7a423869..3c066650d9 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, onMounted, onUnmounted, shallowRef } from 'vue';
+import { computed, onMounted, onUnmounted, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import PhotoSwipeLightbox from 'photoswipe/lightbox';
 import PhotoSwipe from 'photoswipe';
@@ -46,7 +46,7 @@ const props = defineProps<{
 	raw?: boolean;
 }>();
 
-const gallery = shallowRef<HTMLDivElement>();
+const gallery = useTemplateRef('gallery');
 const pswpZIndex = os.claimZIndex('middle');
 document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
 const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue
index 9674cb4461..5475e703a5 100644
--- a/packages/frontend/src/components/MkMediaVideo.vue
+++ b/packages/frontend/src/components/MkMediaVideo.vue
@@ -109,7 +109,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
+import { ref, useTemplateRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { MenuItem } from '@/types/menu.js';
 import type { Keymap } from '@/utility/hotkey.js';
@@ -299,8 +299,8 @@ async function toggleSensitive(file: Misskey.entities.DriveFile) {
 }
 
 // MediaControl: Video State
-const videoEl = shallowRef<HTMLVideoElement>();
-const playerEl = shallowRef<HTMLDivElement>();
+const videoEl = useTemplateRef('videoEl');
+const playerEl = useTemplateRef('playerEl');
 const isHoverring = ref(false);
 const controlsShowing = computed(() => {
 	if (!oncePlayed.value) return true;
diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue
index 086573ba6d..f7cd72b6c6 100644
--- a/packages/frontend/src/components/MkMenu.child.vue
+++ b/packages/frontend/src/components/MkMenu.child.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onMounted, onUnmounted, provide, shallowRef, watch } from 'vue';
+import { nextTick, onMounted, onUnmounted, provide, useTemplateRef, watch } from 'vue';
 import MkMenu from './MkMenu.vue';
 import type { MenuItem } from '@/types/menu.js';
 
@@ -28,7 +28,7 @@ const emit = defineEmits<{
 
 provide('isNestingMenu', true);
 
-const el = shallowRef<HTMLElement>();
+const el = useTemplateRef('el');
 const align = 'left';
 
 const SCROLLBAR_THICKNESS = 16;
diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue
index e3c27c5f6e..5438dac3a8 100644
--- a/packages/frontend/src/components/MkMenu.vue
+++ b/packages/frontend/src/components/MkMenu.vue
@@ -179,7 +179,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts">
-import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, unref, watch } from 'vue';
+import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, unref, watch } from 'vue';
 import type { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
 import type { Keymap } from '@/utility/hotkey.js';
 import MkSwitchButton from '@/components/MkSwitch.button.vue';
@@ -212,11 +212,11 @@ const big = isTouchUsing;
 
 const isNestingMenu = inject<boolean>('isNestingMenu', false);
 
-const itemsEl = shallowRef<HTMLElement>();
+const itemsEl = useTemplateRef('itemsEl');
 
 const items2 = ref<InnerMenuItem[]>();
 
-const child = shallowRef<InstanceType<typeof XChild>>();
+const child = useTemplateRef('child');
 
 const keymap = {
 	'up|k|shift+tab': {
@@ -257,7 +257,7 @@ watch(() => props.items, () => {
 });
 
 const childMenu = ref<MenuItem[] | null>();
-const childTarget = shallowRef<HTMLElement | null>();
+const childTarget = useTemplateRef('childTarget');
 
 function closeChild() {
 	childMenu.value = null;
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index 8b3086d55e..b5c93df4ed 100644
--- a/packages/frontend/src/components/MkModal.vue
+++ b/packages/frontend/src/components/MkModal.vue
@@ -42,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, normalizeClass, onMounted, onUnmounted, provide, watch, ref, shallowRef, computed } from 'vue';
+import { nextTick, normalizeClass, onMounted, onUnmounted, provide, watch, ref, useTemplateRef, computed } from 'vue';
 import type { Keymap } from '@/utility/hotkey.js';
 import * as os from '@/os.js';
 import { isTouchUsing } from '@/utility/touch.js';
@@ -100,8 +100,8 @@ const maxHeight = ref<number>();
 const fixed = ref(false);
 const transformOrigin = ref('center');
 const showing = ref(true);
-const modalRootEl = shallowRef<HTMLElement>();
-const content = shallowRef<HTMLElement>();
+const modalRootEl = useTemplateRef('modalRootEl');
+const content = useTemplateRef('content');
 const zIndex = os.claimZIndex(props.zPriority);
 const useSendAnime = ref(false);
 const type = computed<ModalTypes>(() => {
diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue
index f06cfffee4..19989e375b 100644
--- a/packages/frontend/src/components/MkModalWindow.vue
+++ b/packages/frontend/src/components/MkModalWindow.vue
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, shallowRef, ref } from 'vue';
+import { onMounted, onUnmounted, useTemplateRef, ref } from 'vue';
 import MkModal from './MkModal.vue';
 
 const props = withDefaults(defineProps<{
@@ -47,9 +47,9 @@ const emit = defineEmits<{
 	(event: 'esc'): void;
 }>();
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
-const rootEl = shallowRef<HTMLElement>();
-const headerEl = shallowRef<HTMLElement>();
+const modal = useTemplateRef('modal');
+const rootEl = useTemplateRef('rootEl');
+const headerEl = useTemplateRef('headerEl');
 const bodyWidth = ref(0);
 const bodyHeight = ref(0);
 
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 1f18b173a0..73ff85b150 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -178,7 +178,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, inject, onMounted, ref, shallowRef, watch, provide } from 'vue';
+import { computed, inject, onMounted, ref, useTemplateRef, watch, provide } from 'vue';
 import * as mfm from 'mfm-js';
 import * as Misskey from 'misskey-js';
 import { isLink } from '@@/js/is-link.js';
@@ -271,14 +271,14 @@ if (noteViewInterruptors.length > 0) {
 
 const isRenote = Misskey.note.isPureRenote(note.value);
 
-const rootEl = shallowRef<HTMLElement>();
-const menuButton = shallowRef<HTMLElement>();
-const renoteButton = shallowRef<HTMLElement>();
-const renoteTime = shallowRef<HTMLElement>();
-const reactButton = shallowRef<HTMLElement>();
-const clipButton = shallowRef<HTMLElement>();
+const rootEl = useTemplateRef('rootEl');
+const menuButton = useTemplateRef('menuButton');
+const renoteButton = useTemplateRef('renoteButton');
+const renoteTime = useTemplateRef('renoteTime');
+const reactButton = useTemplateRef('reactButton');
+const clipButton = useTemplateRef('clipButton');
 const appearNote = computed(() => getAppearNote(note.value));
-const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
+const galleryEl = useTemplateRef('galleryEl');
 const isMyRenote = $i && ($i.id === note.value.userId);
 const showContent = ref(false);
 const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 6af8acb2df..4f74432041 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -211,7 +211,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, inject, onMounted, provide, ref, shallowRef } from 'vue';
+import { computed, inject, onMounted, provide, ref, useTemplateRef } from 'vue';
 import * as mfm from 'mfm-js';
 import * as Misskey from 'misskey-js';
 import { isLink } from '@@/js/is-link.js';
@@ -290,14 +290,14 @@ if (noteViewInterruptors.length > 0) {
 
 const isRenote = Misskey.note.isPureRenote(note.value);
 
-const rootEl = shallowRef<HTMLElement>();
-const menuButton = shallowRef<HTMLElement>();
-const renoteButton = shallowRef<HTMLElement>();
-const renoteTime = shallowRef<HTMLElement>();
-const reactButton = shallowRef<HTMLElement>();
-const clipButton = shallowRef<HTMLElement>();
+const rootEl = useTemplateRef('rootEl');
+const menuButton = useTemplateRef('menuButton');
+const renoteButton = useTemplateRef('renoteButton');
+const renoteTime = useTemplateRef('renoteTime');
+const reactButton = useTemplateRef('reactButton');
+const clipButton = useTemplateRef('clipButton');
 const appearNote = computed(() => getAppearNote(note.value));
-const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
+const galleryEl = useTemplateRef('galleryEl');
 const isMyRenote = $i && ($i.id === note.value.userId);
 const showContent = ref(false);
 const isDeleted = ref(false);
diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue
index 62d4350400..cee226ee14 100644
--- a/packages/frontend/src/components/MkNotes.vue
+++ b/packages/frontend/src/components/MkNotes.vue
@@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef } from 'vue';
+import { useTemplateRef } from 'vue';
 import type { Paging } from '@/components/MkPagination.vue';
 import MkNote from '@/components/MkNote.vue';
 import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
@@ -46,7 +46,7 @@ const props = defineProps<{
 	disableAutoLoad?: boolean;
 }>();
 
-const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const pagingComponent = useTemplateRef('pagingComponent');
 
 defineExpose({
 	pagingComponent,
diff --git a/packages/frontend/src/components/MkNotificationSelectWindow.vue b/packages/frontend/src/components/MkNotificationSelectWindow.vue
index a0fb7fea83..d074dceb2f 100644
--- a/packages/frontend/src/components/MkNotificationSelectWindow.vue
+++ b/packages/frontend/src/components/MkNotificationSelectWindow.vue
@@ -30,13 +30,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef } from 'vue';
-import type { Ref } from 'vue';
+import { ref, useTemplateRef } from 'vue';
+import { notificationTypes } from '@@/js/const.js';
 import MkSwitch from './MkSwitch.vue';
 import MkInfo from './MkInfo.vue';
 import MkButton from './MkButton.vue';
+import type { Ref } from 'vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
-import { notificationTypes } from '@@/js/const.js';
 import { i18n } from '@/i18n.js';
 
 type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>>;
@@ -52,7 +52,7 @@ const props = withDefaults(defineProps<{
 	excludeTypes: () => [],
 });
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 
 const typesMap = notificationTypes.reduce((p, t) => ({ ...p, [t]: ref<boolean>(!props.excludeTypes.includes(t)) }), {} as TypesMap);
 
diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue
index 9f75a75fb6..08fc846327 100644
--- a/packages/frontend/src/components/MkNotifications.vue
+++ b/packages/frontend/src/components/MkNotifications.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onUnmounted, onDeactivated, onMounted, computed, shallowRef, onActivated } from 'vue';
+import { onUnmounted, onDeactivated, onMounted, computed, useTemplateRef, onActivated } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { notificationTypes } from '@@/js/const.js';
 import MkPagination from '@/components/MkPagination.vue';
@@ -41,7 +41,7 @@ const props = defineProps<{
 	excludeTypes?: typeof notificationTypes[number][];
 }>();
 
-const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const pagingComponent = useTemplateRef('pagingComponent');
 
 const pagination = computed(() => prefer.r.useGroupedNotifications.value ? {
 	endpoint: 'i/notifications-grouped' as const,
diff --git a/packages/frontend/src/components/MkOmit.vue b/packages/frontend/src/components/MkOmit.vue
index a05176e2f4..04276b47fe 100644
--- a/packages/frontend/src/components/MkOmit.vue
+++ b/packages/frontend/src/components/MkOmit.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, shallowRef, ref } from 'vue';
+import { onMounted, onUnmounted, useTemplateRef, ref } from 'vue';
 import { i18n } from '@/i18n.js';
 
 const props = withDefaults(defineProps<{
@@ -22,7 +22,7 @@ const props = withDefaults(defineProps<{
 	maxHeight: 200,
 });
 
-const content = shallowRef<HTMLElement>();
+const content = useTemplateRef('content');
 const omitted = ref(false);
 const ignoreOmit = ref(false);
 
diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue
index 4fbdc51d22..5cb00c5292 100644
--- a/packages/frontend/src/components/MkPageWindow.vue
+++ b/packages/frontend/src/components/MkPageWindow.vue
@@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue';
+import { computed, onMounted, onUnmounted, provide, ref, useTemplateRef } from 'vue';
 import { url } from '@@/js/config.js';
 import type { PageMetadata } from '@/page.js';
 import RouterView from '@/components/global/RouterView.vue';
@@ -57,7 +57,7 @@ const emit = defineEmits<{
 const windowRouter = createRouter(props.initialPath);
 
 const pageMetadata = ref<null | PageMetadata>(null);
-const windowEl = shallowRef<InstanceType<typeof MkWindow>>();
+const windowEl = useTemplateRef('windowEl');
 const history = ref<{ path: string; }[]>([{
 	path: windowRouter.getCurrentFullPath(),
 }]);
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index 071dbba5eb..6a1a91a9f4 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts">
-import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue';
+import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, useTemplateRef, watch } from 'vue';
 import * as Misskey from 'misskey-js';
 import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
 import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js';
@@ -106,7 +106,7 @@ const emit = defineEmits<{
 	(ev: 'status', error: boolean): void;
 }>();
 
-const rootEl = shallowRef<HTMLElement>();
+const rootEl = useTemplateRef('rootEl');
 
 // 遡り中かどうか
 const backed = ref(false);
diff --git a/packages/frontend/src/components/MkPasswordDialog.vue b/packages/frontend/src/components/MkPasswordDialog.vue
index 2068ca39a1..2abf8669ed 100644
--- a/packages/frontend/src/components/MkPasswordDialog.vue
+++ b/packages/frontend/src/components/MkPasswordDialog.vue
@@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef, ref } from 'vue';
+import { onMounted, useTemplateRef, ref } from 'vue';
 import MkInput from '@/components/MkInput.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
@@ -54,8 +54,8 @@ const emit = defineEmits<{
 	(ev: 'cancelled'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
-const passwordInput = shallowRef<InstanceType<typeof MkInput>>();
+const dialog = useTemplateRef('dialog');
+const passwordInput = useTemplateRef('passwordInput');
 const password = ref('');
 const isBackupCode = ref(false);
 const token = ref<string | null>(null);
diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue
index df664e49f7..232cc005e1 100644
--- a/packages/frontend/src/components/MkPopupMenu.vue
+++ b/packages/frontend/src/components/MkPopupMenu.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef } from 'vue';
+import { ref, useTemplateRef } from 'vue';
 import MkModal from './MkModal.vue';
 import MkMenu from './MkMenu.vue';
 import type { MenuItem } from '@/types/menu.js';
@@ -28,7 +28,7 @@ const emit = defineEmits<{
 	(ev: 'closing'): void;
 }>();
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
+const modal = useTemplateRef('modal');
 const manualShowing = ref(true);
 const hiding = ref(false);
 
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 6e941f75be..0d37d973f0 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -99,7 +99,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed } from 'vue';
+import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef } from 'vue';
 import * as mfm from 'mfm-js';
 import * as Misskey from 'misskey-js';
 import insertTextAtCursor from 'insert-text-at-cursor';
@@ -165,11 +165,11 @@ const emit = defineEmits<{
 	(ev: 'fileChangeSensitive', fileId: string, to: boolean): void;
 }>();
 
-const textareaEl = shallowRef<HTMLTextAreaElement | null>(null);
-const cwInputEl = shallowRef<HTMLInputElement | null>(null);
-const hashtagsInputEl = shallowRef<HTMLInputElement | null>(null);
-const visibilityButton = shallowRef<HTMLElement>();
-const otherSettingsButton = shallowRef<HTMLElement>();
+const textareaEl = useTemplateRef('textareaEl');
+const cwInputEl = useTemplateRef('cwInputEl');
+const hashtagsInputEl = useTemplateRef('hashtagsInputEl');
+const visibilityButton = useTemplateRef('visibilityButton');
+const otherSettingsButton = useTemplateRef('otherSettingsButton');
 
 const posting = ref(false);
 const posted = ref(false);
diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue
index 4bef5824fe..c467e29df6 100644
--- a/packages/frontend/src/components/MkPostFormDialog.vue
+++ b/packages/frontend/src/components/MkPostFormDialog.vue
@@ -25,10 +25,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef } from 'vue';
+import { useTemplateRef } from 'vue';
+import type { PostFormProps } from '@/types/post-form.js';
 import MkModal from '@/components/MkModal.vue';
 import MkPostForm from '@/components/MkPostForm.vue';
-import type { PostFormProps } from '@/types/post-form.js';
 
 const props = withDefaults(defineProps<PostFormProps & {
 	instant?: boolean;
@@ -42,8 +42,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
-const form = shallowRef<InstanceType<typeof MkPostForm>>();
+const modal = useTemplateRef('modal');
 
 function onPosted() {
 	modal.value?.close({
diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue
index af4e2a4fe0..1fbf00d212 100644
--- a/packages/frontend/src/components/MkPullToRefresh.vue
+++ b/packages/frontend/src/components/MkPullToRefresh.vue
@@ -23,9 +23,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, ref, shallowRef } from 'vue';
-import { i18n } from '@/i18n.js';
+import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
 import { getScrollContainer } from '@@/js/scroll.js';
+import { i18n } from '@/i18n.js';
 import { isHorizontalSwipeSwiping } from '@/utility/touch.js';
 
 const SCROLL_STOP = 10;
@@ -43,7 +43,7 @@ const pullDistance = ref(0);
 let supportPointerDesktop = false;
 let startScreenY: number | null = null;
 
-const rootEl = shallowRef<HTMLDivElement>();
+const rootEl = useTemplateRef('rootEl');
 let scrollEl: HTMLElement | null = null;
 
 let disabled = false;
diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue
index 8e5be38e03..118dbbe15a 100644
--- a/packages/frontend/src/components/MkRange.vue
+++ b/packages/frontend/src/components/MkRange.vue
@@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
+import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
 import { isTouchUsing } from '@/utility/touch.js';
 import * as os from '@/os.js';
 
@@ -58,8 +58,8 @@ const emit = defineEmits<{
 	(ev: 'dragEnded', value: number): void;
 }>();
 
-const containerEl = shallowRef<HTMLElement>();
-const thumbEl = shallowRef<HTMLElement>();
+const containerEl = useTemplateRef('containerEl');
+const thumbEl = useTemplateRef('thumbEl');
 
 const rawValue = ref((props.modelValue - props.min) / (props.max - props.min));
 const steppedRawValue = computed(() => {
diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue
index 7551524246..453253f0fc 100644
--- a/packages/frontend/src/components/MkReactionIcon.vue
+++ b/packages/frontend/src/components/MkReactionIcon.vue
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, shallowRef } from 'vue';
+import { defineAsyncComponent, useTemplateRef } from 'vue';
 import { useTooltip } from '@/use/use-tooltip.js';
 import * as os from '@/os.js';
 
@@ -20,7 +20,7 @@ const props = defineProps<{
 	withTooltip?: boolean;
 }>();
 
-const elRef = shallowRef();
+const elRef = useTemplateRef('elRef');
 
 if (props.withTooltip) {
 	useTooltip(elRef, (showing) => {
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index 062894314d..590b0f6f19 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, inject, onMounted, shallowRef, watch } from 'vue';
+import { computed, inject, onMounted, useTemplateRef, watch } from 'vue';
 import * as Misskey from 'misskey-js';
 import { getUnicodeEmoji } from '@@/js/emojilist.js';
 import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue';
@@ -50,7 +50,7 @@ const emit = defineEmits<{
 	(ev: 'reactionToggled', emoji: string, newCount: number): void;
 }>();
 
-const buttonEl = shallowRef<HTMLElement>();
+const buttonEl = useTemplateRef('buttonEl');
 
 const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, ''));
 const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction));
diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue
index a962b37e4a..1ab2397337 100644
--- a/packages/frontend/src/components/MkRetentionHeatmap.vue
+++ b/packages/frontend/src/components/MkRetentionHeatmap.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, nextTick, shallowRef, ref } from 'vue';
+import { onMounted, nextTick, useTemplateRef, ref } from 'vue';
 import { Chart } from 'chart.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
@@ -23,8 +23,8 @@ import { initChart } from '@/utility/init-chart.js';
 
 initChart();
 
-const rootEl = shallowRef<HTMLDivElement | null>(null);
-const chartEl = shallowRef<HTMLCanvasElement | null>(null);
+const rootEl = useTemplateRef('rootEl');
+const chartEl = useTemplateRef('chartEl');
 let chartInstance: Chart | null = null;
 const fetching = ref(true);
 
diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue
index 6fa19efed5..60b3f17b14 100644
--- a/packages/frontend/src/components/MkRetentionLineChart.vue
+++ b/packages/frontend/src/components/MkRetentionLineChart.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef } from 'vue';
+import { onMounted, useTemplateRef } from 'vue';
 import { Chart } from 'chart.js';
 import tinycolor from 'tinycolor2';
 import { store } from '@/store.js';
@@ -20,7 +20,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 
 initChart();
 
-const chartEl = shallowRef<HTMLCanvasElement | null>(null);
+const chartEl = useTemplateRef('chartEl');
 
 const { handler: externalTooltipHandler } = useChartTooltip();
 
diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue
index a970d1ce00..f122da7468 100644
--- a/packages/frontend/src/components/MkSigninDialog.vue
+++ b/packages/frontend/src/components/MkSigninDialog.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import * as Misskey from 'misskey-js';
-import { shallowRef } from 'vue';
+import { useTemplateRef } from 'vue';
 import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
 import MkSignin from '@/components/MkSignin.vue';
 import MkModal from '@/components/MkModal.vue';
@@ -46,7 +46,7 @@ const emit = defineEmits<{
 	(ev: 'cancelled'): void;
 }>();
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
+const modal = useTemplateRef('modal');
 
 function onClose() {
 	emit('cancelled');
diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue
index 6fb9d77837..5c9047dd43 100644
--- a/packages/frontend/src/components/MkSignupDialog.vue
+++ b/packages/frontend/src/components/MkSignupDialog.vue
@@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef, ref } from 'vue';
+import { useTemplateRef, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import XSignup from '@/components/MkSignupDialog.form.vue';
 import XServerRules from '@/components/MkSignupDialog.rules.vue';
@@ -52,7 +52,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 
 const isAcceptedServerRule = ref(false);
 
diff --git a/packages/frontend/src/components/MkSparkle.vue b/packages/frontend/src/components/MkSparkle.vue
index b3fc67c0df..2400c5ec7f 100644
--- a/packages/frontend/src/components/MkSparkle.vue
+++ b/packages/frontend/src/components/MkSparkle.vue
@@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, ref, shallowRef } from 'vue';
+import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
 
 const particles = ref<{
 	id: string,
@@ -66,7 +66,7 @@ const particles = ref<{
 	dur: number,
 	color: string
 }[]>([]);
-const el = shallowRef<HTMLElement>();
+const el = useTemplateRef('el');
 const width = ref(0);
 const height = ref(0);
 const colors = ['#FF1493', '#00FFFF', '#FFE202', '#FFE202', '#FFE202'];
diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.vue b/packages/frontend/src/components/MkSystemWebhookEditor.vue
index 2e1a96c326..f819f82923 100644
--- a/packages/frontend/src/components/MkSystemWebhookEditor.vue
+++ b/packages/frontend/src/components/MkSystemWebhookEditor.vue
@@ -92,15 +92,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, ref, shallowRef, toRefs } from 'vue';
+import { computed, onMounted, ref, useTemplateRef, toRefs } from 'vue';
 import * as Misskey from 'misskey-js';
-import MkInput from '@/components/MkInput.vue';
-import MkSwitch from '@/components/MkSwitch.vue';
 import type {
 	MkSystemWebhookEditorProps,
 	MkSystemWebhookResult,
 	SystemWebhookEventType,
 } from '@/components/MkSystemWebhookEditor.impl.js';
+import MkInput from '@/components/MkInput.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
 import { i18n } from '@/i18n.js';
 import MkButton from '@/components/MkButton.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
@@ -122,7 +122,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialogEl = useTemplateRef('dialogEl');
 
 const props = defineProps<MkSystemWebhookEditorProps>();
 
diff --git a/packages/frontend/src/components/MkTagCloud.vue b/packages/frontend/src/components/MkTagCloud.vue
index 87aa046963..8ad9e14015 100644
--- a/packages/frontend/src/components/MkTagCloud.vue
+++ b/packages/frontend/src/components/MkTagCloud.vue
@@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, watch, onBeforeUnmount, ref, shallowRef } from 'vue';
+import { onMounted, watch, onBeforeUnmount, ref, useTemplateRef } from 'vue';
 import tinycolor from 'tinycolor2';
 
 const loaded = !!window.TagCanvas;
@@ -24,9 +24,9 @@ const computedStyle = getComputedStyle(document.documentElement);
 const idForCanvas = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
 const idForTags = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
 const available = ref(false);
-const rootEl = shallowRef<HTMLElement | null>(null);
-const canvasEl = shallowRef<HTMLCanvasElement | null>(null);
-const tagsEl = shallowRef<HTMLElement | null>(null);
+const rootEl = useTemplateRef('rootEl');
+const canvasEl = useTemplateRef('canvasEl');
+const tagsEl = useTemplateRef('tagsEl');
 const width = ref(300);
 
 watch(available, () => {
diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue
index c9da226734..14ffdae663 100644
--- a/packages/frontend/src/components/MkTextarea.vue
+++ b/packages/frontend/src/components/MkTextarea.vue
@@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, shallowRef } from 'vue';
+import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, useTemplateRef } from 'vue';
 import { debounce } from 'throttle-debounce';
 import type { SuggestionType } from '@/utility/autocomplete.js';
 import MkButton from '@/components/MkButton.vue';
@@ -75,7 +75,7 @@ const focused = ref(false);
 const changed = ref(false);
 const invalid = ref(false);
 const filled = computed(() => v.value !== '' && v.value != null);
-const inputEl = shallowRef<HTMLTextAreaElement>();
+const inputEl = useTemplateRef('inputEl');
 const preview = ref(false);
 let autocompleteWorker: Autocomplete | null = null;
 
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index e8b740eae2..73057e4644 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, watch, onUnmounted, provide, ref, shallowRef } from 'vue';
+import { computed, watch, onUnmounted, provide, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { BasicTimelineType } from '@/timelines.js';
 import type { Paging } from '@/components/MkPagination.vue';
@@ -67,8 +67,8 @@ type TimelineQueryType = {
 	roleId?: string
 };
 
-const prComponent = shallowRef<InstanceType<typeof MkPullToRefresh>>();
-const tlComponent = shallowRef<InstanceType<typeof MkNotes>>();
+const prComponent = useTemplateRef('prComponent');
+const tlComponent = useTemplateRef('tlComponent');
 
 let tlNotesCount = 0;
 
diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue
index bed15031cb..b449155edb 100644
--- a/packages/frontend/src/components/MkTokenGenerateWindow.vue
+++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue
@@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef, ref } from 'vue';
+import { useTemplateRef, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkInput from './MkInput.vue';
 import MkSwitch from './MkSwitch.vue';
@@ -77,7 +77,7 @@ const emit = defineEmits<{
 const defaultPermissions = Misskey.permissions.filter(p => !p.startsWith('read:admin') && !p.startsWith('write:admin'));
 const adminPermissions = Misskey.permissions.filter(p => p.startsWith('read:admin') || p.startsWith('write:admin'));
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 const name = ref(props.initialName);
 const permissionSwitches = ref({} as Record<(typeof Misskey.permissions)[number], boolean>);
 const permissionSwitchesForAdmin = ref({} as Record<(typeof Misskey.permissions)[number], boolean>);
diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue
index 8031abd563..ddfa6def87 100644
--- a/packages/frontend/src/components/MkTooltip.vue
+++ b/packages/frontend/src/components/MkTooltip.vue
@@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onMounted, onUnmounted, shallowRef } from 'vue';
+import { nextTick, onMounted, onUnmounted, useTemplateRef } from 'vue';
 import * as os from '@/os.js';
 import { calcPopupPosition } from '@/utility/popup-position.js';
 import { prefer } from '@/preferences.js';
@@ -51,7 +51,7 @@ const emit = defineEmits<{
 // タイミングによっては最初から showing = false な場合があり、その場合に closed 扱いにしないと永久にDOMに残ることになる
 if (!props.showing) emit('closed');
 
-const el = shallowRef<HTMLElement>();
+const el = useTemplateRef('el');
 const zIndex = os.claimZIndex('high');
 
 function setPosition() {
diff --git a/packages/frontend/src/components/MkTutorialDialog.vue b/packages/frontend/src/components/MkTutorialDialog.vue
index 5908db33c4..3e91baada4 100644
--- a/packages/frontend/src/components/MkTutorialDialog.vue
+++ b/packages/frontend/src/components/MkTutorialDialog.vue
@@ -148,7 +148,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef, watch } from 'vue';
+import { ref, useTemplateRef, watch } from 'vue';
+import { host } from '@@/js/config.js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkButton from '@/components/MkButton.vue';
 import XNote from '@/components/MkTutorialDialog.Note.vue';
@@ -158,7 +159,6 @@ import XSensitive from '@/components/MkTutorialDialog.Sensitive.vue';
 import MkAnimBg from '@/components/MkAnimBg.vue';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
-import { host } from '@@/js/config.js';
 import { claimAchievement } from '@/utility/achievements.js';
 import * as os from '@/os.js';
 
@@ -170,7 +170,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 
 // eslint-disable-next-line vue/no-setup-props-reactivity-loss
 const page = ref(props.initialPage ?? 0);
diff --git a/packages/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue
index 95732d5796..79ab464cb0 100644
--- a/packages/frontend/src/components/MkUpdated.vue
+++ b/packages/frontend/src/components/MkUpdated.vue
@@ -15,15 +15,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef } from 'vue';
+import { onMounted, useTemplateRef } from 'vue';
+import { version } from '@@/js/config.js';
 import MkModal from '@/components/MkModal.vue';
 import MkButton from '@/components/MkButton.vue';
 import MkSparkle from '@/components/MkSparkle.vue';
-import { version } from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
 import { confetti } from '@/utility/confetti.js';
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
+const modal = useTemplateRef('modal');
 
 function whatIsNew() {
 	modal.value?.close();
diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue
index 6bf3eb44dc..057af49a36 100644
--- a/packages/frontend/src/components/MkUserSelectDialog.vue
+++ b/packages/frontend/src/components/MkUserSelectDialog.vue
@@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, ref, computed, shallowRef } from 'vue';
+import { onMounted, ref, computed, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import { host as currentHost, hostname } from '@@/js/config.js';
 import MkInput from '@/components/MkInput.vue';
@@ -94,7 +94,7 @@ const host = ref('');
 const users = ref<Misskey.entities.UserLite[]>([]);
 const recentUsers = ref<Misskey.entities.UserDetailed[]>([]);
 const selected = ref<Misskey.entities.UserLite | null>(null);
-const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialogEl = useTemplateRef('dialogEl');
 
 function search() {
 	if (username.value === '' && host.value === '') {
diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue
index eb3a69217e..767f5c591a 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.vue
@@ -128,7 +128,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef, watch, nextTick, defineAsyncComponent } from 'vue';
+import { ref, useTemplateRef, watch, nextTick, defineAsyncComponent } from 'vue';
+import { host } from '@@/js/config.js';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkButton from '@/components/MkButton.vue';
 import XProfile from '@/components/MkUserSetupDialog.Profile.vue';
@@ -137,7 +138,6 @@ import XPrivacy from '@/components/MkUserSetupDialog.Privacy.vue';
 import MkAnimBg from '@/components/MkAnimBg.vue';
 import { i18n } from '@/i18n.js';
 import { instance } from '@/instance.js';
-import { host } from '@@/js/config.js';
 import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
 import { store } from '@/store.js';
 import * as os from '@/os.js';
@@ -146,9 +146,8 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
-
-// eslint-disable-next-line vue/no-setup-props-reactivity-loss
+const dialog = useTemplateRef('dialog');
+ 
 const page = ref(store.s.accountSetupWizard);
 
 watch(page, () => {
diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue
index 650e639c4f..cb402b1a57 100644
--- a/packages/frontend/src/components/MkVisibilityPicker.vue
+++ b/packages/frontend/src/components/MkVisibilityPicker.vue
@@ -42,12 +42,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, shallowRef, ref } from 'vue';
+import { nextTick, useTemplateRef, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkModal from '@/components/MkModal.vue';
 import { i18n } from '@/i18n.js';
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
+const modal = useTemplateRef('modal');
 
 const props = withDefaults(defineProps<{
 	currentVisibility: typeof Misskey.noteVisibilities[number];
diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
index 4168876f08..2c2c515032 100644
--- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
+++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef, ref, nextTick } from 'vue';
+import { onMounted, useTemplateRef, ref, nextTick } from 'vue';
 import { Chart } from 'chart.js';
 import gradient from 'chartjs-plugin-gradient';
 import tinycolor from 'tinycolor2';
@@ -25,7 +25,7 @@ import { initChart } from '@/utility/init-chart.js';
 
 initChart();
 
-const chartEl = shallowRef<HTMLCanvasElement | null>(null);
+const chartEl = useTemplateRef('chartEl');
 const now = new Date();
 let chartInstance: Chart | null = null;
 const chartLimit = 30;
diff --git a/packages/frontend/src/components/MkWaitingDialog.vue b/packages/frontend/src/components/MkWaitingDialog.vue
index 34fa6b0723..282da00ee1 100644
--- a/packages/frontend/src/components/MkWaitingDialog.vue
+++ b/packages/frontend/src/components/MkWaitingDialog.vue
@@ -14,10 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { watch, shallowRef } from 'vue';
+import { watch, useTemplateRef } from 'vue';
 import MkModal from '@/components/MkModal.vue';
 
-const modal = shallowRef<InstanceType<typeof MkModal>>();
+const modal = useTemplateRef('modal');
 
 const props = defineProps<{
 	success: boolean;
diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue
index 4b3c728ee4..98ec448a8e 100644
--- a/packages/frontend/src/components/MkWindow.vue
+++ b/packages/frontend/src/components/MkWindow.vue
@@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onBeforeUnmount, onMounted, provide, shallowRef, ref } from 'vue';
+import { onBeforeUnmount, onMounted, provide, useTemplateRef, ref } from 'vue';
 import type { MenuItem } from '@/types/menu.js';
 import contains from '@/utility/contains.js';
 import * as os from '@/os.js';
@@ -114,7 +114,7 @@ const emit = defineEmits<{
 
 provide('inWindow', true);
 
-const rootEl = shallowRef<HTMLElement | null>();
+const rootEl = useTemplateRef('rootEl');
 const showing = ref(true);
 let beforeClickedAt = 0;
 const maximized = ref(false);
diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue
index 3403418991..336160ec17 100644
--- a/packages/frontend/src/components/global/MkA.vue
+++ b/packages/frontend/src/components/global/MkA.vue
@@ -14,7 +14,7 @@ export type MkABehavior = 'window' | 'browser' | null;
 </script>
 
 <script lang="ts" setup>
-import { computed, inject, shallowRef } from 'vue';
+import { computed, inject, useTemplateRef } from 'vue';
 import { url } from '@@/js/config.js';
 import * as os from '@/os.js';
 import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
@@ -32,7 +32,7 @@ const props = withDefaults(defineProps<{
 
 const behavior = props.behavior ?? inject<MkABehavior>('linkNavigationBehavior', null);
 
-const el = shallowRef<HTMLElement>();
+const el = useTemplateRef('el');
 
 defineExpose({ $el: el });
 
diff --git a/packages/frontend/src/components/global/MkLazy.vue b/packages/frontend/src/components/global/MkLazy.vue
index f35932ae77..b352296469 100644
--- a/packages/frontend/src/components/global/MkLazy.vue
+++ b/packages/frontend/src/components/global/MkLazy.vue
@@ -11,9 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onMounted, onActivated, onBeforeUnmount, ref, shallowRef } from 'vue';
+import { nextTick, onMounted, onActivated, onBeforeUnmount, ref, useTemplateRef } from 'vue';
 
-const rootEl = shallowRef<HTMLDivElement>();
+const rootEl = useTemplateRef('rootEl');
 const showing = ref(false);
 
 const observer = new IntersectionObserver(
diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue
index 8a5e556293..81adc07f26 100644
--- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue
@@ -53,7 +53,7 @@ export type Tab = {
 </script>
 
 <script lang="ts" setup>
-import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue';
+import { nextTick, onMounted, onUnmounted, useTemplateRef, watch } from 'vue';
 import { prefer } from '@/preferences.js';
 
 const props = withDefaults(defineProps<{
@@ -69,9 +69,9 @@ const emit = defineEmits<{
 	(ev: 'tabClick', key: string);
 }>();
 
-const el = shallowRef<HTMLElement | null>(null);
+const el = useTemplateRef('el');
+const tabHighlightEl = useTemplateRef('tabHighlightEl');
 const tabRefs: Record<string, HTMLElement | null> = {};
-const tabHighlightEl = shallowRef<HTMLElement | null>(null);
 
 function onTabMousedown(tab: Tab, ev: MouseEvent): void {
 	// ユーザビリティの観点からmousedown時にはonClickは呼ばない
diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue
index b353269fef..4321d69253 100644
--- a/packages/frontend/src/components/global/MkPageHeader.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.vue
@@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, ref, inject, shallowRef, computed } from 'vue';
+import { onMounted, onUnmounted, ref, inject, useTemplateRef, computed } from 'vue';
 import tinycolor from 'tinycolor2';
 import { scrollToTop } from '@@/js/scroll.js';
 import XTabs from './MkPageHeader.tabs.vue';
@@ -75,7 +75,7 @@ const pageMetadata = computed(() => props.overridePageMetadata ?? injectedPageMe
 const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle);
 const thin_ = props.thin || inject('shouldHeaderThin', false);
 
-const el = shallowRef<HTMLElement | undefined>(undefined);
+const el = useTemplateRef('el');
 const bg = ref<string | undefined>(undefined);
 const narrow = ref(false);
 const hasTabs = computed(() => props.tabs.length > 0);
diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue
index 7ee3952083..7dda4b2f8a 100644
--- a/packages/frontend/src/components/global/MkStickyContainer.vue
+++ b/packages/frontend/src/components/global/MkStickyContainer.vue
@@ -23,9 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { onMounted, onUnmounted, provide, inject, ref, watch, useTemplateRef } from 'vue';
-import type { Ref } from 'vue';
-
 import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@@/js/const.js';
+import type { Ref } from 'vue';
 
 const rootEl = useTemplateRef('rootEl');
 const headerEl = useTemplateRef('headerEl');
diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue
index f7f6f5c140..7c8a5d64d7 100644
--- a/packages/frontend/src/components/grid/MkDataCell.vue
+++ b/packages/frontend/src/components/grid/MkDataCell.vue
@@ -39,10 +39,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 					{{ cell.value }}
 				</div>
 				<div v-else-if="cellType === 'boolean'">
-					<div :class="[$style.bool, {
-						[$style.boolTrue]: cell.value === true,
-						'ti ti-check': cell.value === true,
-					}]"></div>
+					<div
+						:class="[$style.bool, {
+							[$style.boolTrue]: cell.value === true,
+							'ti ti-check': cell.value === true,
+						}]"
+					></div>
 				</div>
 				<div v-else-if="cellType === 'image'">
 					<img
@@ -88,14 +90,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script setup lang="ts">
-import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue';
+import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, useTemplateRef, toRefs, watch } from 'vue';
+import type { Size } from '@/components/grid/grid.js';
+import type { CellValue, GridCell } from '@/components/grid/cell.js';
+import type { GridRowSetting } from '@/components/grid/row.js';
 import { GridEventEmitter } from '@/components/grid/grid.js';
 import { useTooltip } from '@/use/use-tooltip.js';
 import * as os from '@/os.js';
 import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js';
-import type { Size } from '@/components/grid/grid.js';
-import type { CellValue, GridCell } from '@/components/grid/cell.js';
-import type { GridRowSetting } from '@/components/grid/row.js';
 
 const emit = defineEmits<{
 	(ev: 'operation:beginEdit', sender: GridCell): void;
@@ -111,9 +113,9 @@ const props = defineProps<{
 
 const { cell, bus } = toRefs(props);
 
-const rootEl = shallowRef<InstanceType<typeof HTMLTableCellElement>>();
-const contentAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>();
-const inputAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>();
+const rootEl = useTemplateRef('rootEl');
+const contentAreaEl = useTemplateRef('contentAreaEl');
+const inputAreaEl = useTemplateRef('inputAreaEl');
 
 /** 値が編集中かどうか */
 const editing = ref<boolean>(false);
diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue
index 36dac2954d..2d0135e6a6 100644
--- a/packages/frontend/src/pages/about-misskey.vue
+++ b/packages/frontend/src/pages/about-misskey.vue
@@ -134,7 +134,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { nextTick, onBeforeUnmount, ref, shallowRef, computed } from 'vue';
+import { nextTick, onBeforeUnmount, ref, useTemplateRef, computed } from 'vue';
 import { version } from '@@/js/config.js';
 import FormLink from '@/components/form/link.vue';
 import FormSection from '@/components/form/section.vue';
@@ -403,7 +403,7 @@ const easterEggEmojis = ref<{
 	emoji: string
 }[]>([]);
 const easterEggEngine = ref<{ stop: () => void } | null>(null);
-const containerEl = shallowRef<HTMLElement>();
+const containerEl = useTemplateRef('containerEl');
 
 function iconLoaded() {
 	const emojis = prefer.s.emojiPalettes[0].emojis;
diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue
index f073a4af7e..819ca2c127 100644
--- a/packages/frontend/src/pages/admin/_header_.vue
+++ b/packages/frontend/src/pages/admin/_header_.vue
@@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, onMounted, onUnmounted, ref, shallowRef, watch, nextTick, inject } from 'vue';
+import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch, nextTick, inject } from 'vue';
 import tinycolor from 'tinycolor2';
 import { scrollToTop } from '@@/js/scroll.js';
 import { popupMenu } from '@/os.js';
@@ -68,9 +68,9 @@ const emit = defineEmits<{
 
 const pageMetadata = inject(DI.pageMetadata);
 
-const el = shallowRef<HTMLElement>(null);
+const el = useTemplateRef('el');
+const tabHighlightEl = useTemplateRef('tabHighlightEl');
 const tabRefs = {};
-const tabHighlightEl = shallowRef<HTMLElement | null>(null);
 const bg = ref<string | null>(null);
 const height = ref(0);
 const hasTabs = computed(() => {
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 5b049e5bad..a56a24ff7d 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
@@ -71,8 +71,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, onMounted, ref, shallowRef, toRefs } from 'vue';
+import { computed, onMounted, ref, useTemplateRef, toRefs } from 'vue';
 import { entities } from 'misskey-js';
+import type { MkSystemWebhookResult } from '@/components/MkSystemWebhookEditor.impl.js';
 import MkButton from '@/components/MkButton.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import { i18n } from '@/i18n.js';
@@ -80,7 +81,6 @@ import MkInput from '@/components/MkInput.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import MkSelect from '@/components/MkSelect.vue';
 import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
-import type { MkSystemWebhookResult } from '@/components/MkSystemWebhookEditor.impl.js';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkDivider from '@/components/MkDivider.vue';
 import * as os from '@/os.js';
@@ -100,7 +100,7 @@ const props = defineProps<{
 
 const { mode, id } = toRefs(props);
 
-const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialogEl = useTemplateRef('dialogEl');
 
 const loading = ref<number>(0);
 
diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue
index e4119bb62d..08e06ce4b4 100644
--- a/packages/frontend/src/pages/admin/abuses.vue
+++ b/packages/frontend/src/pages/admin/abuses.vue
@@ -59,7 +59,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, shallowRef, ref } from 'vue';
+import { computed, useTemplateRef, ref } from 'vue';
 import XHeader from './_header_.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkPagination from '@/components/MkPagination.vue';
@@ -70,7 +70,7 @@ import MkButton from '@/components/MkButton.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import { store } from '@/store.js';
 
-const reports = shallowRef<InstanceType<typeof MkPagination>>();
+const reports = useTemplateRef('reports');
 
 const state = ref('unresolved');
 const reporterOrigin = ref('combined');
diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue
index 069ed6e1f3..6e6476b027 100644
--- a/packages/frontend/src/pages/admin/invites.vue
+++ b/packages/frontend/src/pages/admin/invites.vue
@@ -55,8 +55,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, ref, shallowRef } from 'vue';
+import { computed, ref, useTemplateRef } from 'vue';
 import XHeader from './_header_.vue';
+import type { Paging } from '@/components/MkPagination.vue';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
@@ -66,11 +67,10 @@ import MkSelect from '@/components/MkSelect.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
 import MkPagination from '@/components/MkPagination.vue';
-import type { Paging } from '@/components/MkPagination.vue';
 import MkInviteCode from '@/components/MkInviteCode.vue';
 import { definePage } from '@/page.js';
 
-const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const pagingComponent = useTemplateRef('pagingComponent');
 
 const type = ref('all');
 const sort = ref('+createdAt');
diff --git a/packages/frontend/src/pages/admin/modlog.vue b/packages/frontend/src/pages/admin/modlog.vue
index ae7022a1c9..1fb2c4b726 100644
--- a/packages/frontend/src/pages/admin/modlog.vue
+++ b/packages/frontend/src/pages/admin/modlog.vue
@@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, shallowRef, ref } from 'vue';
+import { computed, useTemplateRef, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import XHeader from './_header_.vue';
 import XModLog from './modlog.ModLog.vue';
@@ -41,7 +41,7 @@ import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
 
-const logs = shallowRef<InstanceType<typeof MkPagination>>();
+const logs = useTemplateRef('logs');
 
 const type = ref<string | null>(null);
 const moderatorId = ref('');
diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue
index 5c50336b2c..5b7f669f6b 100644
--- a/packages/frontend/src/pages/admin/overview.active-users.vue
+++ b/packages/frontend/src/pages/admin/overview.active-users.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef, ref } from 'vue';
+import { onMounted, useTemplateRef, ref } from 'vue';
 import { Chart } from 'chart.js';
 import gradient from 'chartjs-plugin-gradient';
 import { misskeyApi } from '@/utility/misskey-api.js';
@@ -24,7 +24,7 @@ import { initChart } from '@/utility/init-chart.js';
 
 initChart();
 
-const chartEl = shallowRef<HTMLCanvasElement>(null);
+const chartEl = useTemplateRef('chartEl');
 const now = new Date();
 let chartInstance: Chart = null;
 const chartLimit = 7;
diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue
index 1949201ca0..4c06d94d6d 100644
--- a/packages/frontend/src/pages/admin/overview.ap-requests.vue
+++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef, ref } from 'vue';
+import { onMounted, useTemplateRef, ref } from 'vue';
 import { Chart } from 'chart.js';
 import gradient from 'chartjs-plugin-gradient';
 import isChromatic from 'chromatic';
@@ -34,8 +34,8 @@ import { initChart } from '@/utility/init-chart.js';
 initChart();
 
 const chartLimit = 50;
-const chartEl = shallowRef<HTMLCanvasElement>();
-const chartEl2 = shallowRef<HTMLCanvasElement>();
+const chartEl = useTemplateRef('chartEl');
+const chartEl2 = useTemplateRef('chartEl2');
 const fetching = ref(true);
 
 const { handler: externalTooltipHandler } = useChartTooltip();
diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue
index 424bcdd51f..32dd981ca9 100644
--- a/packages/frontend/src/pages/admin/overview.pie.vue
+++ b/packages/frontend/src/pages/admin/overview.pie.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef } from 'vue';
+import { onMounted, useTemplateRef } from 'vue';
 import { Chart } from 'chart.js';
 import { useChartTooltip } from '@/use/use-chart-tooltip.js';
 import { initChart } from '@/utility/init-chart.js';
@@ -26,7 +26,7 @@ const props = defineProps<{
 	data: InstanceForPie[];
 }>();
 
-const chartEl = shallowRef<HTMLCanvasElement>(null);
+const chartEl = useTemplateRef('chartEl');
 
 const { handler: externalTooltipHandler } = useChartTooltip({
 	position: 'middle',
diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue
index 34c0945ddb..6fc941a848 100644
--- a/packages/frontend/src/pages/admin/overview.queue.chart.vue
+++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef } from 'vue';
+import { onMounted, useTemplateRef } from 'vue';
 import { Chart } from 'chart.js';
 import { store } from '@/store.js';
 import { useChartTooltip } from '@/use/use-chart-tooltip.js';
@@ -22,7 +22,7 @@ const props = defineProps<{
 	type: string;
 }>();
 
-const chartEl = shallowRef<HTMLCanvasElement>(null);
+const chartEl = useTemplateRef('chartEl');
 
 const { handler: externalTooltipHandler } = useChartTooltip();
 
diff --git a/packages/frontend/src/pages/admin/overview.queue.vue b/packages/frontend/src/pages/admin/overview.queue.vue
index de6b254412..cf07cddced 100644
--- a/packages/frontend/src/pages/admin/overview.queue.vue
+++ b/packages/frontend/src/pages/admin/overview.queue.vue
@@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
+import { markRaw, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import XChart from './overview.queue.chart.vue';
 import type { ApQueueDomain } from '@/pages/admin/queue.vue';
@@ -48,10 +48,10 @@ const activeSincePrevTick = ref(0);
 const active = ref(0);
 const delayed = ref(0);
 const waiting = ref(0);
-const chartProcess = shallowRef<InstanceType<typeof XChart>>();
-const chartActive = shallowRef<InstanceType<typeof XChart>>();
-const chartDelayed = shallowRef<InstanceType<typeof XChart>>();
-const chartWaiting = shallowRef<InstanceType<typeof XChart>>();
+const chartProcess = useTemplateRef('chartProcess');
+const chartActive = useTemplateRef('chartActive');
+const chartDelayed = useTemplateRef('chartDelayed');
+const chartWaiting = useTemplateRef('chartWaiting');
 
 const props = defineProps<{
 	domain: ApQueueDomain;
diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue
index c23662572a..616815a6a6 100644
--- a/packages/frontend/src/pages/admin/overview.vue
+++ b/packages/frontend/src/pages/admin/overview.vue
@@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { markRaw, onMounted, onBeforeUnmount, nextTick, shallowRef, ref, computed } from 'vue';
+import { markRaw, onMounted, onBeforeUnmount, nextTick, shallowRef, ref, computed, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import XFederation from './overview.federation.vue';
 import XInstances from './overview.instances.vue';
@@ -85,7 +85,7 @@ import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
 
-const rootEl = shallowRef<HTMLElement>();
+const rootEl = useTemplateRef('rootEl');
 const serverInfo = ref<Misskey.entities.ServerInfoResponse | null>(null);
 const topSubInstancesForPie = ref<InstanceForPie[] | null>(null);
 const topPubInstancesForPie = ref<InstanceForPie[] | null>(null);
diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue
index 9c7a83b1fb..5dd2887024 100644
--- a/packages/frontend/src/pages/admin/queue.chart.chart.vue
+++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef } from 'vue';
+import { onMounted, useTemplateRef } from 'vue';
 import { Chart } from 'chart.js';
 import { store } from '@/store.js';
 import { useChartTooltip } from '@/use/use-chart-tooltip.js';
@@ -22,7 +22,7 @@ const props = defineProps<{
 	type: string;
 }>();
 
-const chartEl = shallowRef<HTMLCanvasElement>(null);
+const chartEl = useTemplateRef('chartEl');
 
 const { handler: externalTooltipHandler } = useChartTooltip();
 
diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/queue.chart.vue
index 607a974d20..1ba02d6e0e 100644
--- a/packages/frontend/src/pages/admin/queue.chart.vue
+++ b/packages/frontend/src/pages/admin/queue.chart.vue
@@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
+import { markRaw, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import XChart from './queue.chart.chart.vue';
 import type { ApQueueDomain } from '@/pages/admin/queue.vue';
@@ -65,10 +65,10 @@ const active = ref(0);
 const delayed = ref(0);
 const waiting = ref(0);
 const jobs = ref<Misskey.Endpoints[`admin/queue/${ApQueueDomain}-delayed`]['res']>([]);
-const chartProcess = shallowRef<InstanceType<typeof XChart>>();
-const chartActive = shallowRef<InstanceType<typeof XChart>>();
-const chartDelayed = shallowRef<InstanceType<typeof XChart>>();
-const chartWaiting = shallowRef<InstanceType<typeof XChart>>();
+const chartProcess = useTemplateRef('chartProcess');
+const chartActive = useTemplateRef('chartActive');
+const chartDelayed = useTemplateRef('chartDelayed');
+const chartWaiting = useTemplateRef('chartWaiting');
 
 const props = defineProps<{
 	domain: ApQueueDomain;
diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue
index 1998144c05..a44951a947 100644
--- a/packages/frontend/src/pages/admin/users.vue
+++ b/packages/frontend/src/pages/admin/users.vue
@@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, shallowRef, ref, watchEffect } from 'vue';
+import { computed, useTemplateRef, ref, watchEffect } from 'vue';
 import XHeader from './_header_.vue';
 import { defaultMemoryStorage } from '@/memory-storage';
 import MkButton from '@/components/MkButton.vue';
@@ -82,7 +82,7 @@ type SearchQuery = {
 	hostname?: string;
 };
 
-const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const paginationComponent = useTemplateRef('paginationComponent');
 const storedQuery = JSON.parse(defaultMemoryStorage.getItem('admin-users-query') ?? '{}') as SearchQuery;
 
 const sort = ref(storedQuery.sort ?? '+createdAt');
diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue
index f0587a5ca0..5f98ed798f 100644
--- a/packages/frontend/src/pages/antenna-timeline.vue
+++ b/packages/frontend/src/pages/antenna-timeline.vue
@@ -24,10 +24,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, watch, ref, shallowRef } from 'vue';
+import { computed, watch, ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
-import MkTimeline from '@/components/MkTimeline.vue';
 import { scroll } from '@@/js/scroll.js';
+import MkTimeline from '@/components/MkTimeline.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { definePage } from '@/page.js';
@@ -42,8 +42,8 @@ const props = defineProps<{
 
 const antenna = ref<Misskey.entities.Antenna | null>(null);
 const queue = ref(0);
-const rootEl = shallowRef<HTMLElement>();
-const tlEl = shallowRef<InstanceType<typeof MkTimeline>>();
+const rootEl = useTemplateRef('rootEl');
+const tlEl = useTemplateRef('tlEl');
 
 function queueUpdated(q) {
 	queue.value = q;
diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue
index 46e7a3d05e..c714ccb003 100644
--- a/packages/frontend/src/pages/custom-emojis-manager.vue
+++ b/packages/frontend/src/pages/custom-emojis-manager.vue
@@ -72,7 +72,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, defineAsyncComponent, ref, shallowRef } from 'vue';
+import { computed, defineAsyncComponent, ref, useTemplateRef } from 'vue';
 import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkPagination from '@/components/MkPagination.vue';
@@ -86,7 +86,7 @@ import { getProxiedImageUrl } from '@/utility/media-proxy.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 
-const emojisPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const emojisPaginationComponent = useTemplateRef('emojisPaginationComponent');
 
 const tab = ref('local');
 const query = ref<string | null>(null);
diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue
index e30e381ce1..baae888a94 100644
--- a/packages/frontend/src/pages/drop-and-fusion.game.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.game.vue
@@ -191,7 +191,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, onDeactivated, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
+import { computed, onDeactivated, onMounted, onUnmounted, ref, shallowRef, watch, useTemplateRef } from 'vue';
 import * as Matter from 'matter-js';
 import * as Misskey from 'misskey-js';
 import { DropAndFusionGame } from 'misskey-bubble-game';
@@ -567,8 +567,8 @@ let game = new DropAndFusionGame({
 });
 attachGameEvents();
 
-const containerEl = shallowRef<HTMLElement>();
-const canvasEl = shallowRef<HTMLCanvasElement>();
+const containerEl = useTemplateRef('containerEl');
+const canvasEl = useTemplateRef('canvasEl');
 const dropperX = ref(0);
 const currentPick = shallowRef<{ id: string; mono: Mono } | null>(null);
 const stock = shallowRef<{ id: string; mono: Mono }[]>([]);
diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue
index c112045a21..c0618b9fce 100644
--- a/packages/frontend/src/pages/explore.users.vue
+++ b/packages/frontend/src/pages/explore.users.vue
@@ -63,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { watch, ref, shallowRef, computed } from 'vue';
+import { watch, ref, useTemplateRef, computed } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkUserList from '@/components/MkUserList.vue';
 import MkFoldableSection from '@/components/MkFoldableSection.vue';
@@ -77,7 +77,7 @@ const props = defineProps<{
 }>();
 
 const origin = ref('local');
-const tagsEl = shallowRef<InstanceType<typeof MkFoldableSection>>();
+const tagsEl = useTemplateRef('tagsEl');
 const tagsLocal = ref<Misskey.entities.Hashtag[]>([]);
 const tagsRemote = ref<Misskey.entities.Hashtag[]>([]);
 
diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue
index d24ebe6aae..b76f6a6033 100644
--- a/packages/frontend/src/pages/explore.vue
+++ b/packages/frontend/src/pages/explore.vue
@@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, watch, ref, shallowRef } from 'vue';
+import { computed, watch, ref, useTemplateRef } from 'vue';
 import XFeatured from './explore.featured.vue';
 import XUsers from './explore.users.vue';
 import XRoles from './explore.roles.vue';
@@ -38,7 +38,7 @@ const props = withDefaults(defineProps<{
 });
 
 const tab = ref(props.initialTab);
-const tagsEl = shallowRef<InstanceType<typeof MkFoldableSection>>();
+const tagsEl = useTemplateRef('tagsEl');
 
 watch(() => props.tag, () => {
 	if (tagsEl.value) tagsEl.value.toggleContent(props.tag == null);
diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue
index f44d2364af..1ea501c34c 100644
--- a/packages/frontend/src/pages/follow-requests.vue
+++ b/packages/frontend/src/pages/follow-requests.vue
@@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import * as Misskey from 'misskey-js';
-import { shallowRef, computed, ref } from 'vue';
+import { useTemplateRef, computed, ref } from 'vue';
 import type { Paging } from '@/components/MkPagination.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import MkButton from '@/components/MkButton.vue';
@@ -57,7 +57,7 @@ import { infoImageUrl } from '@/instance.js';
 import { $i } from '@/i.js';
 import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
 
-const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const paginationComponent = useTemplateRef('paginationComponent');
 
 const pagination = computed<Paging>(() => tab.value === 'list' ? {
 	endpoint: 'following/requests/list',
diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue
index a6f0dfcf4b..9181e7f2ef 100644
--- a/packages/frontend/src/pages/invite.vue
+++ b/packages/frontend/src/pages/invite.vue
@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, ref, shallowRef } from 'vue';
+import { computed, ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { Paging } from '@/components/MkPagination.vue';
 import { i18n } from '@/i18n.js';
@@ -47,7 +47,7 @@ import { definePage } from '@/page.js';
 import { serverErrorImageUrl, instance } from '@/instance.js';
 import { $i } from '@/i.js';
 
-const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const pagingComponent = useTemplateRef('pagingComponent');
 const currentInviteLimit = ref<null | number>(null);
 const inviteLimit = (($i != null && $i.policies.inviteLimit) || (($i == null && instance.policies.inviteLimit))) as number;
 const inviteLimitCycle = (($i != null && $i.policies.inviteLimitCycle) || ($i == null && instance.policies.inviteLimitCycle)) as number;
diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue
index 247720d9ac..d354221ca3 100644
--- a/packages/frontend/src/pages/my-clips/index.vue
+++ b/packages/frontend/src/pages/my-clips/index.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { watch, ref, shallowRef, computed } from 'vue';
+import { watch, ref, useTemplateRef, computed } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkPagination from '@/components/MkPagination.vue';
 import MkButton from '@/components/MkButton.vue';
@@ -46,7 +46,7 @@ const tab = ref('my');
 
 const favorites = ref<Misskey.entities.Clip[] | null>(null);
 
-const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const pagingComponent = useTemplateRef('pagingComponent');
 
 watch(tab, async () => {
 	favorites.value = await misskeyApi('clips/my-favorites');
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
index a8b4da2a06..4a980ce472 100644
--- a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
@@ -15,8 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-
-import { watch, ref, shallowRef, onMounted, onUnmounted } from 'vue';
+import { watch, ref, useTemplateRef, onMounted, onUnmounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import XContainer from '../page-editor.container.vue';
 import { i18n } from '@/i18n.js';
@@ -33,7 +32,7 @@ const emit = defineEmits<{
 let autocomplete: Autocomplete;
 
 const text = ref(props.modelValue.text ?? '');
-const inputEl = shallowRef<HTMLTextAreaElement | null>(null);
+const inputEl = useTemplateRef('inputEl');
 
 watch(text, () => {
 	emit('update:modelValue', {
diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
index c88f7fbe15..2a07a9a21c 100644
--- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue
+++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
@@ -106,7 +106,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef, ref } from 'vue';
+import { useTemplateRef, ref } from 'vue';
 import MkButton from '@/components/MkButton.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
@@ -132,7 +132,7 @@ const emit = defineEmits<{
 	(ev: 'closed'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 const page = ref(0);
 const token = ref<string | number | null>(null);
 const backupCodes = ref<string[]>();
diff --git a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue
index 335ecedfa8..0f02d95d71 100644
--- a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue
+++ b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue
@@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef, ref, computed } from 'vue';
+import { useTemplateRef, ref, computed } from 'vue';
 import MkButton from '@/components/MkButton.vue';
 import MkModalWindow from '@/components/MkModalWindow.vue';
 import MkSwitch from '@/components/MkSwitch.vue';
@@ -82,7 +82,7 @@ const emit = defineEmits<{
 	(ev: 'detach'): void;
 }>();
 
-const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const dialog = useTemplateRef('dialog');
 const exceeded = computed(() => ($i.policies.avatarDecorationLimit - $i.avatarDecorations.length) <= 0);
 const locked = computed(() => props.decoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => props.decoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id)));
 const angle = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].angle : null) ?? 0);
diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue
index 89dc9581c2..c315e9289a 100644
--- a/packages/frontend/src/pages/settings/index.vue
+++ b/packages/frontend/src/pages/settings/index.vue
@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script setup lang="ts">
-import { computed, onActivated, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
+import { computed, onActivated, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
 import type { PageMetadata } from '@/page.js';
 import type { SuperMenuDef } from '@/components/MkSuperMenu.vue';
 import { i18n } from '@/i18n.js';
@@ -56,7 +56,7 @@ const indexInfo = {
 	hideHeader: true,
 };
 const INFO = ref<PageMetadata>(indexInfo);
-const el = shallowRef<HTMLElement | null>(null);
+const el = useTemplateRef('el');
 const childInfo = ref<null | PageMetadata>(null);
 
 const router = useRouter();
diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue
index f059050621..530b63b701 100644
--- a/packages/frontend/src/pages/settings/notifications.vue
+++ b/packages/frontend/src/pages/settings/notifications.vue
@@ -66,7 +66,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef, computed } from 'vue';
+import { useTemplateRef, computed } from 'vue';
 import { notificationTypes } from '@@/js/const.js';
 import XNotificationConfig from './notifications.notification-config.vue';
 import type { NotificationConfig } from './notifications.notification-config.vue';
@@ -88,7 +88,7 @@ const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequest
 
 const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login', 'createToken'] satisfies (typeof notificationTypes[number])[] as string[];
 
-const allowButton = shallowRef<InstanceType<typeof MkPushNotificationAllowButton>>();
+const allowButton = useTemplateRef('allowButton');
 const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrationInServer);
 const sendReadMessage = computed(() => pushRegistrationInServer.value?.sendReadMessage || false);
 const userLists = await misskeyApi('users/lists/list');
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index 68f4b7a26d..38497e7c1b 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, watch, provide, shallowRef, ref, onMounted, onActivated } from 'vue';
+import { computed, watch, provide, useTemplateRef, ref, onMounted, onActivated } from 'vue';
 import { scroll } from '@@/js/scroll.js';
 import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
 import type { MenuItem } from '@/types/menu.js';
@@ -59,8 +59,8 @@ import { prefer } from '@/preferences.js';
 
 provide('shouldOmitHeaderTitle', true);
 
-const tlComponent = shallowRef<InstanceType<typeof MkTimeline>>();
-const rootEl = shallowRef<HTMLElement>();
+const tlComponent = useTemplateRef('tlComponent');
+const rootEl = useTemplateRef('rootEl');
 
 type TimelinePageSrc = BasicTimelineType | `list:${string}`;
 
diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue
index e4857c7d30..be84fd6efe 100644
--- a/packages/frontend/src/pages/user-list-timeline.vue
+++ b/packages/frontend/src/pages/user-list-timeline.vue
@@ -24,10 +24,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, watch, ref, shallowRef } from 'vue';
+import { computed, watch, ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
-import MkTimeline from '@/components/MkTimeline.vue';
 import { scroll } from '@@/js/scroll.js';
+import MkTimeline from '@/components/MkTimeline.vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { definePage } from '@/page.js';
 import { i18n } from '@/i18n.js';
@@ -41,8 +41,8 @@ const props = defineProps<{
 
 const list = ref<Misskey.entities.UserList | null>(null);
 const queue = ref(0);
-const tlEl = shallowRef<InstanceType<typeof MkTimeline>>();
-const rootEl = shallowRef<HTMLElement>();
+const tlEl = useTemplateRef('tlEl');
+const rootEl = useTemplateRef('rootEl');
 
 watch(() => props.listId, async () => {
 	list.value = await misskeyApi('users/lists/show', {
diff --git a/packages/frontend/src/pages/user/activity.following.vue b/packages/frontend/src/pages/user/activity.following.vue
index 3b5e9617d5..f5d2002669 100644
--- a/packages/frontend/src/pages/user/activity.following.vue
+++ b/packages/frontend/src/pages/user/activity.following.vue
@@ -14,11 +14,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef, ref } from 'vue';
+import { onMounted, useTemplateRef, ref } from 'vue';
 import { Chart } from 'chart.js';
-import type { ChartDataset } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import gradient from 'chartjs-plugin-gradient';
+import type { ChartDataset } from 'chart.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
 import { useChartTooltip } from '@/use/use-chart-tooltip.js';
@@ -33,8 +33,8 @@ const props = defineProps<{
 	user: Misskey.entities.User;
 }>();
 
-const chartEl = shallowRef<HTMLCanvasElement>(null);
-const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>();
+const chartEl = useTemplateRef('chartEl');
+const legendEl = useTemplateRef('legendEl');
 const now = new Date();
 let chartInstance: Chart = null;
 const chartLimit = 30;
diff --git a/packages/frontend/src/pages/user/activity.notes.vue b/packages/frontend/src/pages/user/activity.notes.vue
index af8e4d43a6..01c62810d4 100644
--- a/packages/frontend/src/pages/user/activity.notes.vue
+++ b/packages/frontend/src/pages/user/activity.notes.vue
@@ -14,11 +14,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef, ref } from 'vue';
+import { onMounted, useTemplateRef, ref } from 'vue';
 import { Chart } from 'chart.js';
-import type { ChartDataset } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import gradient from 'chartjs-plugin-gradient';
+import type { ChartDataset } from 'chart.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
 import { useChartTooltip } from '@/use/use-chart-tooltip.js';
@@ -33,8 +33,8 @@ const props = defineProps<{
 	user: Misskey.entities.User;
 }>();
 
-const chartEl = shallowRef<HTMLCanvasElement>(null);
-const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>();
+const chartEl = useTemplateRef('chartEl');
+const legendEl = useTemplateRef('legendEl');
 const now = new Date();
 let chartInstance: Chart = null;
 const chartLimit = 50;
diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue
index 32e4e78d21..ed12b1b5c7 100644
--- a/packages/frontend/src/pages/user/activity.pv.vue
+++ b/packages/frontend/src/pages/user/activity.pv.vue
@@ -14,11 +14,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, shallowRef, ref } from 'vue';
+import { onMounted, useTemplateRef, ref } from 'vue';
 import { Chart } from 'chart.js';
-import type { ChartDataset } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import gradient from 'chartjs-plugin-gradient';
+import type { ChartDataset } from 'chart.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
 import { useChartTooltip } from '@/use/use-chart-tooltip.js';
@@ -33,8 +33,8 @@ const props = defineProps<{
 	user: Misskey.entities.User;
 }>();
 
-const chartEl = shallowRef<HTMLCanvasElement>(null);
-const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>();
+const chartEl = useTemplateRef('chartEl');
+const legendEl = useTemplateRef('legendEl');
 const now = new Date();
 let chartInstance: Chart = null;
 const chartLimit = 30;
diff --git a/packages/frontend/src/pages/welcome.timeline.note.vue b/packages/frontend/src/pages/welcome.timeline.note.vue
index 8fb84fd58f..680fe08c14 100644
--- a/packages/frontend/src/pages/welcome.timeline.note.vue
+++ b/packages/frontend/src/pages/welcome.timeline.note.vue
@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { ref, shallowRef, onUpdated, onMounted } from 'vue';
+import { ref, useTemplateRef, onUpdated, onMounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
 import MkMediaList from '@/components/MkMediaList.vue';
@@ -45,7 +45,7 @@ defineProps<{
 	note: Misskey.entities.Note;
 }>();
 
-const noteTextEl = shallowRef<HTMLDivElement>();
+const noteTextEl = useTemplateRef('noteTextEl');
 const shouldCollapse = ref(false);
 const showContent = ref(false);
 
diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue
index 180b2e5f77..6108a8a698 100644
--- a/packages/frontend/src/pages/welcome.timeline.vue
+++ b/packages/frontend/src/pages/welcome.timeline.vue
@@ -21,15 +21,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import * as Misskey from 'misskey-js';
-import { onUpdated, ref, shallowRef } from 'vue';
+import { onUpdated, ref, useTemplateRef } from 'vue';
+import { getScrollContainer } from '@@/js/scroll.js';
 import XNote from '@/pages/welcome.timeline.note.vue';
 import { misskeyApiGet } from '@/utility/misskey-api.js';
-import { getScrollContainer } from '@@/js/scroll.js';
 
 const notes = ref<Misskey.entities.Note[]>([]);
 const isScrolling = ref(false);
 const scrollState = ref<null | 'intro' | 'loop'>(null);
-const notesMainContainerEl = shallowRef<HTMLElement>();
+const notesMainContainerEl = useTemplateRef('notesMainContainerEl');
 
 misskeyApiGet('notes/featured').then(_notes => {
 	notes.value = _notes;
diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue
index 8200b6ad38..43d24da28b 100644
--- a/packages/frontend/src/ui/classic.sidebar.vue
+++ b/packages/frontend/src/ui/classic.sidebar.vue
@@ -49,7 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, computed, watch, ref, shallowRef } from 'vue';
+import { defineAsyncComponent, computed, watch, ref, useTemplateRef } from 'vue';
 import { openInstanceMenu } from './_common_/common.js';
 // import { host } from '@@/js/config.js';
 import * as os from '@/os.js';
@@ -76,7 +76,7 @@ const otherNavItemIndicated = computed<boolean>(() => {
 	}
 	return false;
 });
-const el = shallowRef<HTMLElement>();
+const el = useTemplateRef('el');
 // let accounts = $ref([]);
 // let connection = $ref(null);
 const iconOnly = ref(false);
diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue
index 2ef06726f9..c17e78bb03 100644
--- a/packages/frontend/src/ui/classic.vue
+++ b/packages/frontend/src/ui/classic.vue
@@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, onMounted, provide, ref, computed, shallowRef } from 'vue';
+import { defineAsyncComponent, onMounted, provide, ref, computed, useTemplateRef } from 'vue';
 import { instanceName } from '@@/js/config.js';
 import { isLink } from '@@/js/is-link.js';
 import XSidebar from './classic.sidebar.vue';
@@ -77,7 +77,7 @@ const fullView = ref(false);
 const globalHeaderHeight = ref(0);
 const wallpaper = miLocalStorage.getItem('wallpaper') != null;
 const showMenuOnTop = computed(() => store.s.menuDisplay === 'top');
-const live2d = shallowRef<HTMLIFrameElement>();
+const live2d = useTemplateRef('live2d');
 const widgetsLeft = ref<HTMLElement>();
 const widgetsRight = ref<HTMLElement>();
 
diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
index 4e9ec7c586..4c33d7cb27 100644
--- a/packages/frontend/src/ui/deck.vue
+++ b/packages/frontend/src/ui/deck.vue
@@ -92,7 +92,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, defineAsyncComponent, ref, watch, shallowRef } from 'vue';
+import { computed, defineAsyncComponent, ref, useTemplateRef } from 'vue';
 import { v4 as uuid } from 'uuid';
 import XCommon from './_common_/common.vue';
 import XSidebar from '@/ui/_common_/navbar.vue';
@@ -169,7 +169,7 @@ function showSettings() {
 	os.pageWindow('/settings/deck');
 }
 
-const columnsEl = shallowRef<HTMLElement>();
+const columnsEl = useTemplateRef('columnsEl');
 
 const addColumn = async (ev) => {
 	const { canceled, result: column } = await os.select({
diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue
index ae282ba324..194b56c842 100644
--- a/packages/frontend/src/ui/deck/antenna-column.vue
+++ b/packages/frontend/src/ui/deck/antenna-column.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, ref, shallowRef, watch, defineAsyncComponent } from 'vue';
+import { onMounted, ref, useTemplateRef, watch, defineAsyncComponent } from 'vue';
 import XColumn from './column.vue';
 import type { entities as MisskeyEntities } from 'misskey-js';
 import type { Column } from '@/deck.js';
@@ -34,7 +34,7 @@ const props = defineProps<{
 	isStacked: boolean;
 }>();
 
-const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
+const timeline = useTemplateRef('timeline');
 const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
 const antennaName = ref<string | null>(null);
 
diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue
index 964b42874e..c2644da707 100644
--- a/packages/frontend/src/ui/deck/channel-column.vue
+++ b/packages/frontend/src/ui/deck/channel-column.vue
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, ref, shallowRef, watch } from 'vue';
+import { onMounted, ref, shallowRef, watch, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
 import XColumn from './column.vue';
 import type { Column } from '@/deck.js';
@@ -40,7 +40,7 @@ const props = defineProps<{
 	isStacked: boolean;
 }>();
 
-const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
+const timeline = useTemplateRef('timeline');
 const channel = shallowRef<Misskey.entities.Channel>();
 const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
 
diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue
index 3358b4e52b..4841a78323 100644
--- a/packages/frontend/src/ui/deck/column.vue
+++ b/packages/frontend/src/ui/deck/column.vue
@@ -42,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onBeforeUnmount, onMounted, provide, watch, shallowRef, ref, computed } from 'vue';
+import { onBeforeUnmount, onMounted, provide, watch, useTemplateRef, ref, computed } from 'vue';
 import type { Column } from '@/deck.js';
 import type { MenuItem } from '@/types/menu.js';
 import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn } from '@/deck.js';
@@ -68,7 +68,7 @@ const emit = defineEmits<{
 	(ev: 'headerWheel', ctx: WheelEvent): void;
 }>();
 
-const body = shallowRef<HTMLDivElement | null>();
+const body = useTemplateRef('body');
 
 const dragging = ref(false);
 watch(dragging, v => os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd'));
diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue
index 2b4e86b8a2..a8f17feb23 100644
--- a/packages/frontend/src/ui/deck/list-column.vue
+++ b/packages/frontend/src/ui/deck/list-column.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { watch, shallowRef, ref, onMounted } from 'vue';
+import { watch, useTemplateRef, ref, onMounted } from 'vue';
 import XColumn from './column.vue';
 import type { entities as MisskeyEntities } from 'misskey-js';
 import type { Column } from '@/deck.js';
@@ -34,7 +34,7 @@ const props = defineProps<{
 	isStacked: boolean;
 }>();
 
-const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
+const timeline = useTemplateRef('timeline');
 const withRenotes = ref(props.column.withRenotes ?? true);
 const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
 const listName = ref<string | null>(null);
diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue
index 0a2c0e9952..8378dddfef 100644
--- a/packages/frontend/src/ui/deck/notifications-column.vue
+++ b/packages/frontend/src/ui/deck/notifications-column.vue
@@ -12,10 +12,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, shallowRef } from 'vue';
+import { defineAsyncComponent, useTemplateRef } from 'vue';
 import XColumn from './column.vue';
-import { updateColumn } from '@/deck.js';
 import type { Column } from '@/deck.js';
+import { updateColumn } from '@/deck.js';
 import XNotifications from '@/components/MkNotifications.vue';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
@@ -25,7 +25,7 @@ const props = defineProps<{
 	isStacked: boolean;
 }>();
 
-const notificationsComponent = shallowRef<InstanceType<typeof XNotifications>>();
+const notificationsComponent = useTemplateRef('notificationsComponent');
 
 function func() {
 	const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSelectWindow.vue')), {
diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue
index 5d07440017..468b3e49e0 100644
--- a/packages/frontend/src/ui/deck/role-timeline-column.vue
+++ b/packages/frontend/src/ui/deck/role-timeline-column.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, onMounted, ref, shallowRef, watch } from 'vue';
+import { onMounted, ref, useTemplateRef, watch } from 'vue';
 import XColumn from './column.vue';
 import type { Column } from '@/deck.js';
 import type { MenuItem } from '@/types/menu.js';
@@ -32,7 +32,7 @@ const props = defineProps<{
 	isStacked: boolean;
 }>();
 
-const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
+const timeline = useTemplateRef('timeline');
 const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
 const roleName = ref<string | null>(null);
 
diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue
index 2dec7bf5aa..6759135654 100644
--- a/packages/frontend/src/ui/deck/tl-column.vue
+++ b/packages/frontend/src/ui/deck/tl-column.vue
@@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, watch, ref, shallowRef, computed } from 'vue';
+import { onMounted, watch, ref, useTemplateRef, computed } from 'vue';
 import XColumn from './column.vue';
 import type { Column } from '@/deck.js';
 import type { MenuItem } from '@/types/menu.js';
@@ -50,7 +50,7 @@ const props = defineProps<{
 	isStacked: boolean;
 }>();
 
-const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
+const timeline = useTemplateRef('timeline');
 
 const soundSetting = ref<SoundStore>(props.column.soundSetting ?? { type: null, volume: 1 });
 const withRenotes = ref(props.column.withRenotes ?? true);
diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue
index 133360972b..d85745bde6 100644
--- a/packages/frontend/src/ui/universal.vue
+++ b/packages/frontend/src/ui/universal.vue
@@ -95,7 +95,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, provide, onMounted, computed, ref, watch, shallowRef } from 'vue';
+import { defineAsyncComponent, provide, onMounted, computed, ref, watch, useTemplateRef } from 'vue';
 import { instanceName } from '@@/js/config.js';
 import { CURRENT_STICKY_BOTTOM } from '@@/js/const.js';
 import { isLink } from '@@/js/is-link.js';
@@ -135,7 +135,7 @@ window.addEventListener('resize', () => {
 
 const pageMetadata = ref<null | PageMetadata>(null);
 const widgetsShowing = ref(false);
-const navFooter = shallowRef<HTMLElement>();
+const navFooter = useTemplateRef('navFooter');
 
 provide(DI.router, mainRouter);
 provideMetadataReceiver((metadataGetter) => {
diff --git a/packages/frontend/src/widgets/WidgetAichan.vue b/packages/frontend/src/widgets/WidgetAichan.vue
index 29e21ee6c3..2bc7facc88 100644
--- a/packages/frontend/src/widgets/WidgetAichan.vue
+++ b/packages/frontend/src/widgets/WidgetAichan.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, shallowRef } from 'vue';
+import { onMounted, onUnmounted, useTemplateRef } from 'vue';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentProps, WidgetComponentEmits, WidgetComponentExpose } from './widget.js';
 import type { GetFormResultType } from '@/utility/form.js';
@@ -35,7 +35,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
 	emit,
 );
 
-const live2d = shallowRef<HTMLIFrameElement>();
+const live2d = useTemplateRef('live2d');
 
 const touched = () => {
 	//if (this.live2d) this.live2d.changeExpression('gurugurume');
diff --git a/packages/frontend/src/widgets/WidgetInstanceCloud.vue b/packages/frontend/src/widgets/WidgetInstanceCloud.vue
index c6724127fa..0c9f98f9e3 100644
--- a/packages/frontend/src/widgets/WidgetInstanceCloud.vue
+++ b/packages/frontend/src/widgets/WidgetInstanceCloud.vue
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <MkContainer :naked="widgetProps.transparent" :showHeader="false" class="mkw-instance-cloud">
 	<div class="">
-		<MkTagCloud v-if="activeInstances">
+		<MkTagCloud v-if="activeInstances" ref="cloud">
 			<li v-for="instance in activeInstances" :key="instance.id">
 				<a @click.prevent="onInstanceClick(instance)">
 					<img style="width: 32px;" :src="getInstanceIcon(instance)">
@@ -18,8 +18,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { shallowRef } from 'vue';
+import { shallowRef, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
+import { useInterval } from '@@/js/use-interval.js';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
 import type { GetFormResultType } from '@/utility/form.js';
@@ -27,7 +28,6 @@ import MkContainer from '@/components/MkContainer.vue';
 import MkTagCloud from '@/components/MkTagCloud.vue';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { useInterval } from '@@/js/use-interval.js';
 import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
 
 const name = 'instanceCloud';
@@ -50,7 +50,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
 	emit,
 );
 
-const cloud = shallowRef<InstanceType<typeof MkTagCloud> | null>();
+const cloud = useTemplateRef('cloud');
 const activeInstances = shallowRef<Misskey.entities.FederationInstance[] | null>(null);
 
 function onInstanceClick(i) {
diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue
index 2cbf3a8ef6..2ccbb7a28f 100644
--- a/packages/frontend/src/widgets/WidgetSlideshow.vue
+++ b/packages/frontend/src/widgets/WidgetSlideshow.vue
@@ -17,14 +17,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, ref, shallowRef } from 'vue';
+import { onMounted, ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
+import { useInterval } from '@@/js/use-interval.js';
 import { useWidgetPropsManager } from './widget.js';
 import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
 import type { GetFormResultType } from '@/utility/form.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { useInterval } from '@@/js/use-interval.js';
 import { i18n } from '@/i18n.js';
 
 const name = 'slideshow';
@@ -54,8 +54,8 @@ const { widgetProps, configure, save } = useWidgetPropsManager(name,
 
 const images = ref<Misskey.entities.DriveFile[]>([]);
 const fetching = ref(true);
-const slideA = shallowRef<HTMLElement>();
-const slideB = shallowRef<HTMLElement>();
+const slideA = useTemplateRef('slideA');
+const slideB = useTemplateRef('slideB');
 
 const change = () => {
 	if (images.value.length === 0) return;
-- 
cgit v1.2.3-freya


From 6015254e59ba0526efbfa139c89546458663ccbd Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Thu, 20 Mar 2025 19:00:09 +0900
Subject: lint fixes

---
 packages/frontend/.storybook/fake-utils.ts         |  2 +-
 packages/frontend/.storybook/preview.ts            |  6 ++---
 packages/frontend/src/boot/common.ts               | 30 +++++++++++-----------
 packages/frontend/src/boot/main-boot.ts            |  6 ++---
 packages/frontend/src/components/MkAnalogClock.vue |  2 +-
 .../frontend/src/components/MkAutocomplete.vue     |  4 +--
 packages/frontend/src/components/MkButton.vue      |  2 +-
 packages/frontend/src/components/MkCaptcha.vue     |  6 ++---
 packages/frontend/src/components/MkContextMenu.vue |  4 +--
 .../frontend/src/components/MkCropperDialog.vue    |  2 +-
 packages/frontend/src/components/MkFolder.vue      |  2 +-
 .../frontend/src/components/MkImgWithBlurhash.vue  |  4 +--
 .../frontend/src/components/MkInstanceStats.vue    |  2 +-
 packages/frontend/src/components/MkMediaAudio.vue  |  2 +-
 packages/frontend/src/components/MkMediaList.vue   |  6 ++---
 packages/frontend/src/components/MkMediaVideo.vue  |  8 +++---
 packages/frontend/src/components/MkMenu.vue        | 16 ++++++------
 packages/frontend/src/components/MkMiniChart.vue   |  2 +-
 .../frontend/src/components/MkNotifications.vue    |  2 +-
 packages/frontend/src/components/MkPagination.vue  |  2 +-
 packages/frontend/src/components/MkRange.vue       |  8 +++---
 .../src/components/MkReactionsViewer.reaction.vue  |  2 +-
 .../src/components/MkRetentionLineChart.vue        |  2 +-
 packages/frontend/src/components/MkTagCloud.vue    |  4 +--
 .../MkVisitorDashboard.ActiveUsersChart.vue        |  2 +-
 packages/frontend/src/components/MkWindow.vue      |  2 +-
 .../src/components/global/MkPageHeader.tabs.vue    |  2 +-
 .../src/components/global/MkPageHeader.vue         |  4 +--
 .../frontend/src/components/global/RouterView.vue  |  6 ++---
 .../src/components/global/SearchMarker.vue         |  2 +-
 packages/frontend/src/directives/hotkey.ts         |  4 +--
 packages/frontend/src/directives/panel.ts          |  2 +-
 packages/frontend/src/directives/tooltip.ts        |  2 +-
 packages/frontend/src/directives/user-preview.ts   |  4 +--
 packages/frontend/src/instance.ts                  |  2 +-
 packages/frontend/src/os.ts                        |  4 +--
 packages/frontend/src/pages/admin/_header_.vue     |  2 +-
 packages/frontend/src/pages/admin/overview.pie.vue |  2 +-
 packages/frontend/src/pages/auth.form.vue          |  2 +-
 .../frontend/src/pages/drop-and-fusion.game.vue    |  2 +-
 packages/frontend/src/pages/oauth.vue              | 18 ++++++-------
 .../frontend/src/pages/settings/2fa.qrdialog.vue   |  2 +-
 packages/frontend/src/preferences.ts               |  6 ++---
 packages/frontend/src/preferences/utility.ts       |  4 +--
 packages/frontend/src/server-context.ts            |  2 +-
 packages/frontend/src/stream.ts                    |  6 ++---
 packages/frontend/src/theme.ts                     | 12 ++++-----
 packages/frontend/src/ui/_common_/common.vue       |  2 +-
 packages/frontend/src/ui/_common_/navbar.vue       |  4 +--
 packages/frontend/src/ui/classic.vue               |  6 ++---
 packages/frontend/src/ui/deck.vue                  |  4 +--
 packages/frontend/src/ui/minimum.vue               |  4 +--
 packages/frontend/src/ui/universal.vue             | 12 ++++-----
 packages/frontend/src/ui/visitor.vue               |  4 +--
 packages/frontend/src/ui/zen.vue                   |  4 +--
 packages/frontend/src/use/use-note-capture.ts      |  2 +-
 packages/frontend/src/use/use-tooltip.ts           |  4 +--
 packages/frontend/src/utility/focus-trap.ts        |  4 +--
 packages/frontend/src/utility/focus.ts             |  4 +--
 packages/frontend/src/utility/fullscreen.ts        |  4 +--
 packages/frontend/src/utility/hotkey.ts            |  6 ++---
 packages/frontend/src/utility/init-chart.ts        |  2 +-
 packages/frontend/src/utility/physics.ts           |  2 +-
 packages/frontend/src/utility/select-file.ts       |  2 +-
 packages/frontend/src/utility/snowfall-effect.ts   |  6 ++---
 packages/frontend/src/utility/sound.ts             |  4 +--
 packages/frontend/src/utility/sticky-sidebar.ts    |  2 +-
 .../frontend/src/utility/upload/isWebpSupported.ts |  2 +-
 packages/frontend/src/widgets/WidgetRss.vue        |  2 +-
 packages/frontend/src/widgets/WidgetRssTicker.vue  |  2 +-
 packages/frontend/test/scroll.test.ts              | 12 ++++-----
 71 files changed, 160 insertions(+), 160 deletions(-)

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

diff --git a/packages/frontend/.storybook/fake-utils.ts b/packages/frontend/.storybook/fake-utils.ts
index c777cbbe72..44e2263ca0 100644
--- a/packages/frontend/.storybook/fake-utils.ts
+++ b/packages/frontend/.storybook/fake-utils.ts
@@ -131,7 +131,7 @@ export function imageDataUrl(options?: {
 		alpha?: number,
 	}
 }, seed?: string): string {
-	const canvas = document.createElement('canvas');
+	const canvas = window.document.createElement('canvas');
 	canvas.width = options?.size?.width ?? 100;
 	canvas.height = options?.size?.height ?? 100;
 
diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts
index 3f6bfb62ff..269bc4fb9a 100644
--- a/packages/frontend/.storybook/preview.ts
+++ b/packages/frontend/.storybook/preview.ts
@@ -23,9 +23,9 @@ let misskeyOS = null;
 
 function loadTheme(applyTheme: typeof import('../src/theme')['applyTheme']) {
 	unobserve();
-	const theme = themes[document.documentElement.dataset.misskeyTheme];
+	const theme = themes[window.document.documentElement.dataset.misskeyTheme];
 	if (theme) {
-		applyTheme(themes[document.documentElement.dataset.misskeyTheme]);
+		applyTheme(themes[window.document.documentElement.dataset.misskeyTheme]);
 	} else {
 		applyTheme(themes['l-light']);
 	}
@@ -42,7 +42,7 @@ function loadTheme(applyTheme: typeof import('../src/theme')['applyTheme']) {
 			}
 		}
 	});
-	observer.observe(document.documentElement, {
+	observer.observe(window.document.documentElement, {
 		attributes: true,
 		attributeFilter: ['data-misskey-theme'],
 	});
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts
index 75b74b41f8..9a505ca9f8 100644
--- a/packages/frontend/src/boot/common.ts
+++ b/packages/frontend/src/boot/common.ts
@@ -95,7 +95,7 @@ export async function common(createVue: () => App<Element>) {
 	//#endregion
 
 	// タッチデバイスでCSSの:hoverを機能させる
-	document.addEventListener('touchend', () => {}, { passive: true });
+	window.document.addEventListener('touchend', () => {}, { passive: true });
 
 	// URLに#pswpを含む場合は取り除く
 	if (window.location.hash === '#pswp') {
@@ -110,13 +110,13 @@ export async function common(createVue: () => App<Element>) {
 
 	// If mobile, insert the viewport meta tag
 	if (['smartphone', 'tablet'].includes(deviceKind)) {
-		const viewport = document.getElementsByName('viewport').item(0);
+		const viewport = window.document.getElementsByName('viewport').item(0);
 		viewport.setAttribute('content',
 			`${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`);
 	}
 
 	//#region Set lang attr
-	const html = document.documentElement;
+	const html = window.document.documentElement;
 	html.setAttribute('lang', lang);
 	//#endregion
 
@@ -155,7 +155,7 @@ export async function common(createVue: () => App<Element>) {
 		);
 	}, { immediate: miLocalStorage.getItem('theme') == null });
 
-	document.documentElement.dataset.colorScheme = store.s.darkMode ? 'dark' : 'light';
+	window.document.documentElement.dataset.colorScheme = store.s.darkMode ? 'dark' : 'light';
 
 	const darkTheme = prefer.model('darkTheme');
 	const lightTheme = prefer.model('lightTheme');
@@ -201,20 +201,20 @@ export async function common(createVue: () => App<Element>) {
 	}, { immediate: true });
 
 	watch(prefer.r.useBlurEffectForModal, v => {
-		document.documentElement.style.setProperty('--MI-modalBgFilter', v ? 'blur(4px)' : 'none');
+		window.document.documentElement.style.setProperty('--MI-modalBgFilter', v ? 'blur(4px)' : 'none');
 	}, { immediate: true });
 
 	watch(prefer.r.useBlurEffect, v => {
 		if (v) {
-			document.documentElement.style.removeProperty('--MI-blur');
+			window.document.documentElement.style.removeProperty('--MI-blur');
 		} else {
-			document.documentElement.style.setProperty('--MI-blur', 'none');
+			window.document.documentElement.style.setProperty('--MI-blur', 'none');
 		}
 	}, { immediate: true });
 
 	// Keep screen on
-	const onVisibilityChange = () => document.addEventListener('visibilitychange', () => {
-		if (document.visibilityState === 'visible') {
+	const onVisibilityChange = () => window.document.addEventListener('visibilitychange', () => {
+		if (window.document.visibilityState === 'visible') {
 			navigator.wakeLock.request('screen');
 		}
 	});
@@ -224,7 +224,7 @@ export async function common(createVue: () => App<Element>) {
 			.catch(() => {
 				// On WebKit-based browsers, user activation is required to send wake lock request
 				// https://webkit.org/blog/13862/the-user-activation-api/
-				document.addEventListener(
+				window.document.addEventListener(
 					'click',
 					() => navigator.wakeLock.request('screen').then(onVisibilityChange),
 					{ once: true },
@@ -233,7 +233,7 @@ export async function common(createVue: () => App<Element>) {
 	}
 
 	if (prefer.s.makeEveryTextElementsSelectable) {
-		document.documentElement.classList.add('forceSelectableAll');
+		window.document.documentElement.classList.add('forceSelectableAll');
 	}
 
 	//#region Fetch user
@@ -278,16 +278,16 @@ export async function common(createVue: () => App<Element>) {
 	const rootEl = ((): HTMLElement => {
 		const MISSKEY_MOUNT_DIV_ID = 'misskey_app';
 
-		const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID);
+		const currentRoot = window.document.getElementById(MISSKEY_MOUNT_DIV_ID);
 
 		if (currentRoot) {
 			console.warn('multiple import detected');
 			return currentRoot;
 		}
 
-		const root = document.createElement('div');
+		const root = window.document.createElement('div');
 		root.id = MISSKEY_MOUNT_DIV_ID;
-		document.body.appendChild(root);
+		window.document.body.appendChild(root);
 		return root;
 	})();
 
@@ -330,7 +330,7 @@ export async function common(createVue: () => App<Element>) {
 }
 
 function removeSplash() {
-	const splash = document.getElementById('splash');
+	const splash = window.document.getElementById('splash');
 	if (splash) {
 		splash.style.opacity = '0';
 		splash.style.pointerEvents = 'none';
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
index f7360a7340..134490e317 100644
--- a/packages/frontend/src/boot/main-boot.ts
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -398,7 +398,7 @@ export async function mainBoot() {
 			let lastVisibilityChangedAt = Date.now();
 
 			function claimPlainLucky() {
-				if (document.visibilityState !== 'visible') {
+				if (window.document.visibilityState !== 'visible') {
 					if (justPlainLuckyTimer != null) window.clearTimeout(justPlainLuckyTimer);
 					return;
 				}
@@ -413,7 +413,7 @@ export async function mainBoot() {
 			window.addEventListener('visibilitychange', () => {
 				const now = Date.now();
 
-				if (document.visibilityState === 'visible') {
+				if (window.document.visibilityState === 'visible') {
 					// タブを高速で切り替えたら取得処理が何度も走るのを防ぐ
 					if ((now - lastVisibilityChangedAt) < 1000 * 10) {
 						justPlainLuckyTimer = window.setTimeout(claimPlainLucky, 1000 * 10);
@@ -554,7 +554,7 @@ export async function mainBoot() {
 			mainRouter.push('/search');
 		},
 	} as const satisfies Keymap;
-	document.addEventListener('keydown', makeHotkey(keymap), { passive: false });
+	window.document.addEventListener('keydown', makeHotkey(keymap), { passive: false });
 
 	initializeSw();
 }
diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue
index b39bca5b27..eac1ea9534 100644
--- a/packages/frontend/src/components/MkAnalogClock.vue
+++ b/packages/frontend/src/components/MkAnalogClock.vue
@@ -192,7 +192,7 @@ function tick() {
 tick();
 
 function calcColors() {
-	const computedStyle = getComputedStyle(document.documentElement);
+	const computedStyle = getComputedStyle(window.document.documentElement);
 	const dark = tinycolor(computedStyle.getPropertyValue('--MI_THEME-bg')).isDark();
 	const accent = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString();
 	majorGraduationColor.value = dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)';
diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue
index 5b747a2a1a..03cf107231 100644
--- a/packages/frontend/src/components/MkAutocomplete.vue
+++ b/packages/frontend/src/components/MkAutocomplete.vue
@@ -359,7 +359,7 @@ onMounted(() => {
 
 	props.textarea.addEventListener('keydown', onKeydown);
 
-	document.body.addEventListener('mousedown', onMousedown);
+	window.document.body.addEventListener('mousedown', onMousedown);
 
 	nextTick(() => {
 		exec();
@@ -375,7 +375,7 @@ onMounted(() => {
 onBeforeUnmount(() => {
 	props.textarea.removeEventListener('keydown', onKeydown);
 
-	document.body.removeEventListener('mousedown', onMousedown);
+	window.document.body.removeEventListener('mousedown', onMousedown);
 });
 </script>
 
diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue
index c81edc2a73..5e89dfba12 100644
--- a/packages/frontend/src/components/MkButton.vue
+++ b/packages/frontend/src/components/MkButton.vue
@@ -92,7 +92,7 @@ function onMousedown(evt: MouseEvent): void {
 	const target = evt.target! as HTMLElement;
 	const rect = target.getBoundingClientRect();
 
-	const ripple = document.createElement('div');
+	const ripple = window.document.createElement('div');
 	ripple.classList.add(ripples.value!.dataset.childrenClass!);
 	ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px';
 	ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px';
diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue
index e4f953bda8..30940a34a9 100644
--- a/packages/frontend/src/components/MkCaptcha.vue
+++ b/packages/frontend/src/components/MkCaptcha.vue
@@ -112,7 +112,7 @@ watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => {
 if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') {
 	available.value = true;
 } else if (src.value !== null) {
-	(document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), {
+	(window.document.getElementById(scriptId.value) ?? window.document.head.appendChild(Object.assign(window.document.createElement('script'), {
 		async: true,
 		id: scriptId.value,
 		src: src.value,
@@ -149,7 +149,7 @@ async function requestRender() {
 	if (captcha.value.render && captchaEl.value instanceof Element && props.sitekey) {
 		// reCAPTCHAのレンダリング重複判定を回避するため、captchaEl配下に仮のdivを用意する.
 		// （同じdivに対して複数回renderを呼び出すとreCAPTCHAはエラーを返すので）
-		const elem = document.createElement('div');
+		const elem = window.document.createElement('div');
 		captchaEl.value.appendChild(elem);
 
 		captchaWidgetId.value = captcha.value.render(elem, {
@@ -174,7 +174,7 @@ async function requestRender() {
 
 function clearWidget() {
 	if (props.provider === 'mcaptcha') {
-		const container = document.getElementById('mcaptcha__widget-container');
+		const container = window.document.getElementById('mcaptcha__widget-container');
 		if (container) {
 			container.innerHTML = '';
 		}
diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue
index e47dba4bae..9c6397a72c 100644
--- a/packages/frontend/src/components/MkContextMenu.vue
+++ b/packages/frontend/src/components/MkContextMenu.vue
@@ -68,11 +68,11 @@ onMounted(() => {
 		rootEl.value.style.left = `${left}px`;
 	}
 
-	document.body.addEventListener('mousedown', onMousedown);
+	window.document.body.addEventListener('mousedown', onMousedown);
 });
 
 onBeforeUnmount(() => {
-	document.body.removeEventListener('mousedown', onMousedown);
+	window.document.body.removeEventListener('mousedown', onMousedown);
 });
 
 function onMousedown(evt: Event) {
diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue
index bd2e6a9cbc..ba21394cbc 100644
--- a/packages/frontend/src/components/MkCropperDialog.vue
+++ b/packages/frontend/src/components/MkCropperDialog.vue
@@ -122,7 +122,7 @@ onMounted(() => {
 	cropper = new Cropper(imgEl.value!, {
 	});
 
-	const computedStyle = getComputedStyle(document.documentElement);
+	const computedStyle = getComputedStyle(window.document.documentElement);
 
 	const selection = cropper.getCropperSelection()!;
 	selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString();
diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
index b13972f66d..afa09e3125 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -116,7 +116,7 @@ function toggle() {
 }
 
 onMounted(() => {
-	const computedStyle = getComputedStyle(document.documentElement);
+	const computedStyle = getComputedStyle(window.document.documentElement);
 	const parentBg = getBgColor(rootEl.value?.parentElement) ?? 'transparent';
 	const myBg = computedStyle.getPropertyValue('--MI_THEME-panel');
 	bgSame.value = parentBg === myBg;
diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue
index 420e5e392f..e3a0a371b4 100644
--- a/packages/frontend/src/components/MkImgWithBlurhash.vue
+++ b/packages/frontend/src/components/MkImgWithBlurhash.vue
@@ -55,7 +55,7 @@ import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurha
 const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
 	// テスト環境で Web Worker インスタンスは作成できない
 	if (import.meta.env.MODE === 'test') {
-		const canvas = document.createElement('canvas');
+		const canvas = window.document.createElement('canvas');
 		canvas.width = 64;
 		canvas.height = 64;
 		resolve(canvas);
@@ -70,7 +70,7 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol
 			);
 			resolve(workers);
 		} else {
-			const canvas = document.createElement('canvas');
+			const canvas = window.document.createElement('canvas');
 			canvas.width = 64;
 			canvas.height = 64;
 			resolve(canvas);
diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue
index 76bec9bc66..90391005bc 100644
--- a/packages/frontend/src/components/MkInstanceStats.vue
+++ b/packages/frontend/src/components/MkInstanceStats.vue
@@ -126,7 +126,7 @@ function createDoughnut(chartEl, tooltip, data) {
 			labels: data.map(x => x.name),
 			datasets: [{
 				backgroundColor: data.map(x => x.color),
-				borderColor: getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-panel'),
+				borderColor: getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel'),
 				borderWidth: 2,
 				hoverOffset: 0,
 				data: data.map(x => x.value),
diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue
index db6deb161a..b7052ad918 100644
--- a/packages/frontend/src/components/MkMediaAudio.vue
+++ b/packages/frontend/src/components/MkMediaAudio.vue
@@ -148,7 +148,7 @@ const keymap = {
 // PlayerElもしくはその子要素にフォーカスがあるかどうか
 function hasFocus() {
 	if (!playerEl.value) return false;
-	return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement);
+	return playerEl.value === window.document.activeElement || playerEl.value.contains(window.document.activeElement);
 }
 
 const playerEl = useTemplateRef('playerEl');
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index 44a9d14a46..ae15776041 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -48,7 +48,7 @@ const props = defineProps<{
 
 const gallery = useTemplateRef('gallery');
 const pswpZIndex = os.claimZIndex('middle');
-document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
+window.document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
 const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
 let lightbox: PhotoSwipeLightbox | null = null;
 
@@ -166,7 +166,7 @@ onMounted(() => {
 			className: 'pswp__alt-text-container',
 			appendTo: 'wrapper',
 			onInit: (el, pswp) => {
-				const textBox = document.createElement('p');
+				const textBox = window.document.createElement('p');
 				textBox.className = 'pswp__alt-text _acrylic';
 				el.appendChild(textBox);
 
@@ -178,7 +178,7 @@ onMounted(() => {
 	});
 
 	lightbox.on('afterInit', () => {
-		activeEl = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+		activeEl = window.document.activeElement instanceof HTMLElement ? window.document.activeElement : null;
 		focusParent(activeEl, true, true);
 		lightbox?.pswp?.element?.focus({
 			preventScroll: true,
diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue
index 5475e703a5..629679a971 100644
--- a/packages/frontend/src/components/MkMediaVideo.vue
+++ b/packages/frontend/src/components/MkMediaVideo.vue
@@ -171,7 +171,7 @@ const keymap = {
 // PlayerElもしくはその子要素にフォーカスがあるかどうか
 function hasFocus() {
 	if (!playerEl.value) return false;
-	return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement);
+	return playerEl.value === window.document.activeElement || playerEl.value.contains(window.document.activeElement);
 }
 
 // eslint-disable-next-line vue/no-setup-props-reactivity-loss
@@ -216,7 +216,7 @@ function showMenu(ev: MouseEvent) {
 				'2.0x': 2,
 			},
 		},
-		...(document.pictureInPictureEnabled ? [{
+		...(window.document.pictureInPictureEnabled ? [{
 			text: i18n.ts._mediaControls.pip,
 			icon: 'ti ti-picture-in-picture',
 			action: togglePictureInPicture,
@@ -384,8 +384,8 @@ function toggleFullscreen() {
 
 function togglePictureInPicture() {
 	if (videoEl.value) {
-		if (document.pictureInPictureElement) {
-			document.exitPictureInPicture();
+		if (window.document.pictureInPictureElement) {
+			window.document.exitPictureInPicture();
 		} else {
 			videoEl.value.requestPictureInPicture();
 		}
diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue
index e380d0bc37..a84bd9b256 100644
--- a/packages/frontend/src/components/MkMenu.vue
+++ b/packages/frontend/src/components/MkMenu.vue
@@ -358,10 +358,10 @@ function switchItem(item: MenuSwitch & { ref: any }) {
 
 function focusUp() {
 	if (disposed) return;
-	if (!itemsEl.value?.contains(document.activeElement)) return;
+	if (!itemsEl.value?.contains(window.document.activeElement)) return;
 
 	const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable);
-	const activeIndex = focusableElements.findIndex(el => el === document.activeElement);
+	const activeIndex = focusableElements.findIndex(el => el === window.document.activeElement);
 	const targetIndex = (activeIndex !== -1 && activeIndex !== 0) ? (activeIndex - 1) : (focusableElements.length - 1);
 	const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value;
 
@@ -370,10 +370,10 @@ function focusUp() {
 
 function focusDown() {
 	if (disposed) return;
-	if (!itemsEl.value?.contains(document.activeElement)) return;
+	if (!itemsEl.value?.contains(window.document.activeElement)) return;
 
 	const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable);
-	const activeIndex = focusableElements.findIndex(el => el === document.activeElement);
+	const activeIndex = focusableElements.findIndex(el => el === window.document.activeElement);
 	const targetIndex = (activeIndex !== -1 && activeIndex !== (focusableElements.length - 1)) ? (activeIndex + 1) : 0;
 	const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value;
 
@@ -400,9 +400,9 @@ const onGlobalMousedown = (ev: MouseEvent) => {
 
 const setupHandlers = () => {
 	if (!isNestingMenu) {
-		document.addEventListener('focusin', onGlobalFocusin, { passive: true });
+		window.document.addEventListener('focusin', onGlobalFocusin, { passive: true });
 	}
-	document.addEventListener('mousedown', onGlobalMousedown, { passive: true });
+	window.document.addEventListener('mousedown', onGlobalMousedown, { passive: true });
 };
 
 let disposed = false;
@@ -410,9 +410,9 @@ let disposed = false;
 const disposeHandlers = () => {
 	disposed = true;
 	if (!isNestingMenu) {
-		document.removeEventListener('focusin', onGlobalFocusin);
+		window.document.removeEventListener('focusin', onGlobalFocusin);
 	}
-	document.removeEventListener('mousedown', onGlobalMousedown);
+	window.document.removeEventListener('mousedown', onGlobalMousedown);
 };
 
 onMounted(() => {
diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue
index 7ea585ecc2..98bd471438 100644
--- a/packages/frontend/src/components/MkMiniChart.vue
+++ b/packages/frontend/src/components/MkMiniChart.vue
@@ -48,7 +48,7 @@ const polygonPoints = ref('');
 const headX = ref<number | null>(null);
 const headY = ref<number | null>(null);
 const clock = ref<number | null>(null);
-const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-accent'));
+const accent = tinycolor(getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-accent'));
 const color = accent.toRgbString();
 
 function draw(): void {
diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue
index 08fc846327..21f1967bfa 100644
--- a/packages/frontend/src/components/MkNotifications.vue
+++ b/packages/frontend/src/components/MkNotifications.vue
@@ -59,7 +59,7 @@ const pagination = computed(() => prefer.r.useGroupedNotifications.value ? {
 
 function onNotification(notification) {
 	const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;
-	if (isMuted || document.visibilityState === 'visible') {
+	if (isMuted || window.document.visibilityState === 'visible') {
 		useStream().send('readNotification');
 	}
 
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index 6a1a91a9f4..ab8bda403b 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -142,7 +142,7 @@ const {
 } = prefer.r;
 
 const contentEl = computed(() => props.pagination.pageEl ?? rootEl.value);
-const scrollableElement = computed(() => contentEl.value ? getScrollContainer(contentEl.value) : document.body);
+const scrollableElement = computed(() => contentEl.value ? getScrollContainer(contentEl.value) : window.document.body);
 
 const visibility = useDocumentVisibility();
 
diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue
index 118dbbe15a..734b624541 100644
--- a/packages/frontend/src/components/MkRange.vue
+++ b/packages/frontend/src/components/MkRange.vue
@@ -151,9 +151,9 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
 		closed: () => dispose(),
 	});
 
-	const style = document.createElement('style');
-	style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }'));
-	document.head.appendChild(style);
+	const style = window.document.createElement('style');
+	style.appendChild(window.document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }'));
+	window.document.head.appendChild(style);
 
 	const thumbWidth = getThumbWidth();
 
@@ -172,7 +172,7 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
 	let beforeValue = finalValue.value;
 
 	const onMouseup = () => {
-		document.head.removeChild(style);
+		window.document.head.removeChild(style);
 		tooltipForDragShowing.value = false;
 		window.removeEventListener('mousemove', onDrag);
 		window.removeEventListener('touchmove', onDrag);
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index 590b0f6f19..9d941a949a 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -136,7 +136,7 @@ async function menu(ev) {
 }
 
 function anime() {
-	if (document.hidden || !prefer.s.animation || buttonEl.value == null) return;
+	if (window.document.hidden || !prefer.s.animation || buttonEl.value == null) return;
 
 	const rect = buttonEl.value.getBoundingClientRect();
 	const x = rect.left + 16;
diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue
index 60b3f17b14..ba66ffecc0 100644
--- a/packages/frontend/src/components/MkRetentionLineChart.vue
+++ b/packages/frontend/src/components/MkRetentionLineChart.vue
@@ -44,7 +44,7 @@ onMounted(async () => {
 
 	const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
 
-	const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-accent'));
+	const accent = tinycolor(getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-accent'));
 	const color = accent.toHex();
 
 	if (chartEl.value == null) return;
diff --git a/packages/frontend/src/components/MkTagCloud.vue b/packages/frontend/src/components/MkTagCloud.vue
index 8ad9e14015..9d541c8acb 100644
--- a/packages/frontend/src/components/MkTagCloud.vue
+++ b/packages/frontend/src/components/MkTagCloud.vue
@@ -20,7 +20,7 @@ import tinycolor from 'tinycolor2';
 
 const loaded = !!window.TagCanvas;
 const SAFE_FOR_HTML_ID = 'abcdefghijklmnopqrstuvwxyz';
-const computedStyle = getComputedStyle(document.documentElement);
+const computedStyle = getComputedStyle(window.document.documentElement);
 const idForCanvas = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
 const idForTags = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
 const available = ref(false);
@@ -57,7 +57,7 @@ onMounted(() => {
 	if (loaded) {
 		available.value = true;
 	} else {
-		document.head.appendChild(Object.assign(document.createElement('script'), {
+		window.document.head.appendChild(Object.assign(window.document.createElement('script'), {
 			async: true,
 			src: '/client-assets/tagcanvas.min.js',
 		})).addEventListener('load', () => available.value = true);
diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
index 2c2c515032..79c9e739c4 100644
--- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
+++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
@@ -61,7 +61,7 @@ async function renderChart() {
 
 	const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
 
-	const computedStyle = getComputedStyle(document.documentElement);
+	const computedStyle = getComputedStyle(window.document.documentElement);
 	const accent = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString();
 
 	const colorRead = accent;
diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue
index 98ec448a8e..e5ac791d0b 100644
--- a/packages/frontend/src/components/MkWindow.vue
+++ b/packages/frontend/src/components/MkWindow.vue
@@ -240,7 +240,7 @@ function onHeaderMousedown(evt: MouseEvent | TouchEvent) {
 	const main = rootEl.value;
 	if (main == null) return;
 
-	if (!contains(main, document.activeElement)) main.focus();
+	if (!contains(main, window.document.activeElement)) main.focus();
 
 	const position = main.getBoundingClientRect();
 
diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue
index 81adc07f26..358a17d3e8 100644
--- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue
@@ -170,7 +170,7 @@ onMounted(() => {
 
 	if (props.rootEl) {
 		ro2 = new ResizeObserver((entries, observer) => {
-			if (document.body.contains(el.value as HTMLElement)) {
+			if (window.document.body.contains(el.value as HTMLElement)) {
 				nextTick(() => renderTab());
 			}
 		});
diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue
index 59bf80cfca..d5680b8413 100644
--- a/packages/frontend/src/components/global/MkPageHeader.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.vue
@@ -108,7 +108,7 @@ function onTabClick(): void {
 
 const calcBg = () => {
 	const rawBg = 'var(--MI_THEME-bg)';
-	const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+	const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(window.document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
 	tinyBg.setAlpha(0.85);
 	bg.value = tinyBg.toRgbString();
 };
@@ -122,7 +122,7 @@ onMounted(() => {
 	if (el.value && el.value.parentElement) {
 		narrow.value = el.value.parentElement.offsetWidth < 500;
 		ro = new ResizeObserver((entries, observer) => {
-			if (el.value && el.value.parentElement && document.body.contains(el.value as HTMLElement)) {
+			if (el.value && el.value.parentElement && window.document.body.contains(el.value as HTMLElement)) {
 				narrow.value = el.value.parentElement.offsetWidth < 500;
 			}
 		});
diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue
index 45cb1e3bd5..1c0c35f34e 100644
--- a/packages/frontend/src/components/global/RouterView.vue
+++ b/packages/frontend/src/components/global/RouterView.vue
@@ -48,7 +48,7 @@ onMounted(() => {
 });
 
 // view-transition-newなどの<pt-name-selector>にはcss varが使えず、v-bindできないため直接スタイルを生成
-const viewTransitionStylesTag = document.createElement('style');
+const viewTransitionStylesTag = window.document.createElement('style');
 viewTransitionStylesTag.textContent = `
 @keyframes ${viewId}-old {
 	to { transform: scale(0.95); opacity: 0; }
@@ -89,8 +89,8 @@ router.useListener('change', ({ resolved }) => {
 	}
 
 	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-	if (prefer.s.animation && document.startViewTransition) {
-		document.startViewTransition(() => new Promise((res) => {
+	if (prefer.s.animation && window.document.startViewTransition) {
+		window.document.startViewTransition(() => new Promise((res) => {
 			_();
 			nextTick(() => {
 				res();
diff --git a/packages/frontend/src/components/global/SearchMarker.vue b/packages/frontend/src/components/global/SearchMarker.vue
index 66a78cb7fd..061ce3f47d 100644
--- a/packages/frontend/src/components/global/SearchMarker.vue
+++ b/packages/frontend/src/components/global/SearchMarker.vue
@@ -42,7 +42,7 @@ const highlighted = ref(props.markerId === searchMarkerId.value);
 
 function checkChildren() {
 	if (props.children?.includes(searchMarkerId.value)) {
-		const el = document.querySelector(`[data-in-app-search-marker-id="${searchMarkerId.value}"]`);
+		const el = window.document.querySelector(`[data-in-app-search-marker-id="${searchMarkerId.value}"]`);
 		highlighted.value = el == null;
 	}
 }
diff --git a/packages/frontend/src/directives/hotkey.ts b/packages/frontend/src/directives/hotkey.ts
index 75e022e98f..63637ab2ba 100644
--- a/packages/frontend/src/directives/hotkey.ts
+++ b/packages/frontend/src/directives/hotkey.ts
@@ -13,7 +13,7 @@ export default {
 		el._keyHandler = makeHotkey(binding.value);
 
 		if (el._hotkey_global) {
-			document.addEventListener('keydown', el._keyHandler, { passive: false });
+			window.document.addEventListener('keydown', el._keyHandler, { passive: false });
 		} else {
 			el.addEventListener('keydown', el._keyHandler, { passive: false });
 		}
@@ -21,7 +21,7 @@ export default {
 
 	unmounted(el) {
 		if (el._hotkey_global) {
-			document.removeEventListener('keydown', el._keyHandler);
+			window.document.removeEventListener('keydown', el._keyHandler);
 		} else {
 			el.removeEventListener('keydown', el._keyHandler);
 		}
diff --git a/packages/frontend/src/directives/panel.ts b/packages/frontend/src/directives/panel.ts
index 17916fb6d3..0af19e6ca3 100644
--- a/packages/frontend/src/directives/panel.ts
+++ b/packages/frontend/src/directives/panel.ts
@@ -10,7 +10,7 @@ export default {
 	mounted(src, binding, vn) {
 		const parentBg = getBgColor(src.parentElement) ?? 'transparent';
 
-		const myBg = getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-panel');
+		const myBg = getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel');
 
 		if (parentBg === myBg) {
 			src.style.backgroundColor = 'var(--MI_THEME-bg)';
diff --git a/packages/frontend/src/directives/tooltip.ts b/packages/frontend/src/directives/tooltip.ts
index 068186dfa0..750acd0588 100644
--- a/packages/frontend/src/directives/tooltip.ts
+++ b/packages/frontend/src/directives/tooltip.ts
@@ -47,7 +47,7 @@ export default {
 		}
 
 		self.show = () => {
-			if (!document.body.contains(el)) return;
+			if (!window.document.body.contains(el)) return;
 			if (self._close) return;
 			if (self.text == null) return;
 
diff --git a/packages/frontend/src/directives/user-preview.ts b/packages/frontend/src/directives/user-preview.ts
index 43a93a0865..94deea82c7 100644
--- a/packages/frontend/src/directives/user-preview.ts
+++ b/packages/frontend/src/directives/user-preview.ts
@@ -31,7 +31,7 @@ export class UserPreview {
 	}
 
 	private show() {
-		if (!document.body.contains(this.el)) return;
+		if (!window.document.body.contains(this.el)) return;
 		if (this.promise) return;
 
 		const showing = ref(true);
@@ -58,7 +58,7 @@ export class UserPreview {
 		};
 
 		this.checkTimer = window.setInterval(() => {
-			if (!document.body.contains(this.el)) {
+			if (!window.document.body.contains(this.el)) {
 				window.clearTimeout(this.showTimer);
 				window.clearTimeout(this.hideTimer);
 				this.close();
diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts
index c694d49c8a..e75e3dfd34 100644
--- a/packages/frontend/src/instance.ts
+++ b/packages/frontend/src/instance.ts
@@ -12,7 +12,7 @@ import { DEFAULT_INFO_IMAGE_URL, DEFAULT_NOT_FOUND_IMAGE_URL, DEFAULT_SERVER_ERR
 // TODO: 他のタブと永続化されたstateを同期
 
 //#region loader
-const providedMetaEl = document.getElementById('misskey_meta');
+const providedMetaEl = window.document.getElementById('misskey_meta');
 
 let cachedMeta = miLocalStorage.getItem('instance') ? JSON.parse(miLocalStorage.getItem('instance')!) : null;
 let cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0;
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index eed929432c..7f4f7c5be3 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -675,7 +675,7 @@ export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | n
 		src = null;
 	}
 
-	let returnFocusTo = getHTMLElementOrNull(src) ?? getHTMLElementOrNull(document.activeElement);
+	let returnFocusTo = getHTMLElementOrNull(src) ?? getHTMLElementOrNull(window.document.activeElement);
 	return new Promise(resolve => nextTick(() => {
 		const { dispose } = popup(MkPopupMenu, {
 			items,
@@ -704,7 +704,7 @@ export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> {
 		return Promise.resolve();
 	}
 
-	let returnFocusTo = getHTMLElementOrNull(ev.currentTarget ?? ev.target) ?? getHTMLElementOrNull(document.activeElement);
+	let returnFocusTo = getHTMLElementOrNull(ev.currentTarget ?? ev.target) ?? getHTMLElementOrNull(window.document.activeElement);
 	ev.preventDefault();
 	return new Promise(resolve => nextTick(() => {
 		const { dispose } = popup(MkContextMenu, {
diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue
index 819ca2c127..0af56e1b15 100644
--- a/packages/frontend/src/pages/admin/_header_.vue
+++ b/packages/frontend/src/pages/admin/_header_.vue
@@ -120,7 +120,7 @@ function onTabClick(tab: Tab, ev: MouseEvent): void {
 
 const calcBg = () => {
 	const rawBg = pageMetadata.value.bg ?? 'var(--MI_THEME-bg)';
-	const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+	const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(window.document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
 	tinyBg.setAlpha(0.85);
 	bg.value = tinyBg.toRgbString();
 };
diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue
index 32dd981ca9..86c5eff4da 100644
--- a/packages/frontend/src/pages/admin/overview.pie.vue
+++ b/packages/frontend/src/pages/admin/overview.pie.vue
@@ -41,7 +41,7 @@ onMounted(() => {
 			labels: props.data.map(x => x.name),
 			datasets: [{
 				backgroundColor: props.data.map(x => x.color),
-				borderColor: getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-panel'),
+				borderColor: getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel'),
 				borderWidth: 2,
 				hoverOffset: 0,
 				data: props.data.map(x => x.value),
diff --git a/packages/frontend/src/pages/auth.form.vue b/packages/frontend/src/pages/auth.form.vue
index 1917293c06..5b1fd1a386 100644
--- a/packages/frontend/src/pages/auth.form.vue
+++ b/packages/frontend/src/pages/auth.form.vue
@@ -38,7 +38,7 @@ const emit = defineEmits<{
 const app = computed(() => props.session.app);
 
 const name = computed(() => {
-	const el = document.createElement('div');
+	const el = window.document.createElement('div');
 	el.textContent = app.value.name;
 	return el.innerHTML;
 });
diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue
index 3b1a845f5b..b8b0d6aef6 100644
--- a/packages/frontend/src/pages/drop-and-fusion.game.vue
+++ b/packages/frontend/src/pages/drop-and-fusion.game.vue
@@ -875,7 +875,7 @@ function loadImage(url: string) {
 
 function getGameImageDriveFile() {
 	return new Promise<Misskey.entities.DriveFile | null>(res => {
-		const dcanvas = document.createElement('canvas');
+		const dcanvas = window.document.createElement('canvas');
 		dcanvas.width = game.GAME_WIDTH;
 		dcanvas.height = game.GAME_HEIGHT;
 		const ctx = dcanvas.getContext('2d');
diff --git a/packages/frontend/src/pages/oauth.vue b/packages/frontend/src/pages/oauth.vue
index 49664c8b08..04bb1661cf 100644
--- a/packages/frontend/src/pages/oauth.vue
+++ b/packages/frontend/src/pages/oauth.vue
@@ -27,42 +27,42 @@ import MkPageWithAnimBg from '@/components/MkPageWithAnimBg.vue';
 import { definePage } from '@/page.js';
 import MkAuthConfirm from '@/components/MkAuthConfirm.vue';
 
-const transactionIdMeta = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]');
+const transactionIdMeta = window.document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]');
 if (transactionIdMeta) {
 	transactionIdMeta.remove();
 }
 
-const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content;
-const logo = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-logo"]')?.content;
-const permissions = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ').filter((p): p is typeof Misskey.permissions[number] => (Misskey.permissions as readonly string[]).includes(p)) ?? [];
+const name = window.document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content;
+const logo = window.document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-logo"]')?.content;
+const permissions = window.document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ').filter((p): p is typeof Misskey.permissions[number] => (Misskey.permissions as readonly string[]).includes(p)) ?? [];
 
 function doPost(token: string, decision: 'accept' | 'deny') {
-	const form = document.createElement('form');
+	const form = window.document.createElement('form');
 	form.action = '/oauth/decision';
 	form.method = 'post';
 	form.acceptCharset = 'utf-8';
 
-	const loginToken = document.createElement('input');
+	const loginToken = window.document.createElement('input');
 	loginToken.type = 'hidden';
 	loginToken.name = 'login_token';
 	loginToken.value = token;
 	form.appendChild(loginToken);
 
-	const transactionId = document.createElement('input');
+	const transactionId = window.document.createElement('input');
 	transactionId.type = 'hidden';
 	transactionId.name = 'transaction_id';
 	transactionId.value = transactionIdMeta?.content ?? '';
 	form.appendChild(transactionId);
 
 	if (decision === 'deny') {
-		const cancel = document.createElement('input');
+		const cancel = window.document.createElement('input');
 		cancel.type = 'hidden';
 		cancel.name = 'cancel';
 		cancel.value = 'cancel';
 		form.appendChild(cancel);
 	}
 
-	document.body.appendChild(form);
+	window.document.body.appendChild(form);
 	form.submit();
 }
 
diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
index a13f1a7813..03f973a33e 100644
--- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue
+++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
@@ -160,7 +160,7 @@ async function tokenDone() {
 function downloadBackupCodes() {
 	if (backupCodes.value !== undefined) {
 		const txtBlob = new Blob([backupCodes.value.join('\n')], { type: 'text/plain' });
-		const dummya = document.createElement('a');
+		const dummya = window.document.createElement('a');
 		dummya.href = URL.createObjectURL(txtBlob);
 		dummya.download = `${$i.username}@${hostname}` + (port !== '' ? `_${port}` : '') + '-2fa-backup-codes.txt';
 		dummya.click();
diff --git a/packages/frontend/src/preferences.ts b/packages/frontend/src/preferences.ts
index 7681333910..73c89e23af 100644
--- a/packages/frontend/src/preferences.ts
+++ b/packages/frontend/src/preferences.ts
@@ -125,8 +125,8 @@ function syncBetweenTabs() {
 
 window.setInterval(syncBetweenTabs, 5000);
 
-document.addEventListener('visibilitychange', () => {
-	if (document.visibilityState === 'visible') {
+window.document.addEventListener('visibilitychange', () => {
+	if (window.document.visibilityState === 'visible') {
 		syncBetweenTabs();
 	}
 });
@@ -136,7 +136,7 @@ let latestBackupAt = 0;
 window.setInterval(() => {
 	if ($i == null) return;
 	if (!store.s.enablePreferencesAutoCloudBackup) return;
-	if (document.visibilityState !== 'visible') return; // 同期されていない古い値がバックアップされるのを防ぐ
+	if (window.document.visibilityState !== 'visible') return; // 同期されていない古い値がバックアップされるのを防ぐ
 	if (prefer.profile.modifiedAt <= latestBackupAt) return;
 
 	cloudBackup().then(() => {
diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts
index bf3dfa157f..7229b7348e 100644
--- a/packages/frontend/src/preferences/utility.ts
+++ b/packages/frontend/src/preferences/utility.ts
@@ -106,14 +106,14 @@ async function renameProfile() {
 function exportCurrentProfile() {
 	const p = prefer.profile;
 	const txtBlob = new Blob([JSON.stringify(p)], { type: 'text/plain' });
-	const dummya = document.createElement('a');
+	const dummya = window.document.createElement('a');
 	dummya.href = URL.createObjectURL(txtBlob);
 	dummya.download = `${p.name || p.id}.misskeypreferences`;
 	dummya.click();
 }
 
 function importProfile() {
-	const input = document.createElement('input');
+	const input = window.document.createElement('input');
 	input.type = 'file';
 	input.accept = '.misskeypreferences';
 	input.onchange = async () => {
diff --git a/packages/frontend/src/server-context.ts b/packages/frontend/src/server-context.ts
index e79d3fa314..744bfa4b7b 100644
--- a/packages/frontend/src/server-context.ts
+++ b/packages/frontend/src/server-context.ts
@@ -5,7 +5,7 @@
 
 import * as Misskey from 'misskey-js';
 
-const providedContextEl = document.getElementById('misskey_clientCtx');
+const providedContextEl = window.document.getElementById('misskey_clientCtx');
 
 export type ServerContext = {
 	clip?: Misskey.entities.Clip;
diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts
index c97d7d4071..25544d9d88 100644
--- a/packages/frontend/src/stream.ts
+++ b/packages/frontend/src/stream.ts
@@ -29,10 +29,10 @@ export function useStream(): Misskey.IStream {
 	timeoutHeartBeat = window.setTimeout(heartbeat, HEART_BEAT_INTERVAL);
 
 	// send heartbeat right now when last send time is over HEART_BEAT_INTERVAL
-	document.addEventListener('visibilitychange', () => {
+	window.document.addEventListener('visibilitychange', () => {
 		if (
 			!stream
-			|| document.visibilityState !== 'visible'
+			|| window.document.visibilityState !== 'visible'
 			|| Date.now() - lastHeartbeatCall < HEART_BEAT_INTERVAL
 		) return;
 		heartbeat();
@@ -42,7 +42,7 @@ export function useStream(): Misskey.IStream {
 }
 
 function heartbeat(): void {
-	if (stream != null && document.visibilityState === 'visible') {
+	if (stream != null && window.document.visibilityState === 'visible') {
 		stream.heartbeat();
 	}
 	lastHeartbeatCall = Date.now();
diff --git a/packages/frontend/src/theme.ts b/packages/frontend/src/theme.ts
index 970d143b97..cd44fff0c4 100644
--- a/packages/frontend/src/theme.ts
+++ b/packages/frontend/src/theme.ts
@@ -68,10 +68,10 @@ let timeout: number | null = null;
 export function applyTheme(theme: Theme, persist = true) {
 	if (timeout) window.clearTimeout(timeout);
 
-	document.documentElement.classList.add('_themeChanging_');
+	window.document.documentElement.classList.add('_themeChanging_');
 
 	timeout = window.setTimeout(() => {
-		document.documentElement.classList.remove('_themeChanging_');
+		window.document.documentElement.classList.remove('_themeChanging_');
 
 		// 色計算など再度行えるようにクライアント全体に通知
 		globalEvents.emit('themeChanged');
@@ -79,7 +79,7 @@ export function applyTheme(theme: Theme, persist = true) {
 
 	const colorScheme = theme.base === 'dark' ? 'dark' : 'light';
 
-	document.documentElement.dataset.colorScheme = colorScheme;
+	window.document.documentElement.dataset.colorScheme = colorScheme;
 
 	// Deep copy
 	const _theme = deepClone(theme);
@@ -91,7 +91,7 @@ export function applyTheme(theme: Theme, persist = true) {
 
 	const props = compile(_theme);
 
-	for (const tag of document.head.children) {
+	for (const tag of window.document.head.children) {
 		if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
 			tag.setAttribute('content', props['htmlThemeColor']);
 			break;
@@ -99,10 +99,10 @@ export function applyTheme(theme: Theme, persist = true) {
 	}
 
 	for (const [k, v] of Object.entries(props)) {
-		document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
+		window.document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
 	}
 
-	document.documentElement.style.setProperty('color-scheme', colorScheme);
+	window.document.documentElement.style.setProperty('color-scheme', colorScheme);
 
 	if (persist) {
 		miLocalStorage.setItem('theme', JSON.stringify(props));
diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue
index a39a4ee86b..930f633c9f 100644
--- a/packages/frontend/src/ui/_common_/common.vue
+++ b/packages/frontend/src/ui/_common_/common.vue
@@ -67,7 +67,7 @@ const dev = _DEV_;
 const notifications = ref<Misskey.entities.Notification[]>([]);
 
 function onNotification(notification: Misskey.entities.Notification, isClient = false) {
-	if (document.visibilityState === 'visible') {
+	if (window.document.visibilityState === 'visible') {
 		if (!isClient && notification.type !== 'test') {
 			// サーバーサイドのテスト通知の際は自動で既読をつけない（テストできないので）
 			useStream().send('readNotification');
diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue
index 754bf070fa..98d6f329ab 100644
--- a/packages/frontend/src/ui/_common_/navbar.vue
+++ b/packages/frontend/src/ui/_common_/navbar.vue
@@ -129,8 +129,8 @@ watch(store.r.menuDisplay, () => {
 });
 
 function toggleIconOnly() {
-	if (document.startViewTransition && prefer.s.animation) {
-		document.startViewTransition(() => {
+	if (window.document.startViewTransition && prefer.s.animation) {
+		window.document.startViewTransition(() => {
 			store.set('menuDisplay', iconOnly.value ? 'sideFull' : 'sideIcon');
 		});
 	} else {
diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue
index 8f35ce0c68..f51577fc68 100644
--- a/packages/frontend/src/ui/classic.vue
+++ b/packages/frontend/src/ui/classic.vue
@@ -87,9 +87,9 @@ provideMetadataReceiver((metadataGetter) => {
 	pageMetadata.value = info;
 	if (pageMetadata.value) {
 		if (isRoot.value && pageMetadata.value.title === instanceName) {
-			document.title = pageMetadata.value.title;
+			window.document.title = pageMetadata.value.title;
 		} else {
-			document.title = `${pageMetadata.value.title} | ${instanceName}`;
+			window.document.title = `${pageMetadata.value.title} | ${instanceName}`;
 		}
 	}
 });
@@ -142,7 +142,7 @@ if (window.innerWidth < 1024) {
 	window.location.reload();
 }
 
-document.documentElement.style.overflowY = 'scroll';
+window.document.documentElement.style.overflowY = 'scroll';
 
 onMounted(() => {
 	window.addEventListener('resize', () => {
diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
index 4c33d7cb27..65aff8455a 100644
--- a/packages/frontend/src/ui/deck.vue
+++ b/packages/frontend/src/ui/deck.vue
@@ -202,8 +202,8 @@ function onWheel(ev: WheelEvent) {
 	}
 }
 
-document.documentElement.style.overflowY = 'hidden';
-document.documentElement.style.scrollBehavior = 'auto';
+window.document.documentElement.style.overflowY = 'hidden';
+window.document.documentElement.style.scrollBehavior = 'auto';
 
 async function deleteProfile() {
 	if (prefer.s['deck.profile'] == null) return;
diff --git a/packages/frontend/src/ui/minimum.vue b/packages/frontend/src/ui/minimum.vue
index 94382b664a..ec20ac1114 100644
--- a/packages/frontend/src/ui/minimum.vue
+++ b/packages/frontend/src/ui/minimum.vue
@@ -30,9 +30,9 @@ provideMetadataReceiver((metadataGetter) => {
 	pageMetadata.value = info;
 	if (pageMetadata.value) {
 		if (isRoot.value && pageMetadata.value.title === instanceName) {
-			document.title = pageMetadata.value.title;
+			window.document.title = pageMetadata.value.title;
 		} else {
-			document.title = `${pageMetadata.value.title} | ${instanceName}`;
+			window.document.title = `${pageMetadata.value.title} | ${instanceName}`;
 		}
 	}
 });
diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue
index 92b5d253d3..2742b4cd98 100644
--- a/packages/frontend/src/ui/universal.vue
+++ b/packages/frontend/src/ui/universal.vue
@@ -143,9 +143,9 @@ provideMetadataReceiver((metadataGetter) => {
 	pageMetadata.value = info;
 	if (pageMetadata.value) {
 		if (isRoot.value && pageMetadata.value.title === instanceName) {
-			document.title = pageMetadata.value.title;
+			window.document.title = pageMetadata.value.title;
 		} else {
-			document.title = `${pageMetadata.value.title} | ${instanceName}`;
+			window.document.title = `${pageMetadata.value.title} | ${instanceName}`;
 		}
 	}
 });
@@ -205,12 +205,12 @@ provide<Ref<number>>(CURRENT_STICKY_BOTTOM, navFooterHeight);
 watch(navFooter, () => {
 	if (navFooter.value) {
 		navFooterHeight.value = navFooter.value.offsetHeight;
-		document.body.style.setProperty('--MI-stickyBottom', `${navFooterHeight.value}px`);
-		document.body.style.setProperty('--MI-minBottomSpacing', 'var(--MI-minBottomSpacingMobile)');
+		window.document.body.style.setProperty('--MI-stickyBottom', `${navFooterHeight.value}px`);
+		window.document.body.style.setProperty('--MI-minBottomSpacing', 'var(--MI-minBottomSpacingMobile)');
 	} else {
 		navFooterHeight.value = 0;
-		document.body.style.setProperty('--MI-stickyBottom', '0px');
-		document.body.style.setProperty('--MI-minBottomSpacing', '0px');
+		window.document.body.style.setProperty('--MI-stickyBottom', '0px');
+		window.document.body.style.setProperty('--MI-minBottomSpacing', '0px');
 	}
 }, {
 	immediate: true,
diff --git a/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue
index ddc3761b04..3e07959458 100644
--- a/packages/frontend/src/ui/visitor.vue
+++ b/packages/frontend/src/ui/visitor.vue
@@ -51,9 +51,9 @@ provideMetadataReceiver((metadataGetter) => {
 	pageMetadata.value = info;
 	if (pageMetadata.value) {
 		if (isRoot.value && pageMetadata.value.title === instanceName) {
-			document.title = pageMetadata.value.title;
+			window.document.title = pageMetadata.value.title;
 		} else {
-			document.title = `${pageMetadata.value.title} | ${instanceName}`;
+			window.document.title = `${pageMetadata.value.title} | ${instanceName}`;
 		}
 	}
 });
diff --git a/packages/frontend/src/ui/zen.vue b/packages/frontend/src/ui/zen.vue
index bfb2ef634b..3e4d452281 100644
--- a/packages/frontend/src/ui/zen.vue
+++ b/packages/frontend/src/ui/zen.vue
@@ -45,9 +45,9 @@ provideMetadataReceiver((metadataGetter) => {
 	pageMetadata.value = info;
 	if (pageMetadata.value) {
 		if (isRoot.value && pageMetadata.value.title === instanceName) {
-			document.title = pageMetadata.value.title;
+			window.document.title = pageMetadata.value.title;
 		} else {
-			document.title = `${pageMetadata.value.title} | ${instanceName}`;
+			window.document.title = `${pageMetadata.value.title} | ${instanceName}`;
 		}
 	}
 });
diff --git a/packages/frontend/src/use/use-note-capture.ts b/packages/frontend/src/use/use-note-capture.ts
index 0de2dbb3c5..97aec4c1f0 100644
--- a/packages/frontend/src/use/use-note-capture.ts
+++ b/packages/frontend/src/use/use-note-capture.ts
@@ -86,7 +86,7 @@ export function useNoteCapture(props: {
 	function capture(withHandler = false): void {
 		if (connection) {
 			// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
-			connection.send(document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id });
+			connection.send(window.document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id });
 			if (pureNote.value.id !== note.value.id) connection.send('s', { id: pureNote.value.id });
 			if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
 		}
diff --git a/packages/frontend/src/use/use-tooltip.ts b/packages/frontend/src/use/use-tooltip.ts
index d9ddfc8b5d..af76a3a1e8 100644
--- a/packages/frontend/src/use/use-tooltip.ts
+++ b/packages/frontend/src/use/use-tooltip.ts
@@ -29,7 +29,7 @@ export function useTooltip(
 		if (!isHovering) return;
 		if (elRef.value == null) return;
 		const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
-		if (!document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため
+		if (!window.document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため
 
 		const showing = ref(true);
 		onShow(showing);
@@ -38,7 +38,7 @@ export function useTooltip(
 		};
 
 		autoHidingTimer = window.setInterval(() => {
-			if (elRef.value == null || !document.body.contains(elRef.value instanceof Element ? elRef.value : elRef.value.$el)) {
+			if (elRef.value == null || !window.document.body.contains(elRef.value instanceof Element ? elRef.value : elRef.value.$el)) {
 				if (!isHovering) return;
 				isHovering = false;
 				window.clearTimeout(timeoutId);
diff --git a/packages/frontend/src/utility/focus-trap.ts b/packages/frontend/src/utility/focus-trap.ts
index fd17fa38a0..13d3bc56d2 100644
--- a/packages/frontend/src/utility/focus-trap.ts
+++ b/packages/frontend/src/utility/focus-trap.ts
@@ -50,7 +50,7 @@ function releaseFocusTrap(el: HTMLElement): void {
 
 	const highestZIndexElement = getHighestZIndexElement();
 
-	if (el.parentElement != null && el !== document.body) {
+	if (el.parentElement != null && el !== window.document.body) {
 		el.parentElement.childNodes.forEach((siblingNode) => {
 			const siblingEl = getHTMLElementOrNull(siblingNode);
 			if (!siblingEl) return;
@@ -104,7 +104,7 @@ export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEl
 		el.inert = false;
 	}
 
-	if (el.parentElement != null && el !== document.body) {
+	if (el.parentElement != null && el !== window.document.body) {
 		el.parentElement.childNodes.forEach((siblingNode) => {
 			const siblingEl = getHTMLElementOrNull(siblingNode);
 			if (!siblingEl) return;
diff --git a/packages/frontend/src/utility/focus.ts b/packages/frontend/src/utility/focus.ts
index e3fd928d1d..cbbe8226d7 100644
--- a/packages/frontend/src/utility/focus.ts
+++ b/packages/frontend/src/utility/focus.ts
@@ -58,7 +58,7 @@ export const focusParent = (input: MaybeHTMLElement | null | undefined, self = f
 
 const focusOrScroll = (element: HTMLElement, scroll: boolean) => {
 	if (scroll) {
-		const scrollContainer = getScrollContainer(element) ?? document.documentElement;
+		const scrollContainer = getScrollContainer(element) ?? window.document.documentElement;
 		const scrollContainerTop = getScrollPosition(scrollContainer);
 		const stickyTop = getStickyTop(element, scrollContainer);
 		const stickyBottom = getStickyBottom(element, scrollContainer);
@@ -74,7 +74,7 @@ const focusOrScroll = (element: HTMLElement, scroll: boolean) => {
 		scrollContainer.scrollTo({ top: scrollTo, behavior: 'instant' });
 	}
 
-	if (document.activeElement !== element) {
+	if (window.document.activeElement !== element) {
 		element.focus({ preventScroll: true });
 	}
 };
diff --git a/packages/frontend/src/utility/fullscreen.ts b/packages/frontend/src/utility/fullscreen.ts
index 7a0a018ef3..6702393cf1 100644
--- a/packages/frontend/src/utility/fullscreen.ts
+++ b/packages/frontend/src/utility/fullscreen.ts
@@ -35,8 +35,8 @@ export const requestFullscreen = ({ videoEl, playerEl, options }: RequestFullscr
 
 export const exitFullscreen = ({ videoEl }: ExitFullscreenProps) => {
 	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-	if (document.exitFullscreen != null) {
-		document.exitFullscreen();
+	if (window.document.exitFullscreen != null) {
+		window.document.exitFullscreen();
 		return;
 	}
 	if (videoEl.webkitExitFullscreen != null) {
diff --git a/packages/frontend/src/utility/hotkey.ts b/packages/frontend/src/utility/hotkey.ts
index fe62139a74..81fc28d7c8 100644
--- a/packages/frontend/src/utility/hotkey.ts
+++ b/packages/frontend/src/utility/hotkey.ts
@@ -54,9 +54,9 @@ export const makeHotkey = (keymap: Keymap) => {
 	const actions = parseKeymap(keymap);
 	return (ev: KeyboardEvent) => {
 		if ('pswp' in window && window.pswp != null) return;
-		if (document.activeElement != null) {
-			if (IGNORE_ELEMENTS.includes(document.activeElement.tagName.toLowerCase())) return;
-			if (getHTMLElementOrNull(document.activeElement)?.isContentEditable) return;
+		if (window.document.activeElement != null) {
+			if (IGNORE_ELEMENTS.includes(window.document.activeElement.tagName.toLowerCase())) return;
+			if (getHTMLElementOrNull(window.document.activeElement)?.isContentEditable) return;
 		}
 		for (const action of actions) {
 			if (matchPatterns(ev, action)) {
diff --git a/packages/frontend/src/utility/init-chart.ts b/packages/frontend/src/utility/init-chart.ts
index 9775b9fec4..260899c1d7 100644
--- a/packages/frontend/src/utility/init-chart.ts
+++ b/packages/frontend/src/utility/init-chart.ts
@@ -50,7 +50,7 @@ export function initChart() {
 	);
 
 	// フォントカラー
-	Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-fg');
+	Chart.defaults.color = getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-fg');
 
 	Chart.defaults.borderColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
 
diff --git a/packages/frontend/src/utility/physics.ts b/packages/frontend/src/utility/physics.ts
index 8a4e9319b3..5de34fd094 100644
--- a/packages/frontend/src/utility/physics.ts
+++ b/packages/frontend/src/utility/physics.ts
@@ -28,7 +28,7 @@ export function physics(container: HTMLElement) {
 	// create renderer
 	const render = Matter.Render.create({
 		engine: engine,
-		//element: document.getElementById('debug'),
+		//element: window.document.getElementById('debug'),
 		options: {
 			width: containerWidth,
 			height: containerHeight,
diff --git a/packages/frontend/src/utility/select-file.ts b/packages/frontend/src/utility/select-file.ts
index 1bee4986f6..b9b3687483 100644
--- a/packages/frontend/src/utility/select-file.ts
+++ b/packages/frontend/src/utility/select-file.ts
@@ -25,7 +25,7 @@ export function chooseFileFromPc(
 	const nameConverter = options?.nameConverter ?? (() => undefined);
 
 	return new Promise((res, rej) => {
-		const input = document.createElement('input');
+		const input = window.document.createElement('input');
 		input.type = 'file';
 		input.multiple = multiple;
 		input.onchange = () => {
diff --git a/packages/frontend/src/utility/snowfall-effect.ts b/packages/frontend/src/utility/snowfall-effect.ts
index d88bdb6660..5c86969876 100644
--- a/packages/frontend/src/utility/snowfall-effect.ts
+++ b/packages/frontend/src/utility/snowfall-effect.ts
@@ -156,7 +156,7 @@ export class SnowfallEffect {
 		easing: 0.0005,
 	};
 	/**
-	 * @throws {Error} - Thrown when it fails to get WebGL context for the canvas 
+	 * @throws {Error} - Thrown when it fails to get WebGL context for the canvas
 	 */
 	constructor(options: {
 		sakura?: boolean;
@@ -172,7 +172,7 @@ export class SnowfallEffect {
 		const gl = canvas.getContext('webgl2', { antialias: true });
 		if (gl == null) throw new Error('Failed to get WebGL context');
 
-		document.body.append(canvas);
+		window.document.body.append(canvas);
 
 		this.canvas = canvas;
 		this.gl = gl;
@@ -190,7 +190,7 @@ export class SnowfallEffect {
 	}
 
 	private initCanvas(): HTMLCanvasElement {
-		const canvas = document.createElement('canvas');
+		const canvas = window.document.createElement('canvas');
 
 		Object.assign(canvas.style, {
 			position: 'fixed',
diff --git a/packages/frontend/src/utility/sound.ts b/packages/frontend/src/utility/sound.ts
index 120f480b63..796af0e5ca 100644
--- a/packages/frontend/src/utility/sound.ts
+++ b/packages/frontend/src/utility/sound.ts
@@ -226,7 +226,7 @@ export function createSourceNode(buffer: AudioBuffer, opts: {
  * @param file ファイルのURL（ドライブIDではない）
  */
 export async function getSoundDuration(file: string): Promise<number> {
-	const audioEl = document.createElement('audio');
+	const audioEl = window.document.createElement('audio');
 	audioEl.src = file;
 	return new Promise((resolve) => {
 		const si = setInterval(() => {
@@ -249,7 +249,7 @@ export function isMute(): boolean {
 	}
 
 	// noinspection RedundantIfStatementJS
-	if (prefer.s['sound.useSoundOnlyWhenActive'] && document.visibilityState === 'hidden') {
+	if (prefer.s['sound.useSoundOnlyWhenActive'] && window.document.visibilityState === 'hidden') {
 		// ブラウザがアクティブな時のみサウンドを出力する
 		return true;
 	}
diff --git a/packages/frontend/src/utility/sticky-sidebar.ts b/packages/frontend/src/utility/sticky-sidebar.ts
index 50f1e6ecc8..867c9b8324 100644
--- a/packages/frontend/src/utility/sticky-sidebar.ts
+++ b/packages/frontend/src/utility/sticky-sidebar.ts
@@ -18,7 +18,7 @@ export class StickySidebar {
 		this.container = container;
 		this.el = this.container.children[0] as HTMLElement;
 		this.el.style.position = 'sticky';
-		this.spacer = document.createElement('div');
+		this.spacer = window.document.createElement('div');
 		this.container.prepend(this.spacer);
 		this.marginTop = marginTop;
 		this.offsetTop = this.container.getBoundingClientRect().top;
diff --git a/packages/frontend/src/utility/upload/isWebpSupported.ts b/packages/frontend/src/utility/upload/isWebpSupported.ts
index 2511236ecc..affd81fd57 100644
--- a/packages/frontend/src/utility/upload/isWebpSupported.ts
+++ b/packages/frontend/src/utility/upload/isWebpSupported.ts
@@ -6,7 +6,7 @@
 let isWebpSupportedCache: boolean | undefined;
 export function isWebpSupported() {
 	if (isWebpSupportedCache === undefined) {
-		const canvas = document.createElement('canvas');
+		const canvas = window.document.createElement('canvas');
 		canvas.width = 1;
 		canvas.height = 1;
 		isWebpSupportedCache = canvas.toDataURL('image/webp').startsWith('data:image/webp');
diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue
index a7213f4c21..132eb0a629 100644
--- a/packages/frontend/src/widgets/WidgetRss.vue
+++ b/packages/frontend/src/widgets/WidgetRss.vue
@@ -77,7 +77,7 @@ const fetchEndpoint = computed(() => {
 const intervalClear = ref<(() => void) | undefined>();
 
 const tick = () => {
-	if (document.visibilityState === 'hidden' && rawItems.value.length !== 0) return;
+	if (window.document.visibilityState === 'hidden' && rawItems.value.length !== 0) return;
 
 	window.fetch(fetchEndpoint.value, {})
 		.then(res => res.json())
diff --git a/packages/frontend/src/widgets/WidgetRssTicker.vue b/packages/frontend/src/widgets/WidgetRssTicker.vue
index 13b76533d7..b5be4d35c2 100644
--- a/packages/frontend/src/widgets/WidgetRssTicker.vue
+++ b/packages/frontend/src/widgets/WidgetRssTicker.vue
@@ -108,7 +108,7 @@ const intervalClear = ref<(() => void) | undefined>();
 const key = ref(0);
 
 const tick = () => {
-	if (document.visibilityState === 'hidden' && rawItems.value.length !== 0) return;
+	if (window.document.visibilityState === 'hidden' && rawItems.value.length !== 0) return;
 
 	window.fetch(fetchEndpoint.value, {})
 		.then(res => res.json())
diff --git a/packages/frontend/test/scroll.test.ts b/packages/frontend/test/scroll.test.ts
index 32a5a1c558..34e7e64313 100644
--- a/packages/frontend/test/scroll.test.ts
+++ b/packages/frontend/test/scroll.test.ts
@@ -12,10 +12,10 @@ describe('Scroll', () => {
 		/* 動作しない(happy-domのバグ？)
 		test('Initial onScrollTop callback for connected elements', () => {
 			const { document } = new Window();
-			const div = document.createElement('div');
+			const div = window.document.createElement('div');
 			assert.strictEqual(div.scrollTop, 0);
 
-			document.body.append(div);
+			window.document.body.append(div);
 
 			let called = false;
 			onScrollTop(div as any as HTMLElement, () => called = true);
@@ -26,7 +26,7 @@ describe('Scroll', () => {
 
 		test('No onScrollTop callback for disconnected elements', () => {
 			const { document } = new Window();
-			const div = document.createElement('div');
+			const div = window.document.createElement('div');
 			assert.strictEqual(div.scrollTop, 0);
 
 			let called = false;
@@ -40,10 +40,10 @@ describe('Scroll', () => {
 		/* 動作しない(happy-domのバグ？)
 		test('Initial onScrollBottom callback for connected elements', () => {
 			const { document } = new Window();
-			const div = document.createElement('div');
+			const div = window.document.createElement('div');
 			assert.strictEqual(div.scrollTop, 0);
 
-			document.body.append(div);
+			window.document.body.append(div);
 
 			let called = false;
 			onScrollBottom(div as any as HTMLElement, () => called = true);
@@ -54,7 +54,7 @@ describe('Scroll', () => {
 
 		test('No onScrollBottom callback for disconnected elements', () => {
 			const { document } = new Window();
-			const div = document.createElement('div');
+			const div = window.document.createElement('div');
 			assert.strictEqual(div.scrollTop, 0);
 
 			let called = false;
-- 
cgit v1.2.3-freya


From f1f24e39d2df3135493e2c2087230b428e2d02b7 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Mon, 24 Mar 2025 21:32:46 +0900
Subject: Feat: Chat (#15686)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* wip

* wip

* wip

* wip

* wip

* wip

* Update types.ts

* Create 1742203321812-chat.js

* wip

* wip

* Update room.vue

* Update home.vue

* Update home.vue

* Update ja-JP.yml

* Update index.d.ts

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update CHANGELOG.md

* wip

* Update home.vue

* clean up

* Update misskey-js.api.md

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* lint fixes

* lint

* Update UserEntityService.ts

* search

* wip

* 🎨

* wip

* Update home.ownedRooms.vue

* wip

* Update CHANGELOG.md

* Update style.scss

* wip

* improve performance

* improve performance

* Update timeline.test.ts
---
 CHANGELOG.md                                       |   13 +-
 locales/index.d.ts                                 |  189 ++-
 locales/ja-JP.yml                                  |   50 +-
 packages/backend/migration/1742203321812-chat.js   |   46 +
 packages/backend/migration/1742608337548-chat-2.js |   18 +
 packages/backend/migration/1742617546147-chat-3.js |   26 +
 packages/backend/migration/1742707840715-chat-4.js |   26 +
 packages/backend/migration/1742721896936-chat-5.js |   16 +
 packages/backend/migration/1742795111958-chat-6.js |   20 +
 packages/backend/src/core/ChatService.ts           |  776 +++++++++
 packages/backend/src/core/CoreModule.ts            |   18 +-
 packages/backend/src/core/GlobalEventService.ts    |   38 +-
 packages/backend/src/core/NoteCreateService.ts     |   27 -
 packages/backend/src/core/NoteReadService.ts       |  143 --
 packages/backend/src/core/RoleService.ts           |    3 +
 packages/backend/src/core/UserFollowingService.ts  |   28 +-
 packages/backend/src/core/WebhookTestService.ts    |    2 +
 .../backend/src/core/entities/ChatEntityService.ts |  376 +++++
 .../backend/src/core/entities/UserEntityService.ts |   20 +-
 packages/backend/src/di-symbols.ts                 |    6 +-
 packages/backend/src/misc/json-schema.ts           |   87 +-
 packages/backend/src/models/ChatApproval.ts        |   39 +
 packages/backend/src/models/ChatMessage.ts         |   85 +
 packages/backend/src/models/ChatRoom.ts            |   41 +
 packages/backend/src/models/ChatRoomInvitation.ts  |   45 +
 packages/backend/src/models/ChatRoomMembership.ts  |   45 +
 packages/backend/src/models/NoteUnread.ts          |   68 -
 packages/backend/src/models/RepositoryModule.ts    |   60 +-
 packages/backend/src/models/User.ts                |   11 +
 packages/backend/src/models/_.ts                   |   23 +-
 .../backend/src/models/json-schema/chat-message.ts |  146 ++
 .../src/models/json-schema/chat-room-invitation.ts |   37 +
 .../src/models/json-schema/chat-room-membership.ts |   37 +
 .../backend/src/models/json-schema/chat-room.ts    |   40 +
 packages/backend/src/models/json-schema/role.ts    |    4 +
 packages/backend/src/models/json-schema/user.ts    |    9 +
 packages/backend/src/postgres.ts                   |   21 +-
 packages/backend/src/server/ServerModule.ts        |    4 +
 .../src/server/api/StreamingApiServerService.ts    |    3 -
 packages/backend/src/server/api/endpoint-list.ts   |   23 +-
 .../src/server/api/endpoints/antennas/notes.ts     |    7 -
 .../src/server/api/endpoints/chat/history.ts       |   73 +
 .../api/endpoints/chat/messages/create-to-room.ts  |  105 ++
 .../api/endpoints/chat/messages/create-to-user.ts  |  122 ++
 .../server/api/endpoints/chat/messages/delete.ts   |   52 +
 .../server/api/endpoints/chat/messages/react.ts    |   49 +
 .../api/endpoints/chat/messages/room-timeline.ts   |   73 +
 .../server/api/endpoints/chat/messages/search.ts   |   76 +
 .../src/server/api/endpoints/chat/messages/show.ts |   63 +
 .../api/endpoints/chat/messages/user-timeline.ts   |   71 +
 .../src/server/api/endpoints/chat/rooms/create.ts  |   62 +
 .../src/server/api/endpoints/chat/rooms/delete.ts  |   52 +
 .../api/endpoints/chat/rooms/invitations/create.ts |   68 +
 .../api/endpoints/chat/rooms/invitations/ignore.ts |   48 +
 .../api/endpoints/chat/rooms/invitations/inbox.ts  |   54 +
 .../src/server/api/endpoints/chat/rooms/join.ts    |   48 +
 .../src/server/api/endpoints/chat/rooms/joining.ts |   58 +
 .../src/server/api/endpoints/chat/rooms/leave.ts   |   48 +
 .../src/server/api/endpoints/chat/rooms/members.ts |   74 +
 .../src/server/api/endpoints/chat/rooms/mute.ts    |   49 +
 .../src/server/api/endpoints/chat/rooms/owned.ts   |   54 +
 .../src/server/api/endpoints/chat/rooms/show.ts    |   58 +
 .../src/server/api/endpoints/chat/rooms/update.ts  |   65 +
 .../api/endpoints/i/notifications-grouped.ts       |   13 -
 .../src/server/api/endpoints/i/notifications.ts    |   11 -
 .../api/endpoints/i/read-all-unread-notes.ts       |   45 -
 .../backend/src/server/api/endpoints/i/update.ts   |    2 +
 .../src/server/api/endpoints/notes/mentions.ts     |    4 -
 .../api/endpoints/notes/thread-muting/create.ts    |    4 -
 .../src/server/api/stream/ChannelsService.ts       |    6 +
 .../backend/src/server/api/stream/Connection.ts    |   17 +-
 .../src/server/api/stream/channels/chat-room.ts    |   78 +
 .../src/server/api/stream/channels/chat-user.ts    |   78 +
 .../backend/test-federation/test/timeline.test.ts  |    2 +-
 packages/backend/test/e2e/mute.ts                  |   22 -
 packages/backend/test/e2e/thread-mute.ts           |   42 -
 packages/backend/test/e2e/users.ts                 |    6 +-
 .../test/unit/entities/UserEntityService.ts        |    2 +
 .../frontend-embed/src/components/EmPagination.vue |   20 +-
 packages/frontend-shared/js/const.ts               |    1 +
 packages/frontend-shared/js/scroll.ts              |   12 +-
 packages/frontend/src/boot/main-boot.ts            |   25 +-
 packages/frontend/src/components/MkButton.vue      |   10 +-
 .../src/components/MkDateSeparatedList.vue         |   22 +-
 packages/frontend/src/components/MkFukidashi.vue   |    9 +-
 packages/frontend/src/components/MkMediaList.vue   |    1 -
 packages/frontend/src/components/MkMenu.vue        |   66 +-
 packages/frontend/src/components/MkPagination.vue  |   39 +-
 packages/frontend/src/components/MkPolkadots.vue   |   40 +
 packages/frontend/src/components/MkUrlPreview.vue  |    1 +
 packages/frontend/src/local-storage.ts             |    2 +-
 packages/frontend/src/navbar.ts                    |    8 +-
 packages/frontend/src/pages/admin/roles.editor.vue |   20 +
 packages/frontend/src/pages/admin/roles.vue        |    8 +
 packages/frontend/src/pages/chat/XMessage.vue      |  245 +++
 packages/frontend/src/pages/chat/XRoom.vue         |   41 +
 packages/frontend/src/pages/chat/home.home.vue     |  252 +++
 .../frontend/src/pages/chat/home.invitations.vue   |   98 ++
 .../frontend/src/pages/chat/home.joiningRooms.vue  |   54 +
 .../frontend/src/pages/chat/home.ownedRooms.vue    |   54 +
 packages/frontend/src/pages/chat/home.vue          |   60 +
 packages/frontend/src/pages/chat/message.vue       |   55 +
 packages/frontend/src/pages/chat/room.form.vue     |  333 ++++
 packages/frontend/src/pages/chat/room.info.vue     |   87 +
 packages/frontend/src/pages/chat/room.members.vue  |   73 +
 packages/frontend/src/pages/chat/room.search.vue   |   68 +
 packages/frontend/src/pages/chat/room.vue          |  426 +++++
 .../frontend/src/pages/settings/notifications.vue  |    5 -
 packages/frontend/src/pages/settings/privacy.vue   |   16 +
 packages/frontend/src/pages/settings/sounds.vue    |    1 +
 packages/frontend/src/preferences/def.ts           |    7 +
 packages/frontend/src/router.definition.ts         |   16 +
 packages/frontend/src/style.scss                   |    8 +-
 packages/frontend/src/types/menu.ts                |   16 +-
 packages/frontend/src/ui/_common_/navbar.vue       |    8 +-
 .../src/utility/autogen/settings-search-index.ts   |   13 +-
 packages/frontend/src/utility/get-user-menu.ts     |   73 +-
 packages/frontend/src/utility/sound.ts             |    1 +
 packages/frontend/src/utility/upload.ts            |    2 +-
 packages/misskey-js/README.md                      |   10 +-
 packages/misskey-js/etc/misskey-js.api.md          |  210 ++-
 packages/misskey-js/src/autogen/apiClientJSDoc.ts  |  253 ++-
 packages/misskey-js/src/autogen/endpoint.ts        |   67 +-
 packages/misskey-js/src/autogen/entities.ts        |   44 +
 packages/misskey-js/src/autogen/models.ts          |    5 +
 packages/misskey-js/src/autogen/types.ts           | 1683 ++++++++++++++++++--
 packages/misskey-js/src/consts.ts                  |    6 +-
 packages/misskey-js/src/streaming.types.ts         |    7 +-
 packages/misskey-js/test/streaming.ts              |   38 +-
 129 files changed, 8226 insertions(+), 823 deletions(-)
 create mode 100644 packages/backend/migration/1742203321812-chat.js
 create mode 100644 packages/backend/migration/1742608337548-chat-2.js
 create mode 100644 packages/backend/migration/1742617546147-chat-3.js
 create mode 100644 packages/backend/migration/1742707840715-chat-4.js
 create mode 100644 packages/backend/migration/1742721896936-chat-5.js
 create mode 100644 packages/backend/migration/1742795111958-chat-6.js
 create mode 100644 packages/backend/src/core/ChatService.ts
 delete mode 100644 packages/backend/src/core/NoteReadService.ts
 create mode 100644 packages/backend/src/core/entities/ChatEntityService.ts
 create mode 100644 packages/backend/src/models/ChatApproval.ts
 create mode 100644 packages/backend/src/models/ChatMessage.ts
 create mode 100644 packages/backend/src/models/ChatRoom.ts
 create mode 100644 packages/backend/src/models/ChatRoomInvitation.ts
 create mode 100644 packages/backend/src/models/ChatRoomMembership.ts
 delete mode 100644 packages/backend/src/models/NoteUnread.ts
 create mode 100644 packages/backend/src/models/json-schema/chat-message.ts
 create mode 100644 packages/backend/src/models/json-schema/chat-room-invitation.ts
 create mode 100644 packages/backend/src/models/json-schema/chat-room-membership.ts
 create mode 100644 packages/backend/src/models/json-schema/chat-room.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/history.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/messages/delete.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/messages/react.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/messages/search.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/messages/show.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/rooms/create.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/rooms/delete.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/rooms/join.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/rooms/joining.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/rooms/leave.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/rooms/members.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/rooms/mute.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/rooms/owned.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/rooms/show.ts
 create mode 100644 packages/backend/src/server/api/endpoints/chat/rooms/update.ts
 delete mode 100644 packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts
 create mode 100644 packages/backend/src/server/api/stream/channels/chat-room.ts
 create mode 100644 packages/backend/src/server/api/stream/channels/chat-user.ts
 create mode 100644 packages/frontend/src/components/MkPolkadots.vue
 create mode 100644 packages/frontend/src/pages/chat/XMessage.vue
 create mode 100644 packages/frontend/src/pages/chat/XRoom.vue
 create mode 100644 packages/frontend/src/pages/chat/home.home.vue
 create mode 100644 packages/frontend/src/pages/chat/home.invitations.vue
 create mode 100644 packages/frontend/src/pages/chat/home.joiningRooms.vue
 create mode 100644 packages/frontend/src/pages/chat/home.ownedRooms.vue
 create mode 100644 packages/frontend/src/pages/chat/home.vue
 create mode 100644 packages/frontend/src/pages/chat/message.vue
 create mode 100644 packages/frontend/src/pages/chat/room.form.vue
 create mode 100644 packages/frontend/src/pages/chat/room.info.vue
 create mode 100644 packages/frontend/src/pages/chat/room.members.vue
 create mode 100644 packages/frontend/src/pages/chat/room.search.vue
 create mode 100644 packages/frontend/src/pages/chat/room.vue

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

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 80d505f696..ff95b6b5dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,17 @@
 ## 2025.3.2
 
 ### General
-- セキュリティを強化するため、ジョブキューのダッシュボード(bull-board)統合が削除されました。
+- Feat: チャットがリニューアルして復活しました(beta)
+  - 既存のDM機能よりも便利で効率的な実装になっています
+  - チャットを受け付ける相手を制限可能です
+    - 誰でも / フォローユーザーのみ / フォロワーのみ / 相互のみ / 受け付けない から選択できます
+    - 自分からメッセージを送った相手とは上記の設定に関わらずチャット可能です
+  - チャット機能を開放するかどうかをロールで制御可能です
+  - ルームを作成して、複数人でのチャットも可能です
+  - 過去自分が送ったメッセージ・自分に送られたメッセージの検索が可能です
+  - 参加中のルームをミュートして通知が来ないように設定可能です
+  - メッセージにはリアクションも可能です
+- Enhance: セキュリティを強化するため、ジョブキューのダッシュボード(bull-board)統合が削除されました。
   - Misskeyネイティブでダッシュボードを実装予定です
 
 ### Client
@@ -37,6 +47,7 @@
 - Fix: テーマ切り替え時に一部の色が変わらない問題を修正
 
 ### Server
+- Enhance 全体的なパフォーマンス向上
 - Fix: プロフィール追加情報で無効なURLに入力された場合に照会エラーを出るのを修正
 - Fix: ActivityPubリクエストURLチェック実装は仕様に従っていないのを修正
 - Fix: 連合無しモードでも外部から照会可能だった問題を修正
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 2ac82edd37..9ef6f9aa39 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -1174,10 +1174,6 @@ export interface Locale extends ILocale {
      * 保存しました
      */
     "saved": string;
-    /**
-     * チャット
-     */
-    "messaging": string;
     /**
      * アップロード
      */
@@ -1227,9 +1223,9 @@ export interface Locale extends ILocale {
      */
     "noMoreHistory": string;
     /**
-     * チャットを開始
+     * チャットを始める
      */
-    "startMessaging": string;
+    "startChat": string;
     /**
      * {n}人が読みました
      */
@@ -1982,14 +1978,6 @@ export interface Locale extends ILocale {
      * クリップボードのテキストが長いです。テキストファイルとして添付しますか？
      */
     "attachAsFileQuestion": string;
-    /**
-     * まだチャットはありません
-     */
-    "noMessagesYet": string;
-    /**
-     * 新しいメッセージがあります
-     */
-    "newMessageExists": string;
     /**
      * メッセージに添付できるファイルはひとつです
      */
@@ -5358,6 +5346,163 @@ export interface Locale extends ILocale {
      * 情報
      */
     "information": string;
+    /**
+     * チャット
+     */
+    "chat": string;
+    "_chat": {
+        /**
+         * まだメッセージはありません
+         */
+        "noMessagesYet": string;
+        /**
+         * 新しいメッセージ
+         */
+        "newMessage": string;
+        /**
+         * 個人チャット
+         */
+        "individualChat": string;
+        /**
+         * 特定ユーザーとの一対一のチャットができます。
+         */
+        "individualChat_description": string;
+        /**
+         * ルームチャット
+         */
+        "roomChat": string;
+        /**
+         * 複数人でのチャットができます。
+         * また、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。
+         */
+        "roomChat_description": string;
+        /**
+         * ルームを作成
+         */
+        "createRoom": string;
+        /**
+         * ユーザーを招待してチャットを始めましょう
+         */
+        "inviteUserToChat": string;
+        /**
+         * 作成したルーム
+         */
+        "yourRooms": string;
+        /**
+         * 参加中のルーム
+         */
+        "joiningRooms": string;
+        /**
+         * 招待
+         */
+        "invitations": string;
+        /**
+         * 招待はありません
+         */
+        "noInvitations": string;
+        /**
+         * 履歴
+         */
+        "history": string;
+        /**
+         * 履歴はありません
+         */
+        "noHistory": string;
+        /**
+         * ルームはありません
+         */
+        "noRooms": string;
+        /**
+         * ユーザーを招待
+         */
+        "inviteUser": string;
+        /**
+         * 参加
+         */
+        "join": string;
+        /**
+         * 無視
+         */
+        "ignore": string;
+        /**
+         * ルームから退出
+         */
+        "leave": string;
+        /**
+         * メンバー
+         */
+        "members": string;
+        /**
+         * メッセージを検索
+         */
+        "searchMessages": string;
+        /**
+         * ホーム
+         */
+        "home": string;
+        /**
+         * このルームをミュート
+         */
+        "muteThisRoom": string;
+        /**
+         * このユーザーとのチャットを開始できません
+         */
+        "cannotChatWithTheUser": string;
+        /**
+         * チャットが使えない状態になっているか、相手がチャットを開放していません。
+         */
+        "cannotChatWithTheUser_description": string;
+        /**
+         * チャットする
+         */
+        "chatWithThisUser": string;
+        /**
+         * このユーザーはフォロワーからのみチャットを受け付けています。
+         */
+        "thisUserAllowsChatOnlyFromFollowers": string;
+        /**
+         * このユーザーはフォローしているユーザーからのみチャットを受け付けています。
+         */
+        "thisUserAllowsChatOnlyFromFollowing": string;
+        /**
+         * このユーザーは相互フォローのユーザーからのみチャットを受け付けています。
+         */
+        "thisUserAllowsChatOnlyFromMutualFollowing": string;
+        /**
+         * このユーザーは誰からもチャットを受け付けていません。
+         */
+        "thisUserNotAllowedChatAnyone": string;
+        /**
+         * チャットを許可する相手
+         */
+        "chatAllowedUsers": string;
+        /**
+         * 自分からチャットメッセージを送った相手とはこの設定に関わらずチャットが可能です。
+         */
+        "chatAllowedUsers_note": string;
+        "_chatAllowedUsers": {
+            /**
+             * 誰でも
+             */
+            "everyone": string;
+            /**
+             * 自分のフォロワーのみ
+             */
+            "followers": string;
+            /**
+             * 自分がフォローしているユーザーのみ
+             */
+            "following": string;
+            /**
+             * 相互フォローのユーザーのみ
+             */
+            "mutual": string;
+            /**
+             * 誰も許可しない
+             */
+            "none": string;
+        };
+    };
     "_emojiPalette": {
         /**
          * パレット
@@ -7285,6 +7430,10 @@ export interface Locale extends ILocale {
              * リストのインポートを許可
              */
             "canImportUserLists": string;
+            /**
+             * チャットを許可
+             */
+            "canChat": string;
         };
         "_condition": {
             /**
@@ -8154,6 +8303,10 @@ export interface Locale extends ILocale {
          * リアクション選択時
          */
         "reaction": string;
+        /**
+         * チャットのメッセージ
+         */
+        "chatMessage": string;
     };
     "_soundSettings": {
         /**
@@ -8726,6 +8879,14 @@ export interface Locale extends ILocale {
          * 違反を報告する
          */
         "write:report-abuse": string;
+        /**
+         * チャットを操作する
+         */
+        "write:chat": string;
+        /**
+         * チャットを閲覧する
+         */
+        "read:chat": string;
     };
     "_auth": {
         /**
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 35f368d3ed..fccf24e759 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -289,7 +289,6 @@ deleteAreYouSure: "「{x}」を削除しますか？"
 resetAreYouSure: "リセットしますか？"
 areYouSure: "よろしいですか？"
 saved: "保存しました"
-messaging: "チャット"
 upload: "アップロード"
 keepOriginalUploading: "オリジナル画像を保持"
 keepOriginalUploadingDescription: "画像をアップロードする時にオリジナル版を保持します。オフにするとアップロード時にブラウザでWeb公開用画像を生成します。"
@@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がか
 explore: "みつける"
 messageRead: "既読"
 noMoreHistory: "これより過去の履歴はありません"
-startMessaging: "チャットを開始"
+startChat: "チャットを始める"
 nUsersRead: "{n}人が読みました"
 agreeTo: "{0}に同意"
 agree: "同意する"
@@ -491,8 +490,6 @@ noteOf: "{user}のノート"
 quoteAttached: "引用付き"
 quoteQuestion: "引用として添付しますか？"
 attachAsFileQuestion: "クリップボードのテキストが長いです。テキストファイルとして添付しますか？"
-noMessagesYet: "まだチャットはありません"
-newMessageExists: "新しいメッセージがあります"
 onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです"
 signinRequired: "続行する前に、登録またはログインが必要です"
 signinOrContinueOnRemote: "続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があります"
@@ -1335,6 +1332,47 @@ emojiPalette: "絵文字パレット"
 postForm: "投稿フォーム"
 textCount: "文字数"
 information: "情報"
+chat: "チャット"
+
+_chat:
+  noMessagesYet: "まだメッセージはありません"
+  newMessage: "新しいメッセージ"
+  individualChat: "個人チャット"
+  individualChat_description: "特定ユーザーとの一対一のチャットができます。"
+  roomChat: "ルームチャット"
+  roomChat_description: "複数人でのチャットができます。\nまた、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。"
+  createRoom: "ルームを作成"
+  inviteUserToChat: "ユーザーを招待してチャットを始めましょう"
+  yourRooms: "作成したルーム"
+  joiningRooms: "参加中のルーム"
+  invitations: "招待"
+  noInvitations: "招待はありません"
+  history: "履歴"
+  noHistory: "履歴はありません"
+  noRooms: "ルームはありません"
+  inviteUser: "ユーザーを招待"
+  join: "参加"
+  ignore: "無視"
+  leave: "ルームから退出"
+  members: "メンバー"
+  searchMessages: "メッセージを検索"
+  home: "ホーム"
+  muteThisRoom: "このルームをミュート"
+  cannotChatWithTheUser: "このユーザーとのチャットを開始できません"
+  cannotChatWithTheUser_description: "チャットが使えない状態になっているか、相手がチャットを開放していません。"
+  chatWithThisUser: "チャットする"
+  thisUserAllowsChatOnlyFromFollowers: "このユーザーはフォロワーからのみチャットを受け付けています。"
+  thisUserAllowsChatOnlyFromFollowing: "このユーザーはフォローしているユーザーからのみチャットを受け付けています。"
+  thisUserAllowsChatOnlyFromMutualFollowing: "このユーザーは相互フォローのユーザーからのみチャットを受け付けています。"
+  thisUserNotAllowedChatAnyone: "このユーザーは誰からもチャットを受け付けていません。"
+  chatAllowedUsers: "チャットを許可する相手"
+  chatAllowedUsers_note: "自分からチャットメッセージを送った相手とはこの設定に関わらずチャットが可能です。"
+  _chatAllowedUsers:
+    everyone: "誰でも"
+    followers: "自分のフォロワーのみ"
+    following: "自分がフォローしているユーザーのみ"
+    mutual: "相互フォローのユーザーのみ"
+    none: "誰も許可しない"
 
 _emojiPalette:
   palettes: "パレット"
@@ -1886,6 +1924,7 @@ _role:
     canImportFollowing: "フォローのインポートを許可"
     canImportMuting: "ミュートのインポートを許可"
     canImportUserLists: "リストのインポートを許可"
+    canChat: "チャットを許可"
   _condition:
     roleAssignedTo: "マニュアルロールにアサイン済み"
     isLocal: "ローカルユーザー"
@@ -2139,6 +2178,7 @@ _sfx:
   noteMy: "ノート(自分)"
   notification: "通知"
   reaction: "リアクション選択時"
+  chatMessage: "チャットのメッセージ"
 
 _soundSettings:
   driveFile: "ドライブの音声を使用"
@@ -2291,6 +2331,8 @@ _permissions:
   "read:clip-favorite": "クリップのいいねを見る"
   "read:federation": "連合に関する情報を取得する"
   "write:report-abuse": "違反を報告する"
+  "write:chat": "チャットを操作する"
+  "read:chat": "チャットを閲覧する"
 
 _auth:
   shareAccessTitle: "アプリへのアクセス許可"
diff --git a/packages/backend/migration/1742203321812-chat.js b/packages/backend/migration/1742203321812-chat.js
new file mode 100644
index 0000000000..3d8f7276b5
--- /dev/null
+++ b/packages/backend/migration/1742203321812-chat.js
@@ -0,0 +1,46 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Chat1742203321812 {
+    name = 'Chat1742203321812'
+
+    async up(queryRunner) {
+        await queryRunner.query(`CREATE TABLE "chat_room" ("id" character varying(32) NOT NULL, "name" character varying(256) NOT NULL, "ownerId" character varying(32) NOT NULL, CONSTRAINT "PK_8aa3a52cf74c96469f0ef9fbe3e" PRIMARY KEY ("id"))`);
+        await queryRunner.query(`CREATE INDEX "IDX_f0d8ad64243fa2ca2800da0dfd" ON "chat_room" ("ownerId") `);
+        await queryRunner.query(`CREATE TABLE "chat_message" ("id" character varying(32) NOT NULL, "fromUserId" character varying(32) NOT NULL, "toUserId" character varying(32), "toRoomId" character varying(32), "text" character varying(4096), "uri" character varying(512), "reads" character varying(32) array NOT NULL DEFAULT '{}', "fileId" character varying(32), "reactions" character varying(1024) array NOT NULL DEFAULT '{}', CONSTRAINT "PK_3cc0d85193aade457d3077dd06b" PRIMARY KEY ("id"))`);
+        await queryRunner.query(`CREATE INDEX "IDX_79a26e7a4d9afa5e4fc05f134e" ON "chat_message" ("fromUserId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_25e097b51d7622c249452c6f75" ON "chat_message" ("toUserId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_f006b8a76efd1abf9f221c175c" ON "chat_message" ("toRoomId") `);
+        await queryRunner.query(`CREATE TABLE "chat_room_membership" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "roomId" character varying(32) NOT NULL, CONSTRAINT "PK_2bd59c741e571b283c048beb69a" PRIMARY KEY ("id"))`);
+        await queryRunner.query(`CREATE INDEX "IDX_d99c5279460fb77ef58c596ce5" ON "chat_room_membership" ("userId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_c25143ebab714e930aeca1c0e8" ON "chat_room_membership" ("roomId") `);
+        await queryRunner.query(`ALTER TABLE "chat_room" ADD CONSTRAINT "FK_f0d8ad64243fa2ca2800da0dfd6" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_79a26e7a4d9afa5e4fc05f134ed" FOREIGN KEY ("fromUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_25e097b51d7622c249452c6f757" FOREIGN KEY ("toUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_f006b8a76efd1abf9f221c175ce" FOREIGN KEY ("toRoomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_fd0f9a4879430239715ad4f8e2a" FOREIGN KEY ("fileId") REFERENCES "drive_file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "chat_room_membership" ADD CONSTRAINT "FK_d99c5279460fb77ef58c596ce51" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "chat_room_membership" ADD CONSTRAINT "FK_c25143ebab714e930aeca1c0e8d" FOREIGN KEY ("roomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "chat_room_membership" DROP CONSTRAINT "FK_c25143ebab714e930aeca1c0e8d"`);
+        await queryRunner.query(`ALTER TABLE "chat_room_membership" DROP CONSTRAINT "FK_d99c5279460fb77ef58c596ce51"`);
+        await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_fd0f9a4879430239715ad4f8e2a"`);
+        await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_f006b8a76efd1abf9f221c175ce"`);
+        await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_25e097b51d7622c249452c6f757"`);
+        await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_79a26e7a4d9afa5e4fc05f134ed"`);
+        await queryRunner.query(`ALTER TABLE "chat_room" DROP CONSTRAINT "FK_f0d8ad64243fa2ca2800da0dfd6"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_c25143ebab714e930aeca1c0e8"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_d99c5279460fb77ef58c596ce5"`);
+        await queryRunner.query(`DROP TABLE "chat_room_membership"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_f006b8a76efd1abf9f221c175c"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_25e097b51d7622c249452c6f75"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_79a26e7a4d9afa5e4fc05f134e"`);
+        await queryRunner.query(`DROP TABLE "chat_message"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_f0d8ad64243fa2ca2800da0dfd"`);
+        await queryRunner.query(`DROP TABLE "chat_room"`);
+    }
+}
diff --git a/packages/backend/migration/1742608337548-chat-2.js b/packages/backend/migration/1742608337548-chat-2.js
new file mode 100644
index 0000000000..9f74a263d6
--- /dev/null
+++ b/packages/backend/migration/1742608337548-chat-2.js
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Chat21742608337548 {
+    name = 'Chat21742608337548'
+
+    async up(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "user" ADD "chatScope" character varying(128) NOT NULL DEFAULT 'mutual'`);
+        await queryRunner.query(`CREATE UNIQUE INDEX "IDX_185b6b5afa707b5d36d1ce3144" ON "chat_room_membership" ("userId", "roomId") `);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`DROP INDEX "public"."IDX_185b6b5afa707b5d36d1ce3144"`);
+        await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "chatScope"`);
+    }
+}
diff --git a/packages/backend/migration/1742617546147-chat-3.js b/packages/backend/migration/1742617546147-chat-3.js
new file mode 100644
index 0000000000..116b9a738b
--- /dev/null
+++ b/packages/backend/migration/1742617546147-chat-3.js
@@ -0,0 +1,26 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Chat31742617546147 {
+    name = 'Chat31742617546147'
+
+    async up(queryRunner) {
+        await queryRunner.query(`CREATE TABLE "chat_approval" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "otherId" character varying(32) NOT NULL, CONSTRAINT "PK_fbbb95d60acf5c85388345b5f5d" PRIMARY KEY ("id"))`);
+        await queryRunner.query(`CREATE INDEX "IDX_530257863e1381a7f2f1d3282f" ON "chat_approval" ("userId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_b1d46037f23d170da5c05fdf75" ON "chat_approval" ("otherId") `);
+        await queryRunner.query(`CREATE UNIQUE INDEX "IDX_12c4768a2f706fc267f2078903" ON "chat_approval" ("userId", "otherId") `);
+        await queryRunner.query(`ALTER TABLE "chat_approval" ADD CONSTRAINT "FK_530257863e1381a7f2f1d3282fe" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "chat_approval" ADD CONSTRAINT "FK_b1d46037f23d170da5c05fdf755" FOREIGN KEY ("otherId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "chat_approval" DROP CONSTRAINT "FK_b1d46037f23d170da5c05fdf755"`);
+        await queryRunner.query(`ALTER TABLE "chat_approval" DROP CONSTRAINT "FK_530257863e1381a7f2f1d3282fe"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_12c4768a2f706fc267f2078903"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_b1d46037f23d170da5c05fdf75"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_530257863e1381a7f2f1d3282f"`);
+        await queryRunner.query(`DROP TABLE "chat_approval"`);
+    }
+}
diff --git a/packages/backend/migration/1742707840715-chat-4.js b/packages/backend/migration/1742707840715-chat-4.js
new file mode 100644
index 0000000000..953a53d880
--- /dev/null
+++ b/packages/backend/migration/1742707840715-chat-4.js
@@ -0,0 +1,26 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Chat41742707840715 {
+    name = 'Chat41742707840715'
+
+    async up(queryRunner) {
+        await queryRunner.query(`CREATE TABLE "chat_room_invitation" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "roomId" character varying(32) NOT NULL, CONSTRAINT "PK_9d489521a312dd28225672de2dc" PRIMARY KEY ("id"))`);
+        await queryRunner.query(`CREATE INDEX "IDX_8552bb38e7ed038c5bdd398a38" ON "chat_room_invitation" ("userId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_5f265075b215fc390a57523b12" ON "chat_room_invitation" ("roomId") `);
+        await queryRunner.query(`CREATE UNIQUE INDEX "IDX_044f2a7962b8ee5bbfaa02e8a3" ON "chat_room_invitation" ("userId", "roomId") `);
+        await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD CONSTRAINT "FK_8552bb38e7ed038c5bdd398a384" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD CONSTRAINT "FK_5f265075b215fc390a57523b12a" FOREIGN KEY ("roomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP CONSTRAINT "FK_5f265075b215fc390a57523b12a"`);
+        await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP CONSTRAINT "FK_8552bb38e7ed038c5bdd398a384"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_044f2a7962b8ee5bbfaa02e8a3"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_5f265075b215fc390a57523b12"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_8552bb38e7ed038c5bdd398a38"`);
+        await queryRunner.query(`DROP TABLE "chat_room_invitation"`);
+    }
+}
diff --git a/packages/backend/migration/1742721896936-chat-5.js b/packages/backend/migration/1742721896936-chat-5.js
new file mode 100644
index 0000000000..00db787cb7
--- /dev/null
+++ b/packages/backend/migration/1742721896936-chat-5.js
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Chat51742721896936 {
+    name = 'Chat51742721896936'
+
+    async up(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD "ignored" boolean NOT NULL DEFAULT false`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP COLUMN "ignored"`);
+    }
+}
diff --git a/packages/backend/migration/1742795111958-chat-6.js b/packages/backend/migration/1742795111958-chat-6.js
new file mode 100644
index 0000000000..9a5dc3e32f
--- /dev/null
+++ b/packages/backend/migration/1742795111958-chat-6.js
@@ -0,0 +1,20 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Chat61742795111958 {
+    name = 'Chat61742795111958'
+
+    async up(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "chat_room" ADD "description" character varying(2048) NOT NULL DEFAULT ''`);
+        await queryRunner.query(`ALTER TABLE "chat_room" ADD "isArchived" boolean NOT NULL DEFAULT false`);
+        await queryRunner.query(`ALTER TABLE "chat_room_membership" ADD "isMuted" boolean NOT NULL DEFAULT false`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "chat_room_membership" DROP COLUMN "isMuted"`);
+        await queryRunner.query(`ALTER TABLE "chat_room" DROP COLUMN "isArchived"`);
+        await queryRunner.query(`ALTER TABLE "chat_room" DROP COLUMN "description"`);
+    }
+}
diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts
new file mode 100644
index 0000000000..eece5d0da3
--- /dev/null
+++ b/packages/backend/src/core/ChatService.ts
@@ -0,0 +1,776 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import * as Redis from 'ioredis';
+import { Brackets } from 'typeorm';
+import { DI } from '@/di-symbols.js';
+import type { Config } from '@/config.js';
+import { QueueService } from '@/core/QueueService.js';
+import { IdService } from '@/core/IdService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
+import { PushNotificationService } from '@/core/PushNotificationService.js';
+import { bindThis } from '@/decorators.js';
+import type { ChatApprovalsRepository, ChatMessagesRepository, ChatRoomInvitationsRepository, ChatRoomMembershipsRepository, ChatRoomsRepository, MiChatMessage, MiChatRoom, MiChatRoomMembership, MiDriveFile, MiUser, MutingsRepository, UsersRepository } from '@/models/_.js';
+import { UserBlockingService } from '@/core/UserBlockingService.js';
+import { QueryService } from '@/core/QueryService.js';
+import { RoleService } from '@/core/RoleService.js';
+import { UserFollowingService } from '@/core/UserFollowingService.js';
+import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
+import { Packed } from '@/misc/json-schema.js';
+import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
+import { CustomEmojiService } from '@/core/CustomEmojiService.js';
+import { emojiRegex } from '@/misc/emoji-regex.js';
+
+const MAX_ROOM_MEMBERS = 30;
+const MAX_REACTIONS_PER_MESSAGE = 100;
+const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
+
+@Injectable()
+export class ChatService {
+	constructor(
+		@Inject(DI.config)
+		private config: Config,
+
+		@Inject(DI.redis)
+		private redisClient: Redis.Redis,
+
+		@Inject(DI.usersRepository)
+		private usersRepository: UsersRepository,
+
+		@Inject(DI.chatMessagesRepository)
+		private chatMessagesRepository: ChatMessagesRepository,
+
+		@Inject(DI.chatApprovalsRepository)
+		private chatApprovalsRepository: ChatApprovalsRepository,
+
+		@Inject(DI.chatRoomsRepository)
+		private chatRoomsRepository: ChatRoomsRepository,
+
+		@Inject(DI.chatRoomInvitationsRepository)
+		private chatRoomInvitationsRepository: ChatRoomInvitationsRepository,
+
+		@Inject(DI.chatRoomMembershipsRepository)
+		private chatRoomMembershipsRepository: ChatRoomMembershipsRepository,
+
+		@Inject(DI.mutingsRepository)
+		private mutingsRepository: MutingsRepository,
+
+		private userEntityService: UserEntityService,
+		private chatEntityService: ChatEntityService,
+		private idService: IdService,
+		private globalEventService: GlobalEventService,
+		private apRendererService: ApRendererService,
+		private queueService: QueueService,
+		private pushNotificationService: PushNotificationService,
+		private userBlockingService: UserBlockingService,
+		private queryService: QueryService,
+		private roleService: RoleService,
+		private userFollowingService: UserFollowingService,
+		private customEmojiService: CustomEmojiService,
+	) {
+	}
+
+	@bindThis
+	public async createMessageToUser(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: {
+		text?: string | null;
+		file?: MiDriveFile | null;
+		uri?: string | null;
+	}): Promise<Packed<'ChatMessageLite'>> {
+		if (fromUser.id === toUser.id) {
+			throw new Error('yourself');
+		}
+
+		const approvals = await this.chatApprovalsRepository.createQueryBuilder('approval')
+			.where(new Brackets(qb => { // 自分が相手を許可しているか
+				qb.where('approval.userId = :fromUserId', { fromUserId: fromUser.id })
+					.andWhere('approval.otherId = :toUserId', { toUserId: toUser.id });
+			}))
+			.orWhere(new Brackets(qb => { // 相手が自分を許可しているか
+				qb.where('approval.userId = :toUserId', { toUserId: toUser.id })
+					.andWhere('approval.otherId = :fromUserId', { fromUserId: fromUser.id });
+			}))
+			.take(2)
+			.getMany();
+
+		const otherApprovedMe = approvals.some(approval => approval.userId === toUser.id);
+		const iApprovedOther = approvals.some(approval => approval.userId === fromUser.id);
+
+		if (!otherApprovedMe) {
+			if (toUser.chatScope === 'none') {
+				throw new Error('recipient is cannot chat (none)');
+			} else if (toUser.chatScope === 'followers') {
+				const isFollower = await this.userFollowingService.isFollowing(fromUser.id, toUser.id);
+				if (!isFollower) {
+					throw new Error('recipient is cannot chat (followers)');
+				}
+			} else if (toUser.chatScope === 'following') {
+				const isFollowing = await this.userFollowingService.isFollowing(toUser.id, fromUser.id);
+				if (!isFollowing) {
+					throw new Error('recipient is cannot chat (following)');
+				}
+			} else if (toUser.chatScope === 'mutual') {
+				const isMutual = await this.userFollowingService.isMutual(fromUser.id, toUser.id);
+				if (!isMutual) {
+					throw new Error('recipient is cannot chat (mutual)');
+				}
+			}
+		}
+
+		if (!(await this.roleService.getUserPolicies(toUser.id)).canChat) {
+			throw new Error('recipient is cannot chat (policy)');
+		}
+
+		const blocked = await this.userBlockingService.checkBlocked(toUser.id, fromUser.id);
+		if (blocked) {
+			throw new Error('blocked');
+		}
+
+		const message = {
+			id: this.idService.gen(),
+			fromUserId: fromUser.id,
+			toUserId: toUser.id,
+			text: params.text ? params.text.trim() : null,
+			fileId: params.file ? params.file.id : null,
+			reads: [],
+			uri: params.uri ?? null,
+		} satisfies Partial<MiChatMessage>;
+
+		const inserted = await this.chatMessagesRepository.insertOne(message);
+
+		// 相手を許可しておく
+		if (!iApprovedOther) {
+			this.chatApprovalsRepository.insertOne({
+				id: this.idService.gen(),
+				userId: fromUser.id,
+				otherId: toUser.id,
+			});
+		}
+
+		const packedMessage = await this.chatEntityService.packMessageLiteFor1on1(inserted);
+
+		if (this.userEntityService.isLocalUser(toUser)) {
+			const redisPipeline = this.redisClient.pipeline();
+			redisPipeline.set(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`, message.id);
+			redisPipeline.sadd(`newChatMessagesExists:${toUser.id}`, `user:${fromUser.id}`);
+			redisPipeline.exec();
+		}
+
+		if (this.userEntityService.isLocalUser(fromUser)) {
+			// 自分のストリーム
+			this.globalEventService.publishChatUserStream(fromUser.id, toUser.id, 'message', packedMessage);
+		}
+
+		if (this.userEntityService.isLocalUser(toUser)) {
+			// 相手のストリーム
+			this.globalEventService.publishChatUserStream(toUser.id, fromUser.id, 'message', packedMessage);
+		}
+
+		// 3秒経っても既読にならなかったらイベント発行
+		if (this.userEntityService.isLocalUser(toUser)) {
+			setTimeout(async () => {
+				const marker = await this.redisClient.get(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`);
+
+				if (marker == null) return; // 既読
+
+				const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted, toUser);
+				this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo);
+				//this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo);
+			}, 3000);
+		}
+
+		return packedMessage;
+	}
+
+	@bindThis
+	public async createMessageToRoom(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toRoom: MiChatRoom, params: {
+		text?: string | null;
+		file?: MiDriveFile | null;
+		uri?: string | null;
+	}): Promise<Packed<'ChatMessageLite'>> {
+		const memberships = await this.chatRoomMembershipsRepository.findBy({ roomId: toRoom.id });
+
+		if (toRoom.ownerId !== fromUser.id && !memberships.some(member => member.userId === fromUser.id)) {
+			throw new Error('you are not a member of the room');
+		}
+
+		const message = {
+			id: this.idService.gen(),
+			fromUserId: fromUser.id,
+			toRoomId: toRoom.id,
+			text: params.text ? params.text.trim() : null,
+			fileId: params.file ? params.file.id : null,
+			reads: [],
+			uri: params.uri ?? null,
+		} satisfies Partial<MiChatMessage>;
+
+		const inserted = await this.chatMessagesRepository.insertOne(message);
+
+		const packedMessage = await this.chatEntityService.packMessageLiteForRoom(inserted);
+
+		this.globalEventService.publishChatRoomStream(toRoom.id, 'message', packedMessage);
+
+		const redisPipeline = this.redisClient.pipeline();
+		for (const membership of memberships) {
+			if (membership.isMuted) continue;
+
+			redisPipeline.set(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`, message.id);
+			redisPipeline.sadd(`newChatMessagesExists:${membership.userId}`, `room:${toRoom.id}`);
+		}
+		redisPipeline.exec();
+
+		// 3秒経っても既読にならなかったらイベント発行
+		setTimeout(async () => {
+			const redisPipeline = this.redisClient.pipeline();
+			for (const membership of memberships) {
+				redisPipeline.get(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`);
+			}
+			const markers = await redisPipeline.exec();
+			if (markers == null) throw new Error('redis error');
+
+			if (markers.every(marker => marker[1] == null)) return;
+
+			const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted);
+
+			for (let i = 0; i < memberships.length; i++) {
+				const marker = markers[i][1];
+				if (marker == null) continue;
+
+				this.globalEventService.publishMainStream(memberships[i].userId, 'newChatMessage', packedMessageForTo);
+				//this.pushNotificationService.pushNotification(memberships[i].userId, 'newChatMessage', packedMessageForTo);
+			}
+		}, 3000);
+
+		return packedMessage;
+	}
+
+	@bindThis
+	public async readUserChatMessage(
+		readerId: MiUser['id'],
+		senderId: MiUser['id'],
+	): Promise<void> {
+		const redisPipeline = this.redisClient.pipeline();
+		redisPipeline.del(`newUserChatMessageExists:${readerId}:${senderId}`);
+		redisPipeline.srem(`newChatMessagesExists:${readerId}`, `user:${senderId}`);
+		await redisPipeline.exec();
+	}
+
+	@bindThis
+	public async readRoomChatMessage(
+		readerId: MiUser['id'],
+		roomId: MiChatRoom['id'],
+	): Promise<void> {
+		const redisPipeline = this.redisClient.pipeline();
+		redisPipeline.del(`newRoomChatMessageExists:${readerId}:${roomId}`);
+		redisPipeline.srem(`newChatMessagesExists:${readerId}`, `room:${roomId}`);
+		await redisPipeline.exec();
+	}
+
+	@bindThis
+	public findMessageById(messageId: MiChatMessage['id']) {
+		return this.chatMessagesRepository.findOneBy({ id: messageId });
+	}
+
+	@bindThis
+	public findMyMessageById(userId: MiUser['id'], messageId: MiChatMessage['id']) {
+		return this.chatMessagesRepository.findOneBy({ id: messageId, fromUserId: userId });
+	}
+
+	@bindThis
+	public async deleteMessage(message: MiChatMessage) {
+		await this.chatMessagesRepository.delete(message.id);
+
+		if (message.toUserId) {
+			const [fromUser, toUser] = await Promise.all([
+				this.usersRepository.findOneByOrFail({ id: message.fromUserId }),
+				this.usersRepository.findOneByOrFail({ id: message.toUserId }),
+			]);
+
+			if (this.userEntityService.isLocalUser(fromUser)) this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId, 'deleted', message.id);
+			if (this.userEntityService.isLocalUser(toUser)) this.globalEventService.publishChatUserStream(message.toUserId, message.fromUserId, 'deleted', message.id);
+
+			if (this.userEntityService.isLocalUser(fromUser) && this.userEntityService.isRemoteUser(toUser)) {
+				//const activity = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), fromUser));
+				//this.queueService.deliver(fromUser, activity, toUser.inbox);
+			}
+		} else if (message.toRoomId) {
+			this.globalEventService.publishChatRoomStream(message.toRoomId, 'deleted', message.id);
+		}
+	}
+
+	@bindThis
+	public async userTimeline(meId: MiUser['id'], otherId: MiUser['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) {
+		const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId)
+			.andWhere(new Brackets(qb => {
+				qb
+					.where(new Brackets(qb => {
+						qb
+							.where('message.fromUserId = :meId')
+							.andWhere('message.toUserId = :otherId');
+					}))
+					.orWhere(new Brackets(qb => {
+						qb
+							.where('message.fromUserId = :otherId')
+							.andWhere('message.toUserId = :meId');
+					}));
+			}))
+			.setParameter('meId', meId)
+			.setParameter('otherId', otherId);
+
+		const messages = await query.take(limit).getMany();
+
+		return messages;
+	}
+
+	@bindThis
+	public async roomTimeline(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) {
+		const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId)
+			.where('message.toRoomId = :roomId', { roomId })
+			.leftJoinAndSelect('message.file', 'file')
+			.leftJoinAndSelect('message.fromUser', 'fromUser');
+
+		const messages = await query.take(limit).getMany();
+
+		return messages;
+	}
+
+	@bindThis
+	public async userHistory(meId: MiUser['id'], limit: number): Promise<MiChatMessage[]> {
+		const history: MiChatMessage[] = [];
+
+		const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
+			.select('muting.muteeId')
+			.where('muting.muterId = :muterId', { muterId: meId });
+
+		for (let i = 0; i < limit; i++) {
+			const found = history.map(m => (m.fromUserId === meId) ? m.toUserId! : m.fromUserId!);
+
+			const query = this.chatMessagesRepository.createQueryBuilder('message')
+				.orderBy('message.id', 'DESC')
+				.where(new Brackets(qb => {
+					qb
+						.where('message.fromUserId = :meId', { meId: meId })
+						.orWhere('message.toUserId = :meId', { meId: meId });
+				}))
+				.andWhere('message.toRoomId IS NULL')
+				.andWhere(`message.fromUserId NOT IN (${ mutingQuery.getQuery() })`)
+				.andWhere(`message.toUserId NOT IN (${ mutingQuery.getQuery() })`);
+
+			if (found.length > 0) {
+				query.andWhere('message.fromUserId NOT IN (:...found)', { found: found });
+				query.andWhere('message.toUserId NOT IN (:...found)', { found: found });
+			}
+
+			query.setParameters(mutingQuery.getParameters());
+
+			const message = await query.getOne();
+
+			if (message) {
+				history.push(message);
+			} else {
+				break;
+			}
+		}
+
+		return history;
+	}
+
+	@bindThis
+	public async roomHistory(meId: MiUser['id'], limit: number): Promise<MiChatMessage[]> {
+		// TODO: 一回のクエリにまとめられるかも
+		const [memberRoomIds, ownedRoomIds] = await Promise.all([
+			this.chatRoomMembershipsRepository.findBy({
+				userId: meId,
+			}).then(xs => xs.map(x => x.roomId)),
+			this.chatRoomsRepository.findBy({
+				ownerId: meId,
+			}).then(xs => xs.map(x => x.id)),
+		]);
+
+		const roomIds = memberRoomIds.concat(ownedRoomIds);
+
+		if (memberRoomIds.length === 0 && ownedRoomIds.length === 0) {
+			return [];
+		}
+
+		const history: MiChatMessage[] = [];
+
+		for (let i = 0; i < limit; i++) {
+			const found = history.map(m => m.toRoomId!);
+
+			const query = this.chatMessagesRepository.createQueryBuilder('message')
+				.orderBy('message.id', 'DESC')
+				.where('message.toRoomId IN (:...roomIds)', { roomIds });
+
+			if (found.length > 0) {
+				query.andWhere('message.toRoomId NOT IN (:...found)', { found: found });
+			}
+
+			const message = await query.getOne();
+
+			if (message) {
+				history.push(message);
+			} else {
+				break;
+			}
+		}
+
+		return history;
+	}
+
+	@bindThis
+	public async getUserReadStateMap(userId: MiUser['id'], otherIds: MiUser['id'][]) {
+		const readStateMap: Record<MiUser['id'], boolean> = {};
+
+		const redisPipeline = this.redisClient.pipeline();
+
+		for (const otherId of otherIds) {
+			redisPipeline.get(`newUserChatMessageExists:${userId}:${otherId}`);
+		}
+
+		const markers = await redisPipeline.exec();
+		if (markers == null) throw new Error('redis error');
+
+		for (let i = 0; i < otherIds.length; i++) {
+			const marker = markers[i][1];
+			readStateMap[otherIds[i]] = marker == null;
+		}
+
+		return readStateMap;
+	}
+
+	@bindThis
+	public async getRoomReadStateMap(userId: MiUser['id'], roomIds: MiChatRoom['id'][]) {
+		const readStateMap: Record<MiChatRoom['id'], boolean> = {};
+
+		const redisPipeline = this.redisClient.pipeline();
+
+		for (const roomId of roomIds) {
+			redisPipeline.get(`newRoomChatMessageExists:${userId}:${roomId}`);
+		}
+
+		const markers = await redisPipeline.exec();
+		if (markers == null) throw new Error('redis error');
+
+		for (let i = 0; i < roomIds.length; i++) {
+			const marker = markers[i][1];
+			readStateMap[roomIds[i]] = marker == null;
+		}
+
+		return readStateMap;
+	}
+
+	@bindThis
+	public async hasUnreadMessages(userId: MiUser['id']) {
+		const card = await this.redisClient.scard(`newChatMessagesExists:${userId}`);
+		return card > 0;
+	}
+
+	@bindThis
+	public async createRoom(owner: MiUser, params: Partial<{
+		name: string;
+		description: string;
+	}>) {
+		const room = {
+			id: this.idService.gen(),
+			name: params.name,
+			description: params.description,
+			ownerId: owner.id,
+		} satisfies Partial<MiChatRoom>;
+
+		const created = await this.chatRoomsRepository.insertOne(room);
+
+		return created;
+	}
+
+	@bindThis
+	public async deleteRoom(room: MiChatRoom) {
+		await this.chatRoomsRepository.delete(room.id);
+	}
+
+	@bindThis
+	public async findMyRoomById(ownerId: MiUser['id'], roomId: MiChatRoom['id']) {
+		return this.chatRoomsRepository.findOneBy({ id: roomId, ownerId: ownerId });
+	}
+
+	@bindThis
+	public async findRoomById(roomId: MiChatRoom['id']) {
+		return this.chatRoomsRepository.findOne({ where: { id: roomId }, relations: ['owner'] });
+	}
+
+	@bindThis
+	public async isRoomMember(room: MiChatRoom, userId: MiUser['id']) {
+		if (room.ownerId === userId) return true;
+		const membership = await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId });
+		return membership != null;
+	}
+
+	@bindThis
+	public async createRoomInvitation(inviterId: MiUser['id'], roomId: MiChatRoom['id'], inviteeId: MiUser['id']) {
+		if (inviterId === inviteeId) {
+			throw new Error('yourself');
+		}
+
+		const room = await this.chatRoomsRepository.findOneByOrFail({ id: roomId, ownerId: inviterId });
+
+		const existingInvitation = await this.chatRoomInvitationsRepository.findOneBy({ roomId, userId: inviteeId });
+		if (existingInvitation) {
+			throw new Error('already invited');
+		}
+
+		const membershipsCount = await this.chatRoomMembershipsRepository.countBy({ roomId });
+		if (membershipsCount >= MAX_ROOM_MEMBERS) {
+			throw new Error('room is full');
+		}
+
+		// TODO: cehck block
+
+		const invitation = {
+			id: this.idService.gen(),
+			roomId: room.id,
+			userId: inviteeId,
+		} satisfies Partial<MiChatRoomInvitation>;
+
+		const created = await this.chatRoomInvitationsRepository.insertOne(invitation);
+
+		return created;
+	}
+
+	@bindThis
+	public async getOwnedRoomsWithPagination(ownerId: MiUser['id'], limit: number, sinceId?: MiChatRoom['id'] | null, untilId?: MiChatRoom['id'] | null) {
+		const query = this.queryService.makePaginationQuery(this.chatRoomsRepository.createQueryBuilder('room'), sinceId, untilId)
+			.where('room.ownerId = :ownerId', { ownerId });
+
+		const rooms = await query.take(limit).getMany();
+
+		return rooms;
+	}
+
+	@bindThis
+	public async getReceivedRoomInvitationsWithPagination(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomInvitation['id'] | null, untilId?: MiChatRoomInvitation['id'] | null) {
+		const query = this.queryService.makePaginationQuery(this.chatRoomInvitationsRepository.createQueryBuilder('invitation'), sinceId, untilId)
+			.where('invitation.userId = :userId', { userId })
+			.andWhere('invitation.ignored = FALSE');
+
+		const invitations = await query.take(limit).getMany();
+
+		return invitations;
+	}
+
+	@bindThis
+	public async joinToRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) {
+		const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId });
+
+		const membershipsCount = await this.chatRoomMembershipsRepository.countBy({ roomId });
+		if (membershipsCount >= MAX_ROOM_MEMBERS) {
+			throw new Error('room is full');
+		}
+
+		const membership = {
+			id: this.idService.gen(),
+			roomId: roomId,
+			userId: userId,
+		} satisfies Partial<MiChatRoomMembership>;
+
+		// TODO: transaction
+		await this.chatRoomMembershipsRepository.insertOne(membership);
+		await this.chatRoomInvitationsRepository.delete(invitation.id);
+	}
+
+	@bindThis
+	public async ignoreRoomInvitation(userId: MiUser['id'], roomId: MiChatRoom['id']) {
+		const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId });
+		await this.chatRoomInvitationsRepository.update(invitation.id, { ignored: true });
+	}
+
+	@bindThis
+	public async leaveRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) {
+		const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId });
+		await this.chatRoomMembershipsRepository.delete(membership.id);
+	}
+
+	@bindThis
+	public async muteRoom(userId: MiUser['id'], roomId: MiChatRoom['id'], mute: boolean) {
+		const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId });
+		await this.chatRoomMembershipsRepository.update(membership.id, { isMuted: mute });
+	}
+
+	@bindThis
+	public async updateRoom(room: MiChatRoom, params: {
+		name?: string;
+		description?: string;
+	}): Promise<MiChatRoom> {
+		return this.chatRoomsRepository.createQueryBuilder().update()
+			.set(params)
+			.where('id = :id', { id: room.id })
+			.returning('*')
+			.execute()
+			.then((response) => {
+				return response.raw[0];
+			});
+	}
+
+	@bindThis
+	public async getRoomMembershipsWithPagination(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) {
+		const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId)
+			.where('membership.roomId = :roomId', { roomId });
+
+		const memberships = await query.take(limit).getMany();
+
+		return memberships;
+	}
+
+	@bindThis
+	public async searchMessages(meId: MiUser['id'], query: string, limit: number, params: {
+		userId?: MiUser['id'] | null;
+		roomId?: MiChatRoom['id'] | null;
+	}) {
+		const q = this.chatMessagesRepository.createQueryBuilder('message');
+
+		if (params.userId) {
+			q.andWhere(new Brackets(qb => {
+				qb
+					.where(new Brackets(qb => {
+						qb
+							.where('message.fromUserId = :meId')
+							.andWhere('message.toUserId = :otherId');
+					}))
+					.orWhere(new Brackets(qb => {
+						qb
+							.where('message.fromUserId = :otherId')
+							.andWhere('message.toUserId = :meId');
+					}));
+			}))
+				.setParameter('meId', meId)
+				.setParameter('otherId', params.userId);
+		} else if (params.roomId) {
+			q.where('message.toRoomId = :roomId', { roomId: params.roomId });
+		} else {
+			const membershipsQuery = this.chatRoomMembershipsRepository.createQueryBuilder('membership')
+				.select('membership.roomId')
+				.where('membership.userId = :meId', { meId: meId });
+
+			const ownedRoomsQuery = this.chatRoomsRepository.createQueryBuilder('room')
+				.select('room.id')
+				.where('room.ownerId = :meId', { meId });
+
+			q.andWhere(new Brackets(qb => {
+				qb
+					.where('message.fromUserId = :meId')
+					.orWhere('message.toUserId = :meId')
+					.orWhere(`message.toRoomId IN (${membershipsQuery.getQuery()})`)
+					.orWhere(`message.toRoomId IN (${ownedRoomsQuery.getQuery()})`);
+			}));
+
+			q.setParameters(membershipsQuery.getParameters());
+			q.setParameters(ownedRoomsQuery.getParameters());
+		}
+
+		q.andWhere('LOWER(message.text) LIKE :q', { q: `%${ sqlLikeEscape(query.toLowerCase()) }%` });
+
+		q.leftJoinAndSelect('message.file', 'file');
+		q.leftJoinAndSelect('message.fromUser', 'fromUser');
+		q.leftJoinAndSelect('message.toUser', 'toUser');
+		q.leftJoinAndSelect('message.toRoom', 'toRoom');
+		q.leftJoinAndSelect('toRoom.owner', 'toRoomOwner');
+
+		const messages = await q.orderBy('message.id', 'DESC').take(limit).getMany();
+
+		return messages;
+	}
+
+	@bindThis
+	public async react(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) {
+		let reaction;
+
+		// TODO: ReactionServiceのやつと共通化
+		function normalize(x: string) {
+			const match = emojiRegex.exec(x);
+			if (match) {
+				// 合字を含む1つの絵文字
+				const unicode = match[0];
+
+				// 異体字セレクタ除去
+				return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
+			} else {
+				throw new Error('invalid emoji');
+			}
+		}
+
+		const custom = reaction_.match(isCustomEmojiRegexp);
+
+		if (custom == null) {
+			reaction = normalize(reaction_);
+		} else {
+			const name = custom[1];
+			const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name);
+
+			if (emoji == null) {
+				throw new Error('no such emoji');
+			} else {
+				reaction = `:${name}:`;
+			}
+		}
+
+		const message = await this.chatMessagesRepository.findOneByOrFail({ id: messageId });
+
+		if (message.fromUserId === userId) {
+			throw new Error('cannot react to own message');
+		}
+
+		if (message.toRoomId === null && message.toUserId !== userId) {
+			throw new Error('cannot react to others message');
+		}
+
+		if (message.reactions.length >= MAX_REACTIONS_PER_MESSAGE) {
+			throw new Error('too many reactions');
+		}
+
+		const room = message.toRoomId ? await this.chatRoomsRepository.findOneByOrFail({ id: message.toRoomId }) : null;
+
+		if (room) {
+			if (!await this.isRoomMember(room, userId)) {
+				throw new Error('cannot react to others message');
+			}
+		}
+
+		await this.chatMessagesRepository.createQueryBuilder().update()
+			.set({
+				reactions: () => `array_append("reactions", '${userId}/${reaction}')`,
+			})
+			.where('id = :id', { id: message.id })
+			.execute();
+
+		if (room) {
+			this.globalEventService.publishChatRoomStream(room.id, 'react', {
+				messageId: message.id,
+				user: await this.userEntityService.pack(userId),
+				reaction,
+			});
+		} else {
+			this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId!, 'react', {
+				messageId: message.id,
+				reaction,
+			});
+			this.globalEventService.publishChatUserStream(message.toUserId!, message.fromUserId, 'react', {
+				messageId: message.id,
+				reaction,
+			});
+		}
+	}
+
+	@bindThis
+	public async getMyMemberships(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) {
+		const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId)
+			.where('membership.userId = :userId', { userId });
+
+		const memberships = await query.take(limit).getMany();
+
+		return memberships;
+	}
+}
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index dc85a23e5b..d8617e343c 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -44,7 +44,6 @@ import { ModerationLogService } from './ModerationLogService.js';
 import { NoteCreateService } from './NoteCreateService.js';
 import { NoteDeleteService } from './NoteDeleteService.js';
 import { NotePiningService } from './NotePiningService.js';
-import { NoteReadService } from './NoteReadService.js';
 import { NotificationService } from './NotificationService.js';
 import { PollService } from './PollService.js';
 import { PushNotificationService } from './PushNotificationService.js';
@@ -75,6 +74,7 @@ import { ClipService } from './ClipService.js';
 import { FeaturedService } from './FeaturedService.js';
 import { FanoutTimelineService } from './FanoutTimelineService.js';
 import { ChannelFollowingService } from './ChannelFollowingService.js';
+import { ChatService } from './ChatService.js';
 import { RegistryApiService } from './RegistryApiService.js';
 import { ReversiService } from './ReversiService.js';
 
@@ -100,6 +100,7 @@ import { AppEntityService } from './entities/AppEntityService.js';
 import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js';
 import { BlockingEntityService } from './entities/BlockingEntityService.js';
 import { ChannelEntityService } from './entities/ChannelEntityService.js';
+import { ChatEntityService } from './entities/ChatEntityService.js';
 import { ClipEntityService } from './entities/ClipEntityService.js';
 import { DriveFileEntityService } from './entities/DriveFileEntityService.js';
 import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js';
@@ -184,7 +185,6 @@ const $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx
 const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService };
 const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
 const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
-const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
 const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
 const $PollService: Provider = { provide: 'PollService', useExisting: PollService };
 const $SystemAccountService: Provider = { provide: 'SystemAccountService', useExisting: SystemAccountService };
@@ -221,6 +221,7 @@ const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: Fe
 const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService };
 const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
 const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
+const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService };
 const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
 const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
 
@@ -247,6 +248,7 @@ const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting:
 const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService };
 const $BlockingEntityService: Provider = { provide: 'BlockingEntityService', useExisting: BlockingEntityService };
 const $ChannelEntityService: Provider = { provide: 'ChannelEntityService', useExisting: ChannelEntityService };
+const $ChatEntityService: Provider = { provide: 'ChatEntityService', useExisting: ChatEntityService };
 const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting: ClipEntityService };
 const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService };
 const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService };
@@ -333,7 +335,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		NoteCreateService,
 		NoteDeleteService,
 		NotePiningService,
-		NoteReadService,
 		NotificationService,
 		PollService,
 		SystemAccountService,
@@ -370,6 +371,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		FanoutTimelineService,
 		FanoutTimelineEndpointService,
 		ChannelFollowingService,
+		ChatService,
 		RegistryApiService,
 		ReversiService,
 
@@ -396,6 +398,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		AuthSessionEntityService,
 		BlockingEntityService,
 		ChannelEntityService,
+		ChatEntityService,
 		ClipEntityService,
 		DriveFileEntityService,
 		DriveFolderEntityService,
@@ -478,7 +481,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$NoteCreateService,
 		$NoteDeleteService,
 		$NotePiningService,
-		$NoteReadService,
 		$NotificationService,
 		$PollService,
 		$SystemAccountService,
@@ -515,6 +517,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$FanoutTimelineService,
 		$FanoutTimelineEndpointService,
 		$ChannelFollowingService,
+		$ChatService,
 		$RegistryApiService,
 		$ReversiService,
 
@@ -541,6 +544,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$AuthSessionEntityService,
 		$BlockingEntityService,
 		$ChannelEntityService,
+		$ChatEntityService,
 		$ClipEntityService,
 		$DriveFileEntityService,
 		$DriveFolderEntityService,
@@ -624,7 +628,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		NoteCreateService,
 		NoteDeleteService,
 		NotePiningService,
-		NoteReadService,
 		NotificationService,
 		PollService,
 		SystemAccountService,
@@ -661,6 +664,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		FanoutTimelineService,
 		FanoutTimelineEndpointService,
 		ChannelFollowingService,
+		ChatService,
 		RegistryApiService,
 		ReversiService,
 
@@ -686,6 +690,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		AuthSessionEntityService,
 		BlockingEntityService,
 		ChannelEntityService,
+		ChatEntityService,
 		ClipEntityService,
 		DriveFileEntityService,
 		DriveFolderEntityService,
@@ -768,7 +773,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$NoteCreateService,
 		$NoteDeleteService,
 		$NotePiningService,
-		$NoteReadService,
 		$NotificationService,
 		$PollService,
 		$SystemAccountService,
@@ -804,6 +808,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$FanoutTimelineService,
 		$FanoutTimelineEndpointService,
 		$ChannelFollowingService,
+		$ChatService,
 		$RegistryApiService,
 		$ReversiService,
 
@@ -829,6 +834,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$AuthSessionEntityService,
 		$BlockingEntityService,
 		$ChannelEntityService,
+		$ChatEntityService,
 		$ClipEntityService,
 		$DriveFileEntityService,
 		$DriveFolderEntityService,
diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index 224fdabc4c..4da3b8bc78 100644
--- a/packages/backend/src/core/GlobalEventService.ts
+++ b/packages/backend/src/core/GlobalEventService.ts
@@ -20,7 +20,7 @@ import type { MiPage } from '@/models/Page.js';
 import type { MiWebhook } from '@/models/Webhook.js';
 import type { MiSystemWebhook } from '@/models/SystemWebhook.js';
 import type { MiMeta } from '@/models/Meta.js';
-import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
+import { MiAvatarDecoration, MiChatMessage, MiChatRoom, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
 import type { Packed } from '@/misc/json-schema.js';
 import { DI } from '@/di-symbols.js';
 import type { Config } from '@/config.js';
@@ -72,12 +72,8 @@ export interface MainEventTypes {
 	readAllNotifications: undefined;
 	notificationFlushed: undefined;
 	unreadNotification: Packed<'Notification'>;
-	unreadMention: MiNote['id'];
-	readAllUnreadMentions: undefined;
-	unreadSpecifiedNote: MiNote['id'];
-	readAllUnreadSpecifiedNotes: undefined;
-	readAllAntennas: undefined;
 	unreadAntenna: MiAntenna;
+	newChatMessage: Packed<'ChatMessage'>;
 	readAllAnnouncements: undefined;
 	myTokenRegenerated: undefined;
 	signin: {
@@ -163,6 +159,16 @@ export interface AdminEventTypes {
 	};
 }
 
+export interface ChatEventTypes {
+	message: Packed<'ChatMessageLite'>;
+	deleted: Packed<'ChatMessageLite'>['id'];
+	react: {
+		reaction: string;
+		user?: Packed<'UserLite'>;
+		messageId: MiChatMessage['id'];
+	};
+}
+
 export interface ReversiEventTypes {
 	matched: {
 		game: Packed<'ReversiGameDetailed'>;
@@ -202,7 +208,7 @@ export interface ReversiGameEventTypes {
 type Events<T extends object> = { [K in keyof T]: { type: K; body: T[K]; } };
 type EventUnionFromDictionary<
 	T extends object,
-	U = Events<T>
+	U = Events<T>,
 > = U[keyof U];
 
 type SerializedAll<T> = {
@@ -295,6 +301,14 @@ export type GlobalEvents = {
 		name: 'notesStream';
 		payload: Serialized<Packed<'Note'>>;
 	};
+	chat: {
+		name: `chatUserStream:${MiUser['id']}-${MiUser['id']}`;
+		payload: EventTypesToEventPayload<ChatEventTypes>;
+	};
+	chatRoom: {
+		name: `chatRoomStream:${MiChatRoom['id']}`;
+		payload: EventTypesToEventPayload<ChatEventTypes>;
+	};
 	reversi: {
 		name: `reversiStream:${MiUser['id']}`;
 		payload: EventTypesToEventPayload<ReversiEventTypes>;
@@ -393,6 +407,16 @@ export class GlobalEventService {
 		this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value);
 	}
 
+	@bindThis
+	public publishChatUserStream<K extends keyof ChatEventTypes>(fromUserId: MiUser['id'], toUserId: MiUser['id'], type: K, value?: ChatEventTypes[K]): void {
+		this.publish(`chatUserStream:${fromUserId}-${toUserId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
+	@bindThis
+	public publishChatRoomStream<K extends keyof ChatEventTypes>(toRoomId: MiChatRoom['id'], type: K, value?: ChatEventTypes[K]): void {
+		this.publish(`chatRoomStream:${toRoomId}`, type, typeof value === 'undefined' ? null : value);
+	}
+
 	@bindThis
 	public publishReversiStream<K extends keyof ReversiEventTypes>(userId: MiUser['id'], type: K, value?: ReversiEventTypes[K]): void {
 		this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value);
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 8a79908e82..8f416f398c 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -42,7 +42,6 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
 import { UserEntityService } from '@/core/entities/UserEntityService.js';
 import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
 import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
 import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
 import { bindThis } from '@/decorators.js';
 import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
@@ -199,7 +198,6 @@ export class NoteCreateService implements OnApplicationShutdown {
 		private globalEventService: GlobalEventService,
 		private queueService: QueueService,
 		private fanoutTimelineService: FanoutTimelineService,
-		private noteReadService: NoteReadService,
 		private notificationService: NotificationService,
 		private relayService: RelayService,
 		private federatedInstanceService: FederatedInstanceService,
@@ -582,31 +580,6 @@ export class NoteCreateService implements OnApplicationShutdown {
 		if (!silent) {
 			if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user);
 
-			// 未読通知を作成
-			if (data.visibility === 'specified') {
-				if (data.visibleUsers == null) throw new Error('invalid param');
-
-				for (const u of data.visibleUsers) {
-					// ローカルユーザーのみ
-					if (!this.userEntityService.isLocalUser(u)) continue;
-
-					this.noteReadService.insertNoteUnread(u.id, note, {
-						isSpecified: true,
-						isMentioned: false,
-					});
-				}
-			} else {
-				for (const u of mentionedUsers) {
-					// ローカルユーザーのみ
-					if (!this.userEntityService.isLocalUser(u)) continue;
-
-					this.noteReadService.insertNoteUnread(u.id, note, {
-						isSpecified: false,
-						isMentioned: true,
-					});
-				}
-			}
-
 			// Pack the note
 			const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true });
 
diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts
deleted file mode 100644
index 181c9f7649..0000000000
--- a/packages/backend/src/core/NoteReadService.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { setTimeout } from 'node:timers/promises';
-import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
-import { In } from 'typeorm';
-import { DI } from '@/di-symbols.js';
-import type { MiUser } from '@/models/User.js';
-import type { Packed } from '@/misc/json-schema.js';
-import type { MiNote } from '@/models/Note.js';
-import { IdService } from '@/core/IdService.js';
-import { GlobalEventService } from '@/core/GlobalEventService.js';
-import type { NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository } from '@/models/_.js';
-import { bindThis } from '@/decorators.js';
-import { trackPromise } from '@/misc/promise-tracker.js';
-
-@Injectable()
-export class NoteReadService implements OnApplicationShutdown {
-	#shutdownController = new AbortController();
-
-	constructor(
-		@Inject(DI.noteUnreadsRepository)
-		private noteUnreadsRepository: NoteUnreadsRepository,
-
-		@Inject(DI.mutingsRepository)
-		private mutingsRepository: MutingsRepository,
-
-		@Inject(DI.noteThreadMutingsRepository)
-		private noteThreadMutingsRepository: NoteThreadMutingsRepository,
-
-		private idService: IdService,
-		private globalEventService: GlobalEventService,
-	) {
-	}
-
-	@bindThis
-	public async insertNoteUnread(userId: MiUser['id'], note: MiNote, params: {
-		// NOTE: isSpecifiedがtrueならisMentionedは必ずfalse
-		isSpecified: boolean;
-		isMentioned: boolean;
-	}): Promise<void> {
-		//#region ミュートしているなら無視
-		const mute = await this.mutingsRepository.findBy({
-			muterId: userId,
-		});
-		if (mute.map(m => m.muteeId).includes(note.userId)) return;
-		//#endregion
-
-		// スレッドミュート
-		const isThreadMuted = await this.noteThreadMutingsRepository.exists({
-			where: {
-				userId: userId,
-				threadId: note.threadId ?? note.id,
-			},
-		});
-		if (isThreadMuted) return;
-
-		const unread = {
-			id: this.idService.gen(),
-			noteId: note.id,
-			userId: userId,
-			isSpecified: params.isSpecified,
-			isMentioned: params.isMentioned,
-			noteUserId: note.userId,
-		};
-
-		await this.noteUnreadsRepository.insert(unread);
-
-		// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
-		setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
-			const exist = await this.noteUnreadsRepository.exists({ where: { id: unread.id } });
-
-			if (!exist) return;
-
-			if (params.isMentioned) {
-				this.globalEventService.publishMainStream(userId, 'unreadMention', note.id);
-			}
-			if (params.isSpecified) {
-				this.globalEventService.publishMainStream(userId, 'unreadSpecifiedNote', note.id);
-			}
-		}, () => { /* aborted, ignore it */ });
-	}
-
-	@bindThis
-	public async read(
-		userId: MiUser['id'],
-		notes: (MiNote | Packed<'Note'>)[],
-	): Promise<void> {
-		if (notes.length === 0) return;
-
-		const noteIds = new Set<MiNote['id']>();
-
-		for (const note of notes) {
-			if (note.mentions && note.mentions.includes(userId)) {
-				noteIds.add(note.id);
-			} else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) {
-				noteIds.add(note.id);
-			}
-		}
-
-		if (noteIds.size === 0) return;
-
-		// Remove the record
-		await this.noteUnreadsRepository.delete({
-			userId: userId,
-			noteId: In(Array.from(noteIds)),
-		});
-
-		// TODO: ↓まとめてクエリしたい
-
-		trackPromise(this.noteUnreadsRepository.countBy({
-			userId: userId,
-			isMentioned: true,
-		}).then(mentionsCount => {
-			if (mentionsCount === 0) {
-				// 全て既読になったイベントを発行
-				this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions');
-			}
-		}));
-
-		trackPromise(this.noteUnreadsRepository.countBy({
-			userId: userId,
-			isSpecified: true,
-		}).then(specifiedCount => {
-			if (specifiedCount === 0) {
-				// 全て既読になったイベントを発行
-				this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
-			}
-		}));
-	}
-
-	@bindThis
-	public dispose(): void {
-		this.#shutdownController.abort();
-	}
-
-	@bindThis
-	public onApplicationShutdown(signal?: string | undefined): void {
-		this.dispose();
-	}
-}
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 01f3e0c116..86f8a5caa1 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -63,6 +63,7 @@ export type RolePolicies = {
 	canImportFollowing: boolean;
 	canImportMuting: boolean;
 	canImportUserLists: boolean;
+	canChat: boolean;
 };
 
 export const DEFAULT_POLICIES: RolePolicies = {
@@ -97,6 +98,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
 	canImportFollowing: true,
 	canImportMuting: true,
 	canImportUserLists: true,
+	canChat: true,
 };
 
 @Injectable()
@@ -400,6 +402,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
 			canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)),
 			canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)),
 			canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)),
+			canChat: calc('canChat', vs => vs.some(v => v === true)),
 		};
 	}
 
diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts
index b98ca97ec9..e7a6be99fb 100644
--- a/packages/backend/src/core/UserFollowingService.ts
+++ b/packages/backend/src/core/UserFollowingService.ts
@@ -5,7 +5,7 @@
 
 import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
 import { ModuleRef } from '@nestjs/core';
-import { IsNull } from 'typeorm';
+import { Brackets, IsNull } from 'typeorm';
 import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
 import { IdentifiableError } from '@/misc/identifiable-error.js';
 import { QueueService } from '@/core/QueueService.js';
@@ -736,4 +736,30 @@ export class UserFollowingService implements OnModuleInit {
 			.where('following.followerId = :followerId', { followerId: userId })
 			.getMany();
 	}
+
+	@bindThis
+	public isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) {
+		return this.followingsRepository.exists({
+			where: {
+				followerId,
+				followeeId,
+			},
+		});
+	}
+
+	@bindThis
+	public async isMutual(aUserId: MiUser['id'], bUserId: MiUser['id']) {
+		const count = await this.followingsRepository.createQueryBuilder('following')
+			.where(new Brackets(qb => {
+				qb.where('following.followerId = :aUserId', { aUserId })
+					.andWhere('following.followeeId = :bUserId', { bUserId });
+			}))
+			.orWhere(new Brackets(qb => {
+				qb.where('following.followerId = :bUserId', { bUserId })
+					.andWhere('following.followeeId = :aUserId', { aUserId });
+			}))
+			.getCount();
+
+		return count === 2;
+	}
 }
diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts
index e1a7801bb1..a6198f7686 100644
--- a/packages/backend/src/core/WebhookTestService.ts
+++ b/packages/backend/src/core/WebhookTestService.ts
@@ -53,6 +53,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
 		requireSigninToViewContents: false,
 		makeNotesFollowersOnlyBefore: null,
 		makeNotesHiddenBefore: null,
+		chatScope: 'mutual',
 		emojis: [],
 		score: 0,
 		host: null,
@@ -461,6 +462,7 @@ export class WebhookTestService {
 			publicReactions: true,
 			followersVisibility: 'public',
 			followingVisibility: 'public',
+			chatScope: 'mutual',
 			twoFactorEnabled: false,
 			usePasswordLessLogin: false,
 			securityKeys: false,
diff --git a/packages/backend/src/core/entities/ChatEntityService.ts b/packages/backend/src/core/entities/ChatEntityService.ts
new file mode 100644
index 0000000000..099a9e3ad2
--- /dev/null
+++ b/packages/backend/src/core/entities/ChatEntityService.ts
@@ -0,0 +1,376 @@
+/*
+ * 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 { MiUser, ChatMessagesRepository, MiChatMessage, ChatRoomsRepository, MiChatRoom, MiChatRoomInvitation, ChatRoomInvitationsRepository, MiChatRoomMembership, ChatRoomMembershipsRepository } from '@/models/_.js';
+import { awaitAll } from '@/misc/prelude/await-all.js';
+import type { Packed } from '@/misc/json-schema.js';
+import type { } from '@/models/Blocking.js';
+import { bindThis } from '@/decorators.js';
+import { IdService } from '@/core/IdService.js';
+import { UserEntityService } from './UserEntityService.js';
+import { DriveFileEntityService } from './DriveFileEntityService.js';
+import { In } from 'typeorm';
+
+@Injectable()
+export class ChatEntityService {
+	constructor(
+		@Inject(DI.chatMessagesRepository)
+		private chatMessagesRepository: ChatMessagesRepository,
+
+		@Inject(DI.chatRoomsRepository)
+		private chatRoomsRepository: ChatRoomsRepository,
+
+		@Inject(DI.chatRoomInvitationsRepository)
+		private chatRoomInvitationsRepository: ChatRoomInvitationsRepository,
+
+		@Inject(DI.chatRoomMembershipsRepository)
+		private chatRoomMembershipsRepository: ChatRoomMembershipsRepository,
+
+		private userEntityService: UserEntityService,
+		private driveFileEntityService: DriveFileEntityService,
+		private idService: IdService,
+	) {
+	}
+
+	@bindThis
+	public async packMessageDetailed(
+		src: MiChatMessage['id'] | MiChatMessage,
+		me?: { id: MiUser['id'] },
+		options?: {
+			_hint_?: {
+				packedFiles?: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
+				packedUsers?: Map<MiChatMessage['id'], Packed<'UserLite'>>;
+				packedRooms?: Map<MiChatMessage['toRoomId'], Packed<'ChatRoom'> | null>;
+			};
+		},
+	): Promise<Packed<'ChatMessage'>> {
+		const packedUsers = options?._hint_?.packedUsers;
+		const packedFiles = options?._hint_?.packedFiles;
+		const packedRooms = options?._hint_?.packedRooms;
+
+		const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
+
+		const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = [];
+
+		for (const record of message.reactions) {
+			const [userId, reaction] = record.split('/');
+			reactions.push({
+				user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId),
+				reaction,
+			});
+		}
+
+		return {
+			id: message.id,
+			createdAt: this.idService.parse(message.id).date.toISOString(),
+			text: message.text,
+			fromUserId: message.fromUserId,
+			fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId, me),
+			toUserId: message.toUserId,
+			toUser: message.toUserId ? (packedUsers?.get(message.toUserId) ?? await this.userEntityService.pack(message.toUser ?? message.toUserId, me)) : undefined,
+			toRoomId: message.toRoomId,
+			toRoom: message.toRoomId ? (packedRooms?.get(message.toRoomId) ?? await this.packRoom(message.toRoom ?? message.toRoomId, me)) : undefined,
+			fileId: message.fileId,
+			file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
+			reactions,
+		};
+	}
+
+	@bindThis
+	public async packMessagesDetailed(
+		messages: MiChatMessage[],
+		me: { id: MiUser['id'] },
+	) {
+		if (messages.length === 0) return [];
+
+		const excludeMe = (x: MiUser | string) => {
+			if (typeof x === 'string') {
+				return x !== me.id;
+			} else {
+				return x.id !== me.id;
+			}
+		};
+
+		const users = [
+			...messages.map((m) => m.fromUser ?? m.fromUserId).filter(excludeMe),
+			...messages.map((m) => m.toUser ?? m.toUserId).filter(x => x != null).filter(excludeMe),
+		];
+
+		const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0]));
+
+		for (const reactedUserId of reactedUserIds) {
+			if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) {
+				users.push(reactedUserId);
+			}
+		}
+
+		const [packedUsers, packedFiles, packedRooms] = await Promise.all([
+			this.userEntityService.packMany(users, me)
+				.then(users => new Map(users.map(u => [u.id, u]))),
+			this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null))
+				.then(files => new Map(files.map(f => [f.id, f]))),
+			this.packRooms(messages.map(m => m.toRoom ?? m.toRoomId).filter(x => x != null), me)
+				.then(rooms => new Map(rooms.map(r => [r.id, r]))),
+		]);
+
+		return Promise.all(messages.map(message => this.packMessageDetailed(message, me, { _hint_: { packedUsers, packedFiles, packedRooms } })));
+	}
+
+	@bindThis
+	public async packMessageLiteFor1on1(
+		src: MiChatMessage['id'] | MiChatMessage,
+		options?: {
+			_hint_?: {
+				packedFiles: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
+			};
+		},
+	): Promise<Packed<'ChatMessageLite'>> {
+		const packedFiles = options?._hint_?.packedFiles;
+
+		const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
+
+		const reactions: { reaction: string; }[] = [];
+
+		for (const record of message.reactions) {
+			const [userId, reaction] = record.split('/');
+			reactions.push({
+				reaction,
+			});
+		}
+
+		return {
+			id: message.id,
+			createdAt: this.idService.parse(message.id).date.toISOString(),
+			text: message.text,
+			fromUserId: message.fromUserId,
+			toUserId: message.toUserId,
+			fileId: message.fileId,
+			file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
+			reactions,
+		};
+	}
+
+	@bindThis
+	public async packMessagesLiteFor1on1(
+		messages: MiChatMessage[],
+	) {
+		if (messages.length === 0) return [];
+
+		const [packedFiles] = await Promise.all([
+			this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null))
+				.then(files => new Map(files.map(f => [f.id, f]))),
+		]);
+
+		return Promise.all(messages.map(message => this.packMessageLiteFor1on1(message, { _hint_: { packedFiles } })));
+	}
+
+	@bindThis
+	public async packMessageLiteForRoom(
+		src: MiChatMessage['id'] | MiChatMessage,
+		options?: {
+			_hint_?: {
+				packedFiles: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
+				packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
+			};
+		},
+	): Promise<Packed<'ChatMessageLite'>> {
+		const packedFiles = options?._hint_?.packedFiles;
+		const packedUsers = options?._hint_?.packedUsers;
+
+		const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
+
+		const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = [];
+
+		for (const record of message.reactions) {
+			const [userId, reaction] = record.split('/');
+			reactions.push({
+				user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId),
+				reaction,
+			});
+		}
+
+		return {
+			id: message.id,
+			createdAt: this.idService.parse(message.id).date.toISOString(),
+			text: message.text,
+			fromUserId: message.fromUserId,
+			fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId),
+			toRoomId: message.toRoomId,
+			fileId: message.fileId,
+			file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
+			reactions,
+		};
+	}
+
+	@bindThis
+	public async packMessagesLiteForRoom(
+		messages: MiChatMessage[],
+	) {
+		if (messages.length === 0) return [];
+
+		const users = messages.map(x => x.fromUser ?? x.fromUserId);
+		const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0]));
+
+		for (const reactedUserId of reactedUserIds) {
+			if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) {
+				users.push(reactedUserId);
+			}
+		}
+
+		const [packedUsers, packedFiles] = await Promise.all([
+			this.userEntityService.packMany(users)
+				.then(users => new Map(users.map(u => [u.id, u]))),
+			this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null))
+				.then(files => new Map(files.map(f => [f.id, f]))),
+		]);
+
+		return Promise.all(messages.map(message => this.packMessageLiteForRoom(message, { _hint_: { packedFiles, packedUsers } })));
+	}
+
+	@bindThis
+	public async packRoom(
+		src: MiChatRoom['id'] | MiChatRoom,
+		me?: { id: MiUser['id'] },
+		options?: {
+			_hint_?: {
+				packedOwners: Map<MiChatRoom['id'], Packed<'UserLite'>>;
+				memberships?: Map<MiChatRoom['id'], MiChatRoomMembership | null | undefined>;
+			};
+		},
+	): Promise<Packed<'ChatRoom'>> {
+		const room = typeof src === 'object' ? src : await this.chatRoomsRepository.findOneByOrFail({ id: src });
+
+		const membership = me && me.id !== room.ownerId ? (options?._hint_?.memberships?.get(room.id) ?? await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId: me.id })) : null;
+
+		return {
+			id: room.id,
+			createdAt: this.idService.parse(room.id).date.toISOString(),
+			name: room.name,
+			description: room.description,
+			ownerId: room.ownerId,
+			owner: options?._hint_?.packedOwners.get(room.ownerId) ?? await this.userEntityService.pack(room.owner ?? room.ownerId, me),
+			isMuted: membership != null ? membership.isMuted : false,
+		};
+	}
+
+	@bindThis
+	public async packRooms(
+		rooms: (MiChatRoom | MiChatRoom['id'])[],
+		me: { id: MiUser['id'] },
+	) {
+		if (rooms.length === 0) return [];
+
+		const _rooms = rooms.filter((room): room is MiChatRoom => typeof room !== 'string');
+		if (_rooms.length !== rooms.length) {
+			_rooms.push(
+				...await this.chatRoomsRepository.find({
+					where: {
+						id: In(rooms.filter((room): room is string => typeof room === 'string')),
+					},
+					relations: ['owner'],
+				}),
+			);
+		}
+
+		const owners = _rooms.map(x => x.owner ?? x.ownerId);
+
+		const [packedOwners, memberships] = await Promise.all([
+			this.userEntityService.packMany(owners, me)
+				.then(users => new Map(users.map(u => [u.id, u]))),
+			this.chatRoomMembershipsRepository.find({
+				where: {
+					roomId: In(_rooms.map(x => x.id)),
+					userId: me.id,
+				},
+			}).then(memberships => new Map(_rooms.map(r => [r.id, memberships.find(m => m.roomId === r.id)]))),
+		]);
+
+		return Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, memberships } })));
+	}
+
+	@bindThis
+	public async packRoomInvitation(
+		src: MiChatRoomInvitation['id'] | MiChatRoomInvitation,
+		me: { id: MiUser['id'] },
+		options?: {
+			_hint_?: {
+				packedRooms: Map<MiChatRoomInvitation['roomId'], Packed<'ChatRoom'>>;
+				packedUsers: Map<MiChatRoomInvitation['id'], Packed<'UserLite'>>;
+			};
+		},
+	): Promise<Packed<'ChatRoomInvitation'>> {
+		const invitation = typeof src === 'object' ? src : await this.chatRoomInvitationsRepository.findOneByOrFail({ id: src });
+
+		return {
+			id: invitation.id,
+			createdAt: this.idService.parse(invitation.id).date.toISOString(),
+			roomId: invitation.roomId,
+			room: options?._hint_?.packedRooms.get(invitation.roomId) ?? await this.packRoom(invitation.room ?? invitation.roomId, me),
+			userId: invitation.userId,
+			user: options?._hint_?.packedUsers.get(invitation.userId) ?? await this.userEntityService.pack(invitation.user ?? invitation.userId, me),
+		};
+	}
+
+	@bindThis
+	public async packRoomInvitations(
+		invitations: MiChatRoomInvitation[],
+		me: { id: MiUser['id'] },
+	) {
+		if (invitations.length === 0) return [];
+
+		return Promise.all(invitations.map(invitation => this.packRoomInvitation(invitation, me)));
+	}
+
+	@bindThis
+	public async packRoomMembership(
+		src: MiChatRoomMembership['id'] | MiChatRoomMembership,
+		me: { id: MiUser['id'] },
+		options?: {
+			populateUser?: boolean;
+			populateRoom?: boolean;
+			_hint_?: {
+				packedRooms: Map<MiChatRoomMembership['roomId'], Packed<'ChatRoom'>>;
+				packedUsers: Map<MiChatRoomMembership['id'], Packed<'UserLite'>>;
+			};
+		},
+	): Promise<Packed<'ChatRoomMembership'>> {
+		const membership = typeof src === 'object' ? src : await this.chatRoomMembershipsRepository.findOneByOrFail({ id: src });
+
+		return {
+			id: membership.id,
+			createdAt: this.idService.parse(membership.id).date.toISOString(),
+			userId: membership.userId,
+			user: options?.populateUser ? (options._hint_?.packedUsers.get(membership.userId) ?? await this.userEntityService.pack(membership.user ?? membership.userId, me)) : undefined,
+			roomId: membership.roomId,
+			room: options?.populateRoom ? (options._hint_?.packedRooms.get(membership.roomId) ?? await this.packRoom(membership.room ?? membership.roomId, me)) : undefined,
+		};
+	}
+
+	@bindThis
+	public async packRoomMemberships(
+		memberships: MiChatRoomMembership[],
+		me: { id: MiUser['id'] },
+		options: {
+			populateUser?: boolean;
+			populateRoom?: boolean;
+		} = {},
+	) {
+		if (memberships.length === 0) return [];
+
+		const users = memberships.map(x => x.user ?? x.userId);
+		const rooms = memberships.map(x => x.room ?? x.roomId);
+
+		const [packedUsers, packedRooms] = await Promise.all([
+			this.userEntityService.packMany(users, me)
+				.then(users => new Map(users.map(u => [u.id, u]))),
+			this.packRooms(rooms, me)
+				.then(rooms => new Map(rooms.map(r => [r.id, r]))),
+		]);
+
+		return Promise.all(memberships.map(membership => this.packRoomMembership(membership, me, { ...options, _hint_: { packedUsers, packedRooms } })));
+	}
+}
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index 69f698d9cb..92a684dc1a 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -32,7 +32,6 @@ import type {
 	MiUserNotePining,
 	MiUserProfile,
 	MutingsRepository,
-	NoteUnreadsRepository,
 	RenoteMutingsRepository,
 	UserMemoRepository,
 	UserNotePiningsRepository,
@@ -48,9 +47,9 @@ import { IdService } from '@/core/IdService.js';
 import type { AnnouncementService } from '@/core/AnnouncementService.js';
 import type { CustomEmojiService } from '@/core/CustomEmojiService.js';
 import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
+import { ChatService } from '@/core/ChatService.js';
 import type { OnModuleInit } from '@nestjs/common';
 import type { NoteEntityService } from './NoteEntityService.js';
-import type { DriveFileEntityService } from './DriveFileEntityService.js';
 import type { PageEntityService } from './PageEntityService.js';
 
 const Ajv = _Ajv.default;
@@ -94,6 +93,7 @@ export class UserEntityService implements OnModuleInit {
 	private federatedInstanceService: FederatedInstanceService;
 	private idService: IdService;
 	private avatarDecorationService: AvatarDecorationService;
+	private chatService: ChatService;
 
 	constructor(
 		private moduleRef: ModuleRef,
@@ -128,9 +128,6 @@ export class UserEntityService implements OnModuleInit {
 		@Inject(DI.renoteMutingsRepository)
 		private renoteMutingsRepository: RenoteMutingsRepository,
 
-		@Inject(DI.noteUnreadsRepository)
-		private noteUnreadsRepository: NoteUnreadsRepository,
-
 		@Inject(DI.userNotePiningsRepository)
 		private userNotePiningsRepository: UserNotePiningsRepository,
 
@@ -152,6 +149,7 @@ export class UserEntityService implements OnModuleInit {
 		this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
 		this.idService = this.moduleRef.get('IdService');
 		this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService');
+		this.chatService = this.moduleRef.get('ChatService');
 	}
 
 	//#region Validators
@@ -558,6 +556,7 @@ export class UserEntityService implements OnModuleInit {
 				publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964
 				followersVisibility: profile!.followersVisibility,
 				followingVisibility: profile!.followingVisibility,
+				chatScope: user.chatScope,
 				roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
 					id: role.id,
 					name: role.name,
@@ -598,14 +597,9 @@ export class UserEntityService implements OnModuleInit {
 				isDeleted: user.isDeleted,
 				twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
 				hideOnlineStatus: user.hideOnlineStatus,
-				hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({
-					where: { userId: user.id, isSpecified: true },
-					take: 1,
-				}).then(count => count > 0),
-				hasUnreadMentions: this.noteUnreadsRepository.count({
-					where: { userId: user.id, isMentioned: true },
-					take: 1,
-				}).then(count => count > 0),
+				hasUnreadSpecifiedNotes: false, // 後方互換性のため
+				hasUnreadMentions: false, // 後方互換性のため
+				hasUnreadChatMessages: this.chatService.hasUnreadMessages(user.id),
 				hasUnreadAnnouncement: unreadAnnouncements!.length > 0,
 				unreadAnnouncements,
 				hasUnreadAntenna: this.getHasUnreadAntenna(user.id),
diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts
index a306aac1a1..77d2838e09 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -24,7 +24,6 @@ export const DI = {
 	noteFavoritesRepository: Symbol('noteFavoritesRepository'),
 	noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'),
 	noteReactionsRepository: Symbol('noteReactionsRepository'),
-	noteUnreadsRepository: Symbol('noteUnreadsRepository'),
 	pollsRepository: Symbol('pollsRepository'),
 	pollVotesRepository: Symbol('pollVotesRepository'),
 	userProfilesRepository: Symbol('userProfilesRepository'),
@@ -83,6 +82,11 @@ export const DI = {
 	flashsRepository: Symbol('flashsRepository'),
 	flashLikesRepository: Symbol('flashLikesRepository'),
 	userMemosRepository: Symbol('userMemosRepository'),
+	chatMessagesRepository: Symbol('chatMessagesRepository'),
+	chatApprovalsRepository: Symbol('chatApprovalsRepository'),
+	chatRoomsRepository: Symbol('chatRoomsRepository'),
+	chatRoomMembershipsRepository: Symbol('chatRoomMembershipsRepository'),
+	chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'),
 	bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
 	reversiGamesRepository: Symbol('reversiGamesRepository'),
 	//#endregion
diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts
index 100fb69585..bc9308ca9b 100644
--- a/packages/backend/src/misc/json-schema.ts
+++ b/packages/backend/src/misc/json-schema.ts
@@ -63,6 +63,10 @@ import {
 } from '@/models/json-schema/meta.js';
 import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js';
 import { packedAbuseReportNotificationRecipientSchema } from '@/models/json-schema/abuse-report-notification-recipient.js';
+import { packedChatMessageSchema, packedChatMessageLiteSchema } from '@/models/json-schema/chat-message.js';
+import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js';
+import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js';
+import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js';
 
 export const refs = {
 	UserLite: packedUserLiteSchema,
@@ -120,6 +124,11 @@ export const refs = {
 	MetaDetailed: packedMetaDetailedSchema,
 	SystemWebhook: packedSystemWebhookSchema,
 	AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema,
+	ChatMessage: packedChatMessageSchema,
+	ChatMessageLite: packedChatMessageLiteSchema,
+	ChatRoom: packedChatRoomSchema,
+	ChatRoomInvitation: packedChatRoomInvitationSchema,
+	ChatRoomMembership: packedChatRoomMembershipSchema,
 };
 
 export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
@@ -171,11 +180,11 @@ export interface Schema extends OfSchema {
 
 type RequiredPropertyNames<s extends Obj> = {
 	[K in keyof s]:
-		// K is not optional
-		s[K]['optional'] extends false ? K :
-		// K has default value
-		s[K]['default'] extends null | string | number | boolean | Record<string, unknown> ? K :
-		never
+	// K is not optional
+	s[K]['optional'] extends false ? K :
+	// K has default value
+	s[K]['default'] extends null | string | number | boolean | Record<string, unknown> ? K :
+	never
 }[keyof s];
 
 export type Obj = Record<string, Schema>;
@@ -214,18 +223,18 @@ type ObjectSchemaTypeDef<p extends Schema> =
 		p['anyOf'] extends ReadonlyArray<Schema> ? p['anyOf'][number]['required'] extends ReadonlyArray<keyof p['properties']> ?
 			UnionObjType<p['properties'], NonNullable<p['anyOf'][number]['required']>> & ObjType<p['properties'], NonNullable<p['required']>>
 			: never
-			: ObjType<p['properties'], NonNullable<p['required']>>
-	:
-	p['anyOf'] extends ReadonlyArray<Schema> ? never : // see CONTRIBUTING.md
-	p['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<p['allOf']>> :
-	p['additionalProperties'] extends true ? Record<string, any> :
-	p['additionalProperties'] extends Schema ?
-		p['additionalProperties'] extends infer AdditionalProperties ?
-			AdditionalProperties extends Schema ?
-				Record<string, SchemaType<AdditionalProperties>> :
+		: ObjType<p['properties'], NonNullable<p['required']>>
+		:
+		p['anyOf'] extends ReadonlyArray<Schema> ? never : // see CONTRIBUTING.md
+		p['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<p['allOf']>> :
+		p['additionalProperties'] extends true ? Record<string, any> :
+		p['additionalProperties'] extends Schema ?
+			p['additionalProperties'] extends infer AdditionalProperties ?
+				AdditionalProperties extends Schema ?
+					Record<string, SchemaType<AdditionalProperties>> :
+					never :
 				never :
-			never :
-	any;
+			any;
 
 type ObjectSchemaType<p extends Schema> = NullOrUndefined<p, ObjectSchemaTypeDef<p>>;
 
@@ -235,30 +244,30 @@ export type SchemaTypeDef<p extends Schema> =
 	p['type'] extends 'number' ? number :
 	p['type'] extends 'string' ? (
 		p['enum'] extends readonly (string | null)[] ?
-		p['enum'][number] :
-		p['format'] extends 'date-time' ? string : // Dateにする？？
-		string
+			p['enum'][number] :
+			p['format'] extends 'date-time' ? string : // Dateにする？？
+			string
 	) :
-	p['type'] extends 'boolean' ? boolean :
-	p['type'] extends 'object' ? ObjectSchemaTypeDef<p> :
-	p['type'] extends 'array' ? (
-		p['items'] extends OfSchema ? (
-			p['items']['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<NonNullable<p['items']['anyOf']>>[] :
-			p['items']['oneOf'] extends ReadonlyArray<Schema> ? ArrayUnion<UnionSchemaType<NonNullable<p['items']['oneOf']>>> :
-			p['items']['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<NonNullable<p['items']['allOf']>>>[] :
-			never
-		) :
-		p['prefixItems'] extends ReadonlyArray<Schema> ? (
-			p['items'] extends NonNullable<Schema> ? [...ArrayToTuple<p['prefixItems']>, ...SchemaType<p['items']>[]] :
-			p['items'] extends false ? ArrayToTuple<p['prefixItems']> :
-			p['unevaluatedItems'] extends false ? ArrayToTuple<p['prefixItems']> :
-			[...ArrayToTuple<p['prefixItems']>, ...unknown[]]
+		p['type'] extends 'boolean' ? boolean :
+		p['type'] extends 'object' ? ObjectSchemaTypeDef<p> :
+		p['type'] extends 'array' ? (
+			p['items'] extends OfSchema ? (
+				p['items']['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<NonNullable<p['items']['anyOf']>>[] :
+				p['items']['oneOf'] extends ReadonlyArray<Schema> ? ArrayUnion<UnionSchemaType<NonNullable<p['items']['oneOf']>>> :
+				p['items']['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<NonNullable<p['items']['allOf']>>>[] :
+				never
+			) :
+				p['prefixItems'] extends ReadonlyArray<Schema> ? (
+					p['items'] extends NonNullable<Schema> ? [...ArrayToTuple<p['prefixItems']>, ...SchemaType<p['items']>[]] :
+					p['items'] extends false ? ArrayToTuple<p['prefixItems']> :
+					p['unevaluatedItems'] extends false ? ArrayToTuple<p['prefixItems']> :
+					[...ArrayToTuple<p['prefixItems']>, ...unknown[]]
+				) :
+					p['items'] extends NonNullable<Schema> ? SchemaType<p['items']>[] :
+					any[]
 		) :
-		p['items'] extends NonNullable<Schema> ? SchemaType<p['items']>[] :
-		any[]
-	) :
-	p['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['anyOf']> & PartialIntersection<UnionSchemaType<p['anyOf']>> :
-	p['oneOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['oneOf']> :
-	any;
+			p['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['anyOf']> & PartialIntersection<UnionSchemaType<p['anyOf']>> :
+			p['oneOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['oneOf']> :
+			any;
 
 export type SchemaType<p extends Schema> = NullOrUndefined<p, SchemaTypeDef<p>>;
diff --git a/packages/backend/src/models/ChatApproval.ts b/packages/backend/src/models/ChatApproval.ts
new file mode 100644
index 0000000000..55c9f07e9a
--- /dev/null
+++ b/packages/backend/src/models/ChatApproval.ts
@@ -0,0 +1,39 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { id } from './util/id.js';
+import { MiUser } from './User.js';
+
+@Entity('chat_approval')
+@Index(['userId', 'otherId'], { unique: true })
+export class MiChatApproval {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Index()
+	@Column({
+		...id(),
+	})
+	public userId: MiUser['id'];
+
+	@ManyToOne(type => MiUser, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public user: MiUser | null;
+
+	@Index()
+	@Column({
+		...id(),
+	})
+	public otherId: MiUser['id'];
+
+	@ManyToOne(type => MiUser, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public other: MiUser | null;
+}
diff --git a/packages/backend/src/models/ChatMessage.ts b/packages/backend/src/models/ChatMessage.ts
new file mode 100644
index 0000000000..3d2b64268e
--- /dev/null
+++ b/packages/backend/src/models/ChatMessage.ts
@@ -0,0 +1,85 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { id } from './util/id.js';
+import { MiUser } from './User.js';
+import { MiDriveFile } from './DriveFile.js';
+import { MiChatRoom } from './ChatRoom.js';
+
+@Entity('chat_message')
+export class MiChatMessage {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Index()
+	@Column({
+		...id(),
+	})
+	public fromUserId: MiUser['id'];
+
+	@ManyToOne(type => MiUser, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public fromUser: MiUser | null;
+
+	@Index()
+	@Column({
+		...id(), nullable: true,
+	})
+	public toUserId: MiUser['id'] | null;
+
+	@ManyToOne(type => MiUser, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public toUser: MiUser | null;
+
+	@Index()
+	@Column({
+		...id(), nullable: true,
+	})
+	public toRoomId: MiChatRoom['id'] | null;
+
+	@ManyToOne(type => MiChatRoom, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public toRoom: MiChatRoom | null;
+
+	@Column('varchar', {
+		length: 4096, nullable: true,
+	})
+	public text: string | null;
+
+	@Column('varchar', {
+		length: 512, nullable: true,
+	})
+	public uri: string | null;
+
+	@Column({
+		...id(),
+		array: true, default: '{}',
+	})
+	public reads: MiUser['id'][];
+
+	@Column({
+		...id(),
+		nullable: true,
+	})
+	public fileId: MiDriveFile['id'] | null;
+
+	@ManyToOne(type => MiDriveFile, {
+		onDelete: 'SET NULL',
+	})
+	@JoinColumn()
+	public file: MiDriveFile | null;
+
+	@Column('varchar', {
+		length: 1024, array: true, default: '{}',
+	})
+	public reactions: string[];
+}
diff --git a/packages/backend/src/models/ChatRoom.ts b/packages/backend/src/models/ChatRoom.ts
new file mode 100644
index 0000000000..ad2a910b78
--- /dev/null
+++ b/packages/backend/src/models/ChatRoom.ts
@@ -0,0 +1,41 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { id } from './util/id.js';
+import { MiUser } from './User.js';
+
+@Entity('chat_room')
+export class MiChatRoom {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Column('varchar', {
+		length: 256,
+	})
+	public name: string;
+
+	@Index()
+	@Column({
+		...id(),
+	})
+	public ownerId: MiUser['id'];
+
+	@ManyToOne(type => MiUser, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public owner: MiUser | null;
+
+	@Column('varchar', {
+		length: 2048, default: '',
+	})
+	public description: string;
+
+	@Column('boolean', {
+		default: false,
+	})
+	public isArchived: boolean;
+}
diff --git a/packages/backend/src/models/ChatRoomInvitation.ts b/packages/backend/src/models/ChatRoomInvitation.ts
new file mode 100644
index 0000000000..36ce12bc92
--- /dev/null
+++ b/packages/backend/src/models/ChatRoomInvitation.ts
@@ -0,0 +1,45 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { id } from './util/id.js';
+import { MiUser } from './User.js';
+import { MiChatRoom } from './ChatRoom.js';
+
+@Entity('chat_room_invitation')
+@Index(['userId', 'roomId'], { unique: true })
+export class MiChatRoomInvitation {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Index()
+	@Column({
+		...id(),
+	})
+	public userId: MiUser['id'];
+
+	@ManyToOne(type => MiUser, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public user: MiUser | null;
+
+	@Index()
+	@Column({
+		...id(),
+	})
+	public roomId: MiChatRoom['id'];
+
+	@ManyToOne(type => MiChatRoom, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public room: MiChatRoom | null;
+
+	@Column('boolean', {
+		default: false,
+	})
+	public ignored: boolean;
+}
diff --git a/packages/backend/src/models/ChatRoomMembership.ts b/packages/backend/src/models/ChatRoomMembership.ts
new file mode 100644
index 0000000000..3cb5524859
--- /dev/null
+++ b/packages/backend/src/models/ChatRoomMembership.ts
@@ -0,0 +1,45 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { id } from './util/id.js';
+import { MiUser } from './User.js';
+import { MiChatRoom } from './ChatRoom.js';
+
+@Entity('chat_room_membership')
+@Index(['userId', 'roomId'], { unique: true })
+export class MiChatRoomMembership {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Index()
+	@Column({
+		...id(),
+	})
+	public userId: MiUser['id'];
+
+	@ManyToOne(type => MiUser, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public user: MiUser | null;
+
+	@Index()
+	@Column({
+		...id(),
+	})
+	public roomId: MiChatRoom['id'];
+
+	@ManyToOne(type => MiChatRoom, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public room: MiChatRoom | null;
+
+	@Column('boolean', {
+		default: false,
+	})
+	public isMuted: boolean;
+}
diff --git a/packages/backend/src/models/NoteUnread.ts b/packages/backend/src/models/NoteUnread.ts
deleted file mode 100644
index c759181117..0000000000
--- a/packages/backend/src/models/NoteUnread.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
-import { id } from './util/id.js';
-import { MiUser } from './User.js';
-import { MiNote } from './Note.js';
-import type { MiChannel } from './Channel.js';
-
-@Entity('note_unread')
-@Index(['userId', 'noteId'], { unique: true })
-export class MiNoteUnread {
-	@PrimaryColumn(id())
-	public id: string;
-
-	@Index()
-	@Column(id())
-	public userId: MiUser['id'];
-
-	@ManyToOne(type => MiUser, {
-		onDelete: 'CASCADE',
-	})
-	@JoinColumn()
-	public user: MiUser | null;
-
-	@Index()
-	@Column(id())
-	public noteId: MiNote['id'];
-
-	@ManyToOne(type => MiNote, {
-		onDelete: 'CASCADE',
-	})
-	@JoinColumn()
-	public note: MiNote | null;
-
-	/**
-	 * メンションか否か
-	 */
-	@Index()
-	@Column('boolean')
-	public isMentioned: boolean;
-
-	/**
-	 * ダイレクト投稿か否か
-	 */
-	@Index()
-	@Column('boolean')
-	public isSpecified: boolean;
-
-	//#region Denormalized fields
-	@Index()
-	@Column({
-		...id(),
-		comment: '[Denormalized]',
-	})
-	public noteUserId: MiUser['id'];
-
-	@Index()
-	@Column({
-		...id(),
-		nullable: true,
-		comment: '[Denormalized]',
-	})
-	public noteChannelId: MiChannel['id'] | null;
-	//#endregion
-}
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index 04a9df6cfb..b7142d91bf 100644
--- a/packages/backend/src/models/RepositoryModule.ts
+++ b/packages/backend/src/models/RepositoryModule.ts
@@ -42,7 +42,6 @@ import {
 	MiNoteFavorite,
 	MiNoteReaction,
 	MiNoteThreadMuting,
-	MiNoteUnread,
 	MiPage,
 	MiPageLike,
 	MiPasswordResetRequest,
@@ -78,6 +77,11 @@ import {
 	MiUserPublickey,
 	MiUserSecurityKey,
 	MiWebhook,
+	MiChatMessage,
+	MiChatRoom,
+	MiChatRoomMembership,
+	MiChatRoomInvitation,
+	MiChatApproval,
 } from './_.js';
 import type { Provider } from '@nestjs/common';
 import type { DataSource } from 'typeorm';
@@ -136,12 +140,6 @@ const $noteReactionsRepository: Provider = {
 	inject: [DI.db],
 };
 
-const $noteUnreadsRepository: Provider = {
-	provide: DI.noteUnreadsRepository,
-	useFactory: (db: DataSource) => db.getRepository(MiNoteUnread).extend(miRepository as MiRepository<MiNoteUnread>),
-	inject: [DI.db],
-};
-
 const $pollsRepository: Provider = {
 	provide: DI.pollsRepository,
 	useFactory: (db: DataSource) => db.getRepository(MiPoll).extend(miRepository as MiRepository<MiPoll>),
@@ -288,7 +286,7 @@ const $swSubscriptionsRepository: Provider = {
 
 const $systemAccountsRepository: Provider = {
 	provide: DI.systemAccountsRepository,
-	useFactory: (db: DataSource) => db.getRepository(MiSystemAccount),
+	useFactory: (db: DataSource) => db.getRepository(MiSystemAccount).extend(miRepository as MiRepository<MiSystemAccount>),
 	inject: [DI.db],
 };
 
@@ -306,7 +304,7 @@ const $abuseUserReportsRepository: Provider = {
 
 const $abuseReportNotificationRecipientRepository: Provider = {
 	provide: DI.abuseReportNotificationRecipientRepository,
-	useFactory: (db: DataSource) => db.getRepository(MiAbuseReportNotificationRecipient),
+	useFactory: (db: DataSource) => db.getRepository(MiAbuseReportNotificationRecipient).extend(miRepository as MiRepository<MiAbuseReportNotificationRecipient>),
 	inject: [DI.db],
 };
 
@@ -438,7 +436,7 @@ const $webhooksRepository: Provider = {
 
 const $systemWebhooksRepository: Provider = {
 	provide: DI.systemWebhooksRepository,
-	useFactory: (db: DataSource) => db.getRepository(MiSystemWebhook),
+	useFactory: (db: DataSource) => db.getRepository(MiSystemWebhook).extend(miRepository as MiRepository<MiSystemWebhook>),
 	inject: [DI.db],
 };
 
@@ -490,6 +488,36 @@ const $userMemosRepository: Provider = {
 	inject: [DI.db],
 };
 
+const $chatMessagesRepository: Provider = {
+	provide: DI.chatMessagesRepository,
+	useFactory: (db: DataSource) => db.getRepository(MiChatMessage).extend(miRepository as MiRepository<MiChatMessage>),
+	inject: [DI.db],
+};
+
+const $chatRoomsRepository: Provider = {
+	provide: DI.chatRoomsRepository,
+	useFactory: (db: DataSource) => db.getRepository(MiChatRoom).extend(miRepository as MiRepository<MiChatRoom>),
+	inject: [DI.db],
+};
+
+const $chatRoomMembershipsRepository: Provider = {
+	provide: DI.chatRoomMembershipsRepository,
+	useFactory: (db: DataSource) => db.getRepository(MiChatRoomMembership).extend(miRepository as MiRepository<MiChatRoomMembership>),
+	inject: [DI.db],
+};
+
+const $chatRoomInvitationsRepository: Provider = {
+	provide: DI.chatRoomInvitationsRepository,
+	useFactory: (db: DataSource) => db.getRepository(MiChatRoomInvitation).extend(miRepository as MiRepository<MiChatRoomInvitation>),
+	inject: [DI.db],
+};
+
+const $chatApprovalsRepository: Provider = {
+	provide: DI.chatApprovalsRepository,
+	useFactory: (db: DataSource) => db.getRepository(MiChatApproval).extend(miRepository as MiRepository<MiChatApproval>),
+	inject: [DI.db],
+};
+
 const $bubbleGameRecordsRepository: Provider = {
 	provide: DI.bubbleGameRecordsRepository,
 	useFactory: (db: DataSource) => db.getRepository(MiBubbleGameRecord).extend(miRepository as MiRepository<MiBubbleGameRecord>),
@@ -514,7 +542,6 @@ const $reversiGamesRepository: Provider = {
 		$noteFavoritesRepository,
 		$noteThreadMutingsRepository,
 		$noteReactionsRepository,
-		$noteUnreadsRepository,
 		$pollsRepository,
 		$pollVotesRepository,
 		$userProfilesRepository,
@@ -573,6 +600,11 @@ const $reversiGamesRepository: Provider = {
 		$flashsRepository,
 		$flashLikesRepository,
 		$userMemosRepository,
+		$chatMessagesRepository,
+		$chatRoomsRepository,
+		$chatRoomMembershipsRepository,
+		$chatRoomInvitationsRepository,
+		$chatApprovalsRepository,
 		$bubbleGameRecordsRepository,
 		$reversiGamesRepository,
 	],
@@ -586,7 +618,6 @@ const $reversiGamesRepository: Provider = {
 		$noteFavoritesRepository,
 		$noteThreadMutingsRepository,
 		$noteReactionsRepository,
-		$noteUnreadsRepository,
 		$pollsRepository,
 		$pollVotesRepository,
 		$userProfilesRepository,
@@ -645,6 +676,11 @@ const $reversiGamesRepository: Provider = {
 		$flashsRepository,
 		$flashLikesRepository,
 		$userMemosRepository,
+		$chatMessagesRepository,
+		$chatRoomsRepository,
+		$chatRoomMembershipsRepository,
+		$chatRoomInvitationsRepository,
+		$chatApprovalsRepository,
 		$bubbleGameRecordsRepository,
 		$reversiGamesRepository,
 	],
diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts
index 630240efde..bc652cea62 100644
--- a/packages/backend/src/models/User.ts
+++ b/packages/backend/src/models/User.ts
@@ -225,6 +225,17 @@ export class MiUser {
 	})
 	public emojis: string[];
 
+	// チャットを許可する相手
+	// everyone: 誰からでも
+	// followers: フォロワーのみ
+	// following: フォローしているユーザーのみ
+	// mutual: 相互フォローのみ
+	// none: 誰からも受け付けない
+	@Column('varchar', {
+		length: 128, default: 'mutual',
+	})
+	public chatScope: 'everyone' | 'followers' | 'following' | 'mutual' | 'none';
+
 	@Index()
 	@Column('varchar', {
 		length: 128, nullable: true,
diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts
index fa15760c00..e852b302f3 100644
--- a/packages/backend/src/models/_.ts
+++ b/packages/backend/src/models/_.ts
@@ -3,13 +3,10 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder, TypeORMError } from 'typeorm';
-import { DriverUtils } from 'typeorm/driver/DriverUtils.js';
+import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm';
 import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js';
 import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js';
 import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js';
-import { ObjectUtils } from 'typeorm/util/ObjectUtils.js';
-import { OrmUtils } from 'typeorm/util/OrmUtils.js';
 import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
 import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
 import { MiAccessToken } from '@/models/AccessToken.js';
@@ -43,7 +40,6 @@ import { MiNote } from '@/models/Note.js';
 import { MiNoteFavorite } from '@/models/NoteFavorite.js';
 import { MiNoteReaction } from '@/models/NoteReaction.js';
 import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js';
-import { MiNoteUnread } from '@/models/NoteUnread.js';
 import { MiPage } from '@/models/Page.js';
 import { MiPageLike } from '@/models/PageLike.js';
 import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js';
@@ -78,6 +74,11 @@ import { MiRoleAssignment } from '@/models/RoleAssignment.js';
 import { MiFlash } from '@/models/Flash.js';
 import { MiFlashLike } from '@/models/FlashLike.js';
 import { MiUserListFavorite } from '@/models/UserListFavorite.js';
+import { MiChatMessage } from '@/models/ChatMessage.js';
+import { MiChatRoom } from '@/models/ChatRoom.js';
+import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
+import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
+import { MiChatApproval } from '@/models/ChatApproval.js';
 import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
 import { MiReversiGame } from '@/models/ReversiGame.js';
 import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
@@ -159,7 +160,6 @@ export {
 	MiNoteFavorite,
 	MiNoteReaction,
 	MiNoteThreadMuting,
-	MiNoteUnread,
 	MiPage,
 	MiPageLike,
 	MiPasswordResetRequest,
@@ -194,6 +194,11 @@ export {
 	MiFlash,
 	MiFlashLike,
 	MiUserMemo,
+	MiChatMessage,
+	MiChatRoom,
+	MiChatRoomMembership,
+	MiChatRoomInvitation,
+	MiChatApproval,
 	MiBubbleGameRecord,
 	MiReversiGame,
 };
@@ -231,7 +236,6 @@ export type NotesRepository = Repository<MiNote> & MiRepository<MiNote>;
 export type NoteFavoritesRepository = Repository<MiNoteFavorite> & MiRepository<MiNoteFavorite>;
 export type NoteReactionsRepository = Repository<MiNoteReaction> & MiRepository<MiNoteReaction>;
 export type NoteThreadMutingsRepository = Repository<MiNoteThreadMuting> & MiRepository<MiNoteThreadMuting>;
-export type NoteUnreadsRepository = Repository<MiNoteUnread> & MiRepository<MiNoteUnread>;
 export type PagesRepository = Repository<MiPage> & MiRepository<MiPage>;
 export type PageLikesRepository = Repository<MiPageLike> & MiRepository<MiPageLike>;
 export type PasswordResetRequestsRepository = Repository<MiPasswordResetRequest> & MiRepository<MiPasswordResetRequest>;
@@ -266,5 +270,10 @@ export type RoleAssignmentsRepository = Repository<MiRoleAssignment> & MiReposit
 export type FlashsRepository = Repository<MiFlash> & MiRepository<MiFlash>;
 export type FlashLikesRepository = Repository<MiFlashLike> & MiRepository<MiFlashLike>;
 export type UserMemoRepository = Repository<MiUserMemo> & MiRepository<MiUserMemo>;
+export type ChatMessagesRepository = Repository<MiChatMessage> & MiRepository<MiChatMessage>;
+export type ChatRoomsRepository = Repository<MiChatRoom> & MiRepository<MiChatRoom>;
+export type ChatRoomMembershipsRepository = Repository<MiChatRoomMembership> & MiRepository<MiChatRoomMembership>;
+export type ChatRoomInvitationsRepository = Repository<MiChatRoomInvitation> & MiRepository<MiChatRoomInvitation>;
+export type ChatApprovalsRepository = Repository<MiChatApproval> & MiRepository<MiChatApproval>;
 export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
 export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
diff --git a/packages/backend/src/models/json-schema/chat-message.ts b/packages/backend/src/models/json-schema/chat-message.ts
new file mode 100644
index 0000000000..44b7298702
--- /dev/null
+++ b/packages/backend/src/models/json-schema/chat-message.ts
@@ -0,0 +1,146 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const packedChatMessageSchema = {
+	type: 'object',
+	properties: {
+		id: {
+			type: 'string',
+			optional: false, nullable: false,
+		},
+		createdAt: {
+			type: 'string',
+			format: 'date-time',
+			optional: false, nullable: false,
+		},
+		fromUserId: {
+			type: 'string',
+			optional: false, nullable: false,
+		},
+		fromUser: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'UserLite',
+		},
+		toUserId: {
+			type: 'string',
+			optional: true, nullable: true,
+		},
+		toUser: {
+			type: 'object',
+			optional: true, nullable: true,
+			ref: 'UserLite',
+		},
+		toRoomId: {
+			type: 'string',
+			optional: true, nullable: true,
+		},
+		toRoom: {
+			type: 'object',
+			optional: true, nullable: true,
+			ref: 'ChatRoom',
+		},
+		text: {
+			type: 'string',
+			optional: true, nullable: true,
+		},
+		fileId: {
+			type: 'string',
+			optional: true, nullable: true,
+		},
+		file: {
+			type: 'object',
+			optional: true, nullable: true,
+			ref: 'DriveFile',
+		},
+		isRead: {
+			type: 'boolean',
+			optional: true, nullable: false,
+		},
+		reactions: {
+			type: 'array',
+			optional: false, nullable: false,
+			items: {
+				type: 'object',
+				optional: false, nullable: false,
+				properties: {
+					reaction: {
+						type: 'string',
+						optional: false, nullable: false,
+					},
+					user: {
+						type: 'object',
+						optional: true, nullable: true,
+						ref: 'UserLite',
+					},
+				},
+			},
+		},
+	},
+} as const;
+
+export const packedChatMessageLiteSchema = {
+	type: 'object',
+	properties: {
+		id: {
+			type: 'string',
+			optional: false, nullable: false,
+		},
+		createdAt: {
+			type: 'string',
+			format: 'date-time',
+			optional: false, nullable: false,
+		},
+		fromUserId: {
+			type: 'string',
+			optional: false, nullable: false,
+		},
+		fromUser: {
+			type: 'object',
+			optional: true, nullable: false,
+			ref: 'UserLite',
+		},
+		toUserId: {
+			type: 'string',
+			optional: true, nullable: true,
+		},
+		toRoomId: {
+			type: 'string',
+			optional: true, nullable: true,
+		},
+		text: {
+			type: 'string',
+			optional: true, nullable: true,
+		},
+		fileId: {
+			type: 'string',
+			optional: true, nullable: true,
+		},
+		file: {
+			type: 'object',
+			optional: true, nullable: true,
+			ref: 'DriveFile',
+		},
+		reactions: {
+			type: 'array',
+			optional: false, nullable: false,
+			items: {
+				type: 'object',
+				optional: false, nullable: false,
+				properties: {
+					reaction: {
+						type: 'string',
+						optional: false, nullable: false,
+					},
+					user: {
+						type: 'object',
+						optional: true, nullable: true,
+						ref: 'UserLite',
+					},
+				},
+			},
+		},
+	},
+} as const;
diff --git a/packages/backend/src/models/json-schema/chat-room-invitation.ts b/packages/backend/src/models/json-schema/chat-room-invitation.ts
new file mode 100644
index 0000000000..204c959b2c
--- /dev/null
+++ b/packages/backend/src/models/json-schema/chat-room-invitation.ts
@@ -0,0 +1,37 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const packedChatRoomInvitationSchema = {
+	type: 'object',
+	properties: {
+		id: {
+			type: 'string',
+			optional: false, nullable: false,
+		},
+		createdAt: {
+			type: 'string',
+			format: 'date-time',
+			optional: false, nullable: false,
+		},
+		userId: {
+			type: 'string',
+			optional: false, nullable: false,
+		},
+		user: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'UserLite',
+		},
+		roomId: {
+			type: 'string',
+			optional: false, nullable: false,
+		},
+		room: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'ChatRoom',
+		},
+	},
+} as const;
diff --git a/packages/backend/src/models/json-schema/chat-room-membership.ts b/packages/backend/src/models/json-schema/chat-room-membership.ts
new file mode 100644
index 0000000000..adb73f9dde
--- /dev/null
+++ b/packages/backend/src/models/json-schema/chat-room-membership.ts
@@ -0,0 +1,37 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const packedChatRoomMembershipSchema = {
+	type: 'object',
+	properties: {
+		id: {
+			type: 'string',
+			optional: false, nullable: false,
+		},
+		createdAt: {
+			type: 'string',
+			format: 'date-time',
+			optional: false, nullable: false,
+		},
+		userId: {
+			type: 'string',
+			optional: false, nullable: false,
+		},
+		user: {
+			type: 'object',
+			optional: true, nullable: false,
+			ref: 'UserLite',
+		},
+		roomId: {
+			type: 'string',
+			optional: false, nullable: false,
+		},
+		room: {
+			type: 'object',
+			optional: true, nullable: false,
+			ref: 'ChatRoom',
+		},
+	},
+} as const;
diff --git a/packages/backend/src/models/json-schema/chat-room.ts b/packages/backend/src/models/json-schema/chat-room.ts
new file mode 100644
index 0000000000..e97556e378
--- /dev/null
+++ b/packages/backend/src/models/json-schema/chat-room.ts
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const packedChatRoomSchema = {
+	type: 'object',
+	properties: {
+		id: {
+			type: 'string',
+			optional: false, nullable: false,
+		},
+		createdAt: {
+			type: 'string',
+			format: 'date-time',
+			optional: false, nullable: false,
+		},
+		ownerId: {
+			type: 'string',
+			optional: false, nullable: false,
+		},
+		owner: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'UserLite',
+		},
+		name: {
+			type: 'string',
+			optional: false, nullable: false,
+		},
+		description: {
+			type: 'string',
+			optional: false, nullable: false,
+		},
+		isMuted: {
+			type: 'boolean',
+			optional: true, nullable: false,
+		},
+	},
+} as const;
diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts
index 3537de94c8..1685a806c9 100644
--- a/packages/backend/src/models/json-schema/role.ts
+++ b/packages/backend/src/models/json-schema/role.ts
@@ -292,6 +292,10 @@ export const packedRolePoliciesSchema = {
 			type: 'boolean',
 			optional: false, nullable: false,
 		},
+		canChat: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
 	},
 } as const;
 
diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts
index 38631f907d..b10430782b 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -358,6 +358,11 @@ export const packedUserDetailedNotMeOnlySchema = {
 			nullable: false, optional: false,
 			enum: ['public', 'followers', 'private'],
 		},
+		chatScope: {
+			type: 'string',
+			nullable: false, optional: false,
+			enum: ['everyone', 'following', 'followers', 'mutual', 'none'],
+		},
 		roles: {
 			type: 'array',
 			nullable: false, optional: false,
@@ -540,6 +545,10 @@ export const packedMeDetailedOnlySchema = {
 			type: 'boolean',
 			nullable: false, optional: false,
 		},
+		hasUnreadChatMessages: {
+			type: 'boolean',
+			nullable: false, optional: false,
+		},
 		hasUnreadNotification: {
 			type: 'boolean',
 			nullable: false, optional: false,
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index 043332d4b5..4694e7003d 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -8,6 +8,9 @@ import pg from 'pg';
 import { DataSource, Logger } from 'typeorm';
 import * as highlight from 'cli-highlight';
 import { entities as charts } from '@/core/chart/entities.js';
+import { Config } from '@/config.js';
+import MisskeyLogger from '@/logger.js';
+import { bindThis } from '@/decorators.js';
 
 import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
 import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
@@ -42,7 +45,6 @@ import { MiNote } from '@/models/Note.js';
 import { MiNoteFavorite } from '@/models/NoteFavorite.js';
 import { MiNoteReaction } from '@/models/NoteReaction.js';
 import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js';
-import { MiNoteUnread } from '@/models/NoteUnread.js';
 import { MiPage } from '@/models/Page.js';
 import { MiPageLike } from '@/models/PageLike.js';
 import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js';
@@ -76,13 +78,14 @@ import { MiRoleAssignment } from '@/models/RoleAssignment.js';
 import { MiFlash } from '@/models/Flash.js';
 import { MiFlashLike } from '@/models/FlashLike.js';
 import { MiUserMemo } from '@/models/UserMemo.js';
+import { MiChatMessage } from '@/models/ChatMessage.js';
+import { MiChatRoom } from '@/models/ChatRoom.js';
+import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
+import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
 import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
 import { MiReversiGame } from '@/models/ReversiGame.js';
-
-import { Config } from '@/config.js';
-import MisskeyLogger from '@/logger.js';
-import { bindThis } from '@/decorators.js';
-import { MiSystemAccount } from './models/SystemAccount.js';
+import { MiChatApproval } from '@/models/ChatApproval.js';
+import { MiSystemAccount } from '@/models/SystemAccount.js';
 
 pg.types.setTypeParser(20, Number);
 
@@ -195,7 +198,6 @@ export const entities = [
 	MiNoteFavorite,
 	MiNoteReaction,
 	MiNoteThreadMuting,
-	MiNoteUnread,
 	MiPage,
 	MiPageLike,
 	MiGalleryPost,
@@ -236,6 +238,11 @@ export const entities = [
 	MiFlash,
 	MiFlashLike,
 	MiUserMemo,
+	MiChatMessage,
+	MiChatRoom,
+	MiChatRoomMembership,
+	MiChatRoomInvitation,
+	MiChatApproval,
 	MiBubbleGameRecord,
 	MiReversiGame,
 	...charts,
diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts
index 3ab0b815f2..0223650329 100644
--- a/packages/backend/src/server/ServerModule.ts
+++ b/packages/backend/src/server/ServerModule.ts
@@ -44,6 +44,8 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
 import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
 import { UserListChannelService } from './api/stream/channels/user-list.js';
 import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
+import { ChatUserChannelService } from './api/stream/channels/chat-user.js';
+import { ChatRoomChannelService } from './api/stream/channels/chat-room.js';
 import { ReversiChannelService } from './api/stream/channels/reversi.js';
 import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
 import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
@@ -84,6 +86,8 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
 		GlobalTimelineChannelService,
 		HashtagChannelService,
 		RoleTimelineChannelService,
+		ChatUserChannelService,
+		ChatRoomChannelService,
 		ReversiChannelService,
 		ReversiGameChannelService,
 		HomeTimelineChannelService,
diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts
index b8f448477b..2a4e1fc574 100644
--- a/packages/backend/src/server/api/StreamingApiServerService.ts
+++ b/packages/backend/src/server/api/StreamingApiServerService.ts
@@ -9,7 +9,6 @@ import * as Redis from 'ioredis';
 import * as WebSocket from 'ws';
 import { DI } from '@/di-symbols.js';
 import type { UsersRepository, MiAccessToken } from '@/models/_.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
 import { NotificationService } from '@/core/NotificationService.js';
 import { bindThis } from '@/decorators.js';
 import { CacheService } from '@/core/CacheService.js';
@@ -35,7 +34,6 @@ export class StreamingApiServerService {
 		private usersRepository: UsersRepository,
 
 		private cacheService: CacheService,
-		private noteReadService: NoteReadService,
 		private authenticateService: AuthenticateService,
 		private channelsService: ChannelsService,
 		private notificationService: NotificationService,
@@ -96,7 +94,6 @@ export class StreamingApiServerService {
 
 			const stream = new MainStreamConnection(
 				this.channelsService,
-				this.noteReadService,
 				this.notificationService,
 				this.cacheService,
 				this.channelFollowingService,
diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts
index 560d3f6587..9bb29e138b 100644
--- a/packages/backend/src/server/api/endpoint-list.ts
+++ b/packages/backend/src/server/api/endpoint-list.ts
@@ -263,7 +263,6 @@ export * as 'i/notifications-grouped' from './endpoints/i/notifications-grouped.
 export * as 'i/page-likes' from './endpoints/i/page-likes.js';
 export * as 'i/pages' from './endpoints/i/pages.js';
 export * as 'i/pin' from './endpoints/i/pin.js';
-export * as 'i/read-all-unread-notes' from './endpoints/i/read-all-unread-notes.js';
 export * as 'i/read-announcement' from './endpoints/i/read-announcement.js';
 export * as 'i/regenerate-token' from './endpoints/i/regenerate-token.js';
 export * as 'i/registry/get' from './endpoints/i/registry/get.js';
@@ -397,4 +396,26 @@ export * as 'users/search' from './endpoints/users/search.js';
 export * as 'users/search-by-username-and-host' from './endpoints/users/search-by-username-and-host.js';
 export * as 'users/show' from './endpoints/users/show.js';
 export * as 'users/update-memo' from './endpoints/users/update-memo.js';
+export * as 'chat/messages/create-to-user' from './endpoints/chat/messages/create-to-user.js';
+export * as 'chat/messages/create-to-room' from './endpoints/chat/messages/create-to-room.js';
+export * as 'chat/messages/delete' from './endpoints/chat/messages/delete.js';
+export * as 'chat/messages/show' from './endpoints/chat/messages/show.js';
+export * as 'chat/messages/react' from './endpoints/chat/messages/react.js';
+export * as 'chat/messages/user-timeline' from './endpoints/chat/messages/user-timeline.js';
+export * as 'chat/messages/room-timeline' from './endpoints/chat/messages/room-timeline.js';
+export * as 'chat/messages/search' from './endpoints/chat/messages/search.js';
+export * as 'chat/rooms/create' from './endpoints/chat/rooms/create.js';
+export * as 'chat/rooms/delete' from './endpoints/chat/rooms/delete.js';
+export * as 'chat/rooms/join' from './endpoints/chat/rooms/join.js';
+export * as 'chat/rooms/leave' from './endpoints/chat/rooms/leave.js';
+export * as 'chat/rooms/mute' from './endpoints/chat/rooms/mute.js';
+export * as 'chat/rooms/show' from './endpoints/chat/rooms/show.js';
+export * as 'chat/rooms/owned' from './endpoints/chat/rooms/owned.js';
+export * as 'chat/rooms/joining' from './endpoints/chat/rooms/joining.js';
+export * as 'chat/rooms/update' from './endpoints/chat/rooms/update.js';
+export * as 'chat/rooms/members' from './endpoints/chat/rooms/members.js';
+export * as 'chat/rooms/invitations/create' from './endpoints/chat/rooms/invitations/create.js';
+export * as 'chat/rooms/invitations/ignore' from './endpoints/chat/rooms/invitations/ignore.js';
+export * as 'chat/rooms/invitations/inbox' from './endpoints/chat/rooms/invitations/inbox.js';
+export * as 'chat/history' from './endpoints/chat/history.js';
 export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js';
diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts
index f4dfe1ecc4..727697ea14 100644
--- a/packages/backend/src/server/api/endpoints/antennas/notes.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts
@@ -8,7 +8,6 @@ import * as Redis from 'ioredis';
 import { Endpoint } from '@/server/api/endpoint-base.js';
 import type { NotesRepository, AntennasRepository } from '@/models/_.js';
 import { QueryService } from '@/core/QueryService.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
 import { DI } from '@/di-symbols.js';
 import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
 import { IdService } from '@/core/IdService.js';
@@ -59,9 +58,6 @@ export const paramDef = {
 @Injectable()
 export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 	constructor(
-		@Inject(DI.redisForTimelines)
-		private redisForTimelines: Redis.Redis,
-
 		@Inject(DI.notesRepository)
 		private notesRepository: NotesRepository,
 
@@ -71,7 +67,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 		private idService: IdService,
 		private noteEntityService: NoteEntityService,
 		private queryService: QueryService,
-		private noteReadService: NoteReadService,
 		private fanoutTimelineService: FanoutTimelineService,
 		private globalEventService: GlobalEventService,
 	) {
@@ -124,8 +119,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				notes.sort((a, b) => a.id > b.id ? -1 : 1);
 			}
 
-			this.noteReadService.read(me.id, notes);
-
 			return await this.noteEntityService.packMany(notes, me);
 		});
 	}
diff --git a/packages/backend/src/server/api/endpoints/chat/history.ts b/packages/backend/src/server/api/endpoints/chat/history.ts
new file mode 100644
index 0000000000..7553a751e0
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/history.ts
@@ -0,0 +1,73 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'read:chat',
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'ChatMessage',
+		},
+	},
+
+	errors: {
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+		room: { type: 'boolean', default: false },
+	},
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatEntityService: ChatEntityService,
+		private chatService: ChatService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const history = ps.room ? await this.chatService.roomHistory(me.id, ps.limit) : await this.chatService.userHistory(me.id, ps.limit);
+
+			const packedMessages = await this.chatEntityService.packMessagesDetailed(history, me);
+
+			if (ps.room) {
+				const roomIds = history.map(m => m.toRoomId!);
+				const readStateMap = await this.chatService.getRoomReadStateMap(me.id, roomIds);
+
+				for (const message of packedMessages) {
+					message.isRead = readStateMap[message.toRoomId!] ?? false;
+				}
+			} else {
+				const otherIds = history.map(m => m.fromUserId === me.id ? m.toUserId! : m.fromUserId!);
+				const readStateMap = await this.chatService.getUserReadStateMap(me.id, otherIds);
+
+				for (const message of packedMessages) {
+					const otherId = message.fromUserId === me.id ? message.toUserId! : message.fromUserId!;
+					message.isRead = readStateMap[otherId] ?? false;
+				}
+			}
+
+			return packedMessages;
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts
new file mode 100644
index 0000000000..1f334d5750
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts
@@ -0,0 +1,105 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatService } from '@/core/ChatService.js';
+import type { DriveFilesRepository, MiUser } from '@/models/_.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+	requiredRolePolicy: 'canChat',
+
+	prohibitMoved: true,
+
+	kind: 'write:chat',
+
+	limit: {
+		duration: ms('1hour'),
+		max: 500,
+	},
+
+	res: {
+		type: 'object',
+		optional: false, nullable: false,
+		ref: 'ChatMessageLite',
+	},
+
+	errors: {
+		noSuchRoom: {
+			message: 'No such room.',
+			code: 'NO_SUCH_ROOM',
+			id: '8098520d-2da5-4e8f-8ee1-df78b55a4ec6',
+		},
+
+		noSuchFile: {
+			message: 'No such file.',
+			code: 'NO_SUCH_FILE',
+			id: 'b6accbd3-1d7b-4d9f-bdb7-eb185bac06db',
+		},
+
+		contentRequired: {
+			message: 'Content required. You need to set text or fileId.',
+			code: 'CONTENT_REQUIRED',
+			id: '340517b7-6d04-42c0-bac1-37ee804e3594',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		text: { type: 'string', nullable: true, maxLength: 2000 },
+		fileId: { type: 'string', format: 'misskey:id' },
+		toRoomId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['toRoomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		@Inject(DI.driveFilesRepository)
+		private driveFilesRepository: DriveFilesRepository,
+
+		private getterService: GetterService,
+		private chatService: ChatService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const room = await this.chatService.findRoomById(ps.toRoomId);
+			if (room == null) {
+				throw new ApiError(meta.errors.noSuchRoom);
+			}
+
+			let file = null;
+			if (ps.fileId != null) {
+				file = await this.driveFilesRepository.findOneBy({
+					id: ps.fileId,
+					userId: me.id,
+				});
+
+				if (file == null) {
+					throw new ApiError(meta.errors.noSuchFile);
+				}
+			}
+
+			// テキストが無いかつ添付ファイルも無かったらエラー
+			if (ps.text == null && file == null) {
+				throw new ApiError(meta.errors.contentRequired);
+			}
+
+			return await this.chatService.createMessageToRoom(me, room, {
+				text: ps.text,
+				file: file,
+			});
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts
new file mode 100644
index 0000000000..6b77a026fb
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts
@@ -0,0 +1,122 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatService } from '@/core/ChatService.js';
+import type { DriveFilesRepository, MiUser } from '@/models/_.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+	requiredRolePolicy: 'canChat',
+
+	prohibitMoved: true,
+
+	kind: 'write:chat',
+
+	limit: {
+		duration: ms('1hour'),
+		max: 500,
+	},
+
+	res: {
+		type: 'object',
+		optional: false, nullable: false,
+		ref: 'ChatMessageLite',
+	},
+
+	errors: {
+		recipientIsYourself: {
+			message: 'You can not send a message to yourself.',
+			code: 'RECIPIENT_IS_YOURSELF',
+			id: '17e2ba79-e22a-4cbc-bf91-d327643f4a7e',
+		},
+
+		noSuchUser: {
+			message: 'No such user.',
+			code: 'NO_SUCH_USER',
+			id: '11795c64-40ea-4198-b06e-3c873ed9039d',
+		},
+
+		noSuchFile: {
+			message: 'No such file.',
+			code: 'NO_SUCH_FILE',
+			id: '4372b8e2-185d-4146-8749-2f68864a3e5f',
+		},
+
+		contentRequired: {
+			message: 'Content required. You need to set text or fileId.',
+			code: 'CONTENT_REQUIRED',
+			id: '25587321-b0e6-449c-9239-f8925092942c',
+		},
+
+		youHaveBeenBlocked: {
+			message: 'You cannot send a message because you have been blocked by this user.',
+			code: 'YOU_HAVE_BEEN_BLOCKED',
+			id: 'c15a5199-7422-4968-941a-2a462c478f7d',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		text: { type: 'string', nullable: true, maxLength: 2000 },
+		fileId: { type: 'string', format: 'misskey:id' },
+		toUserId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['toUserId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		@Inject(DI.driveFilesRepository)
+		private driveFilesRepository: DriveFilesRepository,
+
+		private getterService: GetterService,
+		private chatService: ChatService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			let file = null;
+			if (ps.fileId != null) {
+				file = await this.driveFilesRepository.findOneBy({
+					id: ps.fileId,
+					userId: me.id,
+				});
+
+				if (file == null) {
+					throw new ApiError(meta.errors.noSuchFile);
+				}
+			}
+
+			// テキストが無いかつ添付ファイルも無かったらエラー
+			if (ps.text == null && file == null) {
+				throw new ApiError(meta.errors.contentRequired);
+			}
+
+			// Myself
+			if (ps.toUserId === me.id) {
+				throw new ApiError(meta.errors.recipientIsYourself);
+			}
+
+			const toUser = await this.getterService.getUser(ps.toUserId).catch(err => {
+				if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+				throw err;
+			});
+
+			return await this.chatService.createMessageToUser(me, toUser, {
+				text: ps.text,
+				file: file,
+			});
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/delete.ts b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts
new file mode 100644
index 0000000000..959599ddcf
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts
@@ -0,0 +1,52 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'write:chat',
+
+	res: {
+	},
+
+	errors: {
+		noSuchMessage: {
+			message: 'No such message.',
+			code: 'NO_SUCH_MESSAGE',
+			id: '36b67f0e-66a6-414b-83df-992a55294f17',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		messageId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['messageId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatService: ChatService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const message = await this.chatService.findMyMessageById(me.id, ps.messageId);
+			if (message == null) {
+				throw new ApiError(meta.errors.noSuchMessage);
+			}
+			await this.chatService.deleteMessage(message);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/react.ts b/packages/backend/src/server/api/endpoints/chat/messages/react.ts
new file mode 100644
index 0000000000..561e36ed19
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/react.ts
@@ -0,0 +1,49 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'write:chat',
+
+	res: {
+	},
+
+	errors: {
+		noSuchMessage: {
+			message: 'No such message.',
+			code: 'NO_SUCH_MESSAGE',
+			id: '9b5839b9-0ba0-4351-8c35-37082093d200',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		messageId: { type: 'string', format: 'misskey:id' },
+		reaction: { type: 'string' },
+	},
+	required: ['messageId', 'reaction'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatService: ChatService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			await this.chatService.react(ps.messageId, me.id, ps.reaction);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts
new file mode 100644
index 0000000000..ccc0030403
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts
@@ -0,0 +1,73 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'read:chat',
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'ChatMessageLite',
+		},
+	},
+
+	errors: {
+		noSuchRoom: {
+			message: 'No such room.',
+			code: 'NO_SUCH_ROOM',
+			id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+		sinceId: { type: 'string', format: 'misskey:id' },
+		untilId: { type: 'string', format: 'misskey:id' },
+		roomId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatEntityService: ChatEntityService,
+		private chatService: ChatService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const room = await this.chatService.findRoomById(ps.roomId);
+			if (room == null) {
+				throw new ApiError(meta.errors.noSuchRoom);
+			}
+
+			if (!(await this.chatService.isRoomMember(room, me.id))) {
+				throw new ApiError(meta.errors.noSuchRoom);
+			}
+
+			const messages = await this.chatService.roomTimeline(room.id, ps.limit, ps.sinceId, ps.untilId);
+
+			this.chatService.readRoomChatMessage(me.id, room.id);
+
+			return await this.chatEntityService.packMessagesLiteForRoom(messages);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/search.ts b/packages/backend/src/server/api/endpoints/chat/messages/search.ts
new file mode 100644
index 0000000000..4c989e5ca9
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/search.ts
@@ -0,0 +1,76 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'read:chat',
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'ChatMessage',
+		},
+	},
+
+	errors: {
+		noSuchRoom: {
+			message: 'No such room.',
+			code: 'NO_SUCH_ROOM',
+			id: '460b3669-81b0-4dc9-a997-44442141bf83',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		query: { type: 'string', minLength: 1, maxLength: 256 },
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+		userId: { type: 'string', format: 'misskey:id', nullable: true },
+		roomId: { type: 'string', format: 'misskey:id', nullable: true },
+	},
+	required: ['query'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatEntityService: ChatEntityService,
+		private chatService: ChatService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			if (ps.roomId != null) {
+				const room = await this.chatService.findRoomById(ps.roomId);
+				if (room == null) {
+					throw new ApiError(meta.errors.noSuchRoom);
+				}
+
+				if (!(await this.chatService.isRoomMember(room, me.id))) {
+					throw new ApiError(meta.errors.noSuchRoom);
+				}
+			}
+
+			const messages = await this.chatService.searchMessages(me.id, ps.query, ps.limit, {
+				userId: ps.userId,
+				roomId: ps.roomId,
+			});
+
+			return await this.chatEntityService.packMessagesDetailed(messages, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/show.ts b/packages/backend/src/server/api/endpoints/chat/messages/show.ts
new file mode 100644
index 0000000000..371f7a7071
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/show.ts
@@ -0,0 +1,63 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+import { RoleService } from '@/core/RoleService.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'read:chat',
+
+	res: {
+		type: 'object',
+		optional: false, nullable: false,
+		ref: 'ChatMessage',
+	},
+
+	errors: {
+		noSuchMessage: {
+			message: 'No such message.',
+			code: 'NO_SUCH_MESSAGE',
+			id: '3710865b-1848-4da9-8d61-cfed15510b93',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		messageId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['messageId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatService: ChatService,
+		private roleService: RoleService,
+		private chatEntityService: ChatEntityService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const message = await this.chatService.findMessageById(ps.messageId);
+			if (message == null) {
+				throw new ApiError(meta.errors.noSuchMessage);
+			}
+			if (message.fromUserId !== me.id && message.toUserId !== me.id && !(await this.roleService.isModerator(me))) {
+				throw new ApiError(meta.errors.noSuchMessage);
+			}
+			return this.chatEntityService.packMessageDetailed(message, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts
new file mode 100644
index 0000000000..9d308d79b0
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts
@@ -0,0 +1,71 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'read:chat',
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'ChatMessageLite',
+		},
+	},
+
+	errors: {
+		noSuchUser: {
+			message: 'No such user.',
+			code: 'NO_SUCH_USER',
+			id: '11795c64-40ea-4198-b06e-3c873ed9039d',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+		sinceId: { type: 'string', format: 'misskey:id' },
+		untilId: { type: 'string', format: 'misskey:id' },
+		userId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['userId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatEntityService: ChatEntityService,
+		private chatService: ChatService,
+		private getterService: GetterService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const other = await this.getterService.getUser(ps.userId).catch(err => {
+				if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+				throw err;
+			});
+
+			const messages = await this.chatService.userTimeline(me.id, other.id, ps.limit, ps.sinceId, ps.untilId);
+
+			this.chatService.readUserChatMessage(me.id, other.id);
+
+			return await this.chatEntityService.packMessagesLiteFor1on1(messages);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts
new file mode 100644
index 0000000000..fa4cc8ceb4
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts
@@ -0,0 +1,62 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+	requiredRolePolicy: 'canChat',
+
+	prohibitMoved: true,
+
+	kind: 'write:chat',
+
+	limit: {
+		duration: ms('1day'),
+		max: 10,
+	},
+
+	res: {
+		type: 'object',
+		optional: false, nullable: false,
+		ref: 'ChatRoom',
+	},
+
+	errors: {
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		name: { type: 'string', maxLength: 256 },
+		description: { type: 'string', maxLength: 1024 },
+	},
+	required: ['name'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatService: ChatService,
+		private chatEntityService: ChatEntityService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const room = await this.chatService.createRoom(me, {
+				name: ps.name,
+				description: ps.description ?? '',
+			});
+			return await this.chatEntityService.packRoom(room);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts
new file mode 100644
index 0000000000..2ef0a778f1
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts
@@ -0,0 +1,52 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'write:chat',
+
+	res: {
+	},
+
+	errors: {
+		noSuchRoom: {
+			message: 'No such room.',
+			code: 'NO_SUCH_ROOM',
+			id: 'd4e3753d-97bf-4a19-ab8e-21080fbc0f4b',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		roomId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatService: ChatService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const room = await this.chatService.findMyRoomById(me.id, ps.roomId);
+			if (room == null) {
+				throw new ApiError(meta.errors.noSuchRoom);
+			}
+			await this.chatService.deleteRoom(room);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts
new file mode 100644
index 0000000000..5da4a1a772
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts
@@ -0,0 +1,68 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+	requiredRolePolicy: 'canChat',
+
+	prohibitMoved: true,
+
+	kind: 'write:chat',
+
+	limit: {
+		duration: ms('1day'),
+		max: 50,
+	},
+
+	res: {
+		type: 'object',
+		optional: false, nullable: false,
+		ref: 'ChatRoomInvitation',
+	},
+
+	errors: {
+		noSuchRoom: {
+			message: 'No such room.',
+			code: 'NO_SUCH_ROOM',
+			id: '916f9507-49ba-4e90-b57f-1fd4deaa47a5',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		roomId: { type: 'string', format: 'misskey:id' },
+		userId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['roomId', 'userId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatService: ChatService,
+		private chatEntityService: ChatEntityService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const room = await this.chatService.findMyRoomById(me.id, ps.roomId);
+			if (room == null) {
+				throw new ApiError(meta.errors.noSuchRoom);
+			}
+			const invitation = await this.chatService.createRoomInvitation(me.id, room.id, ps.userId);
+			return await this.chatEntityService.packRoomInvitation(invitation, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts
new file mode 100644
index 0000000000..8c017f7d01
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'write:chat',
+
+	res: {
+	},
+
+	errors: {
+		noSuchRoom: {
+			message: 'No such room.',
+			code: 'NO_SUCH_ROOM',
+			id: '5130557e-5a11-4cfb-9cc5-fe60cda5de0d',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		roomId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatService: ChatService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			await this.chatService.ignoreRoomInvitation(me.id, ps.roomId);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts
new file mode 100644
index 0000000000..07337480fc
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts
@@ -0,0 +1,54 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'read:chat',
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'ChatRoomInvitation',
+		},
+	},
+
+	errors: {
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+		sinceId: { type: 'string', format: 'misskey:id' },
+		untilId: { type: 'string', format: 'misskey:id' },
+	},
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatEntityService: ChatEntityService,
+		private chatService: ChatService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const invitations = await this.chatService.getReceivedRoomInvitationsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId);
+			return this.chatEntityService.packRoomInvitations(invitations, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/join.ts b/packages/backend/src/server/api/endpoints/chat/rooms/join.ts
new file mode 100644
index 0000000000..dbd4d1ea5a
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/join.ts
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'write:chat',
+
+	res: {
+	},
+
+	errors: {
+		noSuchRoom: {
+			message: 'No such room.',
+			code: 'NO_SUCH_ROOM',
+			id: '84416476-5ce8-4a2c-b568-9569f1b10733',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		roomId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatService: ChatService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			await this.chatService.joinToRoom(me.id, ps.roomId);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts
new file mode 100644
index 0000000000..c4c6253236
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts
@@ -0,0 +1,58 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'read:chat',
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'ChatRoomMembership',
+		},
+	},
+
+	errors: {
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+		sinceId: { type: 'string', format: 'misskey:id' },
+		untilId: { type: 'string', format: 'misskey:id' },
+	},
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatService: ChatService,
+		private chatEntityService: ChatEntityService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const memberships = await this.chatService.getMyMemberships(me.id, ps.limit, ps.sinceId, ps.untilId);
+
+			return this.chatEntityService.packRoomMemberships(memberships, me, {
+				populateUser: false,
+				populateRoom: true,
+			});
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts b/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts
new file mode 100644
index 0000000000..724ad61f7e
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'write:chat',
+
+	res: {
+	},
+
+	errors: {
+		noSuchRoom: {
+			message: 'No such room.',
+			code: 'NO_SUCH_ROOM',
+			id: 'cb7f3179-50e8-4389-8c30-dbe2650a67c9',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		roomId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatService: ChatService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			await this.chatService.leaveRoom(me.id, ps.roomId);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/members.ts b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts
new file mode 100644
index 0000000000..407bfe74f1
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts
@@ -0,0 +1,74 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'write:chat',
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'ChatRoomMembership',
+		},
+	},
+
+	errors: {
+		noSuchRoom: {
+			message: 'No such room.',
+			code: 'NO_SUCH_ROOM',
+			id: '7b9fe84c-eafc-4d21-bf89-485458ed2c18',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		roomId: { type: 'string', format: 'misskey:id' },
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+		sinceId: { type: 'string', format: 'misskey:id' },
+		untilId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatService: ChatService,
+		private chatEntityService: ChatEntityService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const room = await this.chatService.findRoomById(ps.roomId);
+			if (room == null) {
+				throw new ApiError(meta.errors.noSuchRoom);
+			}
+
+			if (!(await this.chatService.isRoomMember(room, me.id))) {
+				throw new ApiError(meta.errors.noSuchRoom);
+			}
+
+			const memberships = await this.chatService.getRoomMembershipsWithPagination(room.id, ps.limit, ps.sinceId, ps.untilId);
+
+			return this.chatEntityService.packRoomMemberships(memberships, me, {
+				populateUser: true,
+				populateRoom: false,
+			});
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts b/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts
new file mode 100644
index 0000000000..5208b8a253
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts
@@ -0,0 +1,49 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'write:chat',
+
+	res: {
+	},
+
+	errors: {
+		noSuchRoom: {
+			message: 'No such room.',
+			code: 'NO_SUCH_ROOM',
+			id: 'c2cde4eb-8d0f-42f1-8f2f-c4d6bfc8e5df',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		roomId: { type: 'string', format: 'misskey:id' },
+		mute: { type: 'boolean' },
+	},
+	required: ['roomId', 'mute'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatService: ChatService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			await this.chatService.muteRoom(me.id, ps.roomId, ps.mute);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts
new file mode 100644
index 0000000000..6516120bca
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts
@@ -0,0 +1,54 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'read:chat',
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+			ref: 'ChatRoom',
+		},
+	},
+
+	errors: {
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+		sinceId: { type: 'string', format: 'misskey:id' },
+		untilId: { type: 'string', format: 'misskey:id' },
+	},
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatEntityService: ChatEntityService,
+		private chatService: ChatService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const rooms = await this.chatService.getOwnedRoomsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId);
+			return this.chatEntityService.packRooms(rooms, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/show.ts b/packages/backend/src/server/api/endpoints/chat/rooms/show.ts
new file mode 100644
index 0000000000..547618ee7d
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/show.ts
@@ -0,0 +1,58 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'read:chat',
+
+	res: {
+		type: 'object',
+		optional: false, nullable: false,
+		ref: 'ChatRoom',
+	},
+
+	errors: {
+		noSuchRoom: {
+			message: 'No such room.',
+			code: 'NO_SUCH_ROOM',
+			id: '857ae02f-8759-4d20-9adb-6e95fffe4fd7',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		roomId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatService: ChatService,
+		private chatEntityService: ChatEntityService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const room = await this.chatService.findRoomById(ps.roomId);
+			if (room == null) {
+				throw new ApiError(meta.errors.noSuchRoom);
+			}
+
+			return this.chatEntityService.packRoom(room, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/update.ts b/packages/backend/src/server/api/endpoints/chat/rooms/update.ts
new file mode 100644
index 0000000000..6f2a9c10b5
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/update.ts
@@ -0,0 +1,65 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+
+export const meta = {
+	tags: ['chat'],
+
+	requireCredential: true,
+
+	kind: 'write:chat',
+
+	res: {
+		type: 'object',
+		optional: false, nullable: false,
+		ref: 'ChatRoom',
+	},
+
+	errors: {
+		noSuchRoom: {
+			message: 'No such room.',
+			code: 'NO_SUCH_ROOM',
+			id: 'fcdb0f92-bda6-47f9-bd05-343e0e020932',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		roomId: { type: 'string', format: 'misskey:id' },
+		name: { type: 'string', maxLength: 256 },
+		description: { type: 'string', maxLength: 1024 },
+	},
+	required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		private chatService: ChatService,
+		private chatEntityService: ChatEntityService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const room = await this.chatService.findMyRoomById(me.id, ps.roomId);
+			if (room == null) {
+				throw new ApiError(meta.errors.noSuchRoom);
+			}
+
+			const updated = await this.chatService.updateRoom(room, {
+				name: ps.name,
+				description: ps.description,
+			});
+
+			return this.chatEntityService.packRoom(updated, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts
index dc6ffd3e02..88d7f51c26 100644
--- a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts
+++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts
@@ -9,7 +9,6 @@ import { Inject, Injectable } from '@nestjs/common';
 import type { NotesRepository } from '@/models/_.js';
 import { obsoleteNotificationTypes, groupedNotificationTypes, FilterUnionByProperty } from '@/types.js';
 import { Endpoint } from '@/server/api/endpoint-base.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
 import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
 import { NotificationService } from '@/core/NotificationService.js';
 import { DI } from '@/di-symbols.js';
@@ -63,13 +62,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 		@Inject(DI.redis)
 		private redisClient: Redis.Redis,
 
-		@Inject(DI.notesRepository)
-		private notesRepository: NotesRepository,
-
 		private idService: IdService,
 		private notificationEntityService: NotificationEntityService,
 		private notificationService: NotificationService,
-		private noteReadService: NoteReadService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
 			const EXTRA_LIMIT = 100;
@@ -162,14 +157,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			}
 
 			groupedNotifications = groupedNotifications.slice(0, ps.limit);
-			const noteIds = groupedNotifications
-				.filter((notification): notification is FilterUnionByProperty<MiNotification, 'type', 'mention' | 'reply' | 'quote'> => ['mention', 'reply', 'quote'].includes(notification.type))
-				.map(notification => notification.noteId!);
-
-			if (noteIds.length > 0) {
-				const notes = await this.notesRepository.findBy({ id: In(noteIds) });
-				this.noteReadService.read(me.id, notes);
-			}
 
 			return await this.notificationEntityService.packGroupedMany(groupedNotifications, me.id);
 		});
diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts
index 2f619380e9..be8d0cfb34 100644
--- a/packages/backend/src/server/api/endpoints/i/notifications.ts
+++ b/packages/backend/src/server/api/endpoints/i/notifications.ts
@@ -9,7 +9,6 @@ import { Inject, Injectable } from '@nestjs/common';
 import type { NotesRepository } from '@/models/_.js';
 import { FilterUnionByProperty, notificationTypes, obsoleteNotificationTypes } from '@/types.js';
 import { Endpoint } from '@/server/api/endpoint-base.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
 import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
 import { NotificationService } from '@/core/NotificationService.js';
 import { DI } from '@/di-symbols.js';
@@ -69,7 +68,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 		private idService: IdService,
 		private notificationEntityService: NotificationEntityService,
 		private notificationService: NotificationService,
-		private noteReadService: NoteReadService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
 			// includeTypes が空の場合はクエリしない
@@ -136,15 +134,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				this.notificationService.readAllNotification(me.id);
 			}
 
-			const noteIds = notifications
-				.filter((notification): notification is FilterUnionByProperty<MiNotification, 'type', 'mention' | 'reply' | 'quote'> => ['mention', 'reply', 'quote'].includes(notification.type))
-				.map(notification => notification.noteId);
-
-			if (noteIds.length > 0) {
-				const notes = await this.notesRepository.findBy({ id: In(noteIds) });
-				this.noteReadService.read(me.id, notes);
-			}
-
 			return await this.notificationEntityService.packMany(notifications, me.id);
 		});
 	}
diff --git a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts
deleted file mode 100644
index d1a8eccb1d..0000000000
--- a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { Inject, Injectable } from '@nestjs/common';
-import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { NoteUnreadsRepository } from '@/models/_.js';
-import { GlobalEventService } from '@/core/GlobalEventService.js';
-import { DI } from '@/di-symbols.js';
-
-export const meta = {
-	tags: ['account'],
-
-	requireCredential: true,
-
-	kind: 'write:account',
-} as const;
-
-export const paramDef = {
-	type: 'object',
-	properties: {},
-	required: [],
-} as const;
-
-@Injectable()
-export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
-	constructor(
-		@Inject(DI.noteUnreadsRepository)
-		private noteUnreadsRepository: NoteUnreadsRepository,
-
-		private globalEventService: GlobalEventService,
-	) {
-		super(meta, paramDef, async (ps, me) => {
-			// Remove documents
-			await this.noteUnreadsRepository.delete({
-				userId: me.id,
-			});
-
-			// 全て既読になったイベントを発行
-			this.globalEventService.publishMainStream(me.id, 'readAllUnreadMentions');
-			this.globalEventService.publishMainStream(me.id, 'readAllUnreadSpecifiedNotes');
-		});
-	}
-}
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index 4c72879b73..baf397bf06 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -190,6 +190,7 @@ export const paramDef = {
 		autoSensitive: { type: 'boolean' },
 		followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
 		followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
+		chatScope: { type: 'string', enum: ['everyone', 'followers', 'following', 'mutual', 'none'] },
 		pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true },
 		mutedWords: muteWords,
 		hardMutedWords: muteWords,
@@ -288,6 +289,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
 			if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility;
 			if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility;
+			if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope;
 
 			function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) {
 				// TODO: ちゃんと数える
diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts
index 5558dd3a8b..18a3915ab5 100644
--- a/packages/backend/src/server/api/endpoints/notes/mentions.ts
+++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts
@@ -9,7 +9,6 @@ import type { NotesRepository, FollowingsRepository } from '@/models/_.js';
 import { Endpoint } from '@/server/api/endpoint-base.js';
 import { QueryService } from '@/core/QueryService.js';
 import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
 import { DI } from '@/di-symbols.js';
 
 export const meta = {
@@ -52,7 +51,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 
 		private noteEntityService: NoteEntityService,
 		private queryService: QueryService,
-		private noteReadService: NoteReadService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
 			const followingQuery = this.followingsRepository.createQueryBuilder('following')
@@ -89,8 +87,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 
 			const mentions = await query.limit(ps.limit).getMany();
 
-			this.noteReadService.read(me.id, mentions);
-
 			return await this.noteEntityService.packMany(mentions, me);
 		});
 	}
diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts
index 732d644a29..29c6aa7434 100644
--- a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts
@@ -9,7 +9,6 @@ import type { NotesRepository, NoteThreadMutingsRepository } from '@/models/_.js
 import { IdService } from '@/core/IdService.js';
 import { Endpoint } from '@/server/api/endpoint-base.js';
 import { GetterService } from '@/server/api/GetterService.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
 import { DI } from '@/di-symbols.js';
 import { ApiError } from '../../../error.js';
 
@@ -52,7 +51,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 		private noteThreadMutingsRepository: NoteThreadMutingsRepository,
 
 		private getterService: GetterService,
-		private noteReadService: NoteReadService,
 		private idService: IdService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
@@ -69,8 +67,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				}],
 			});
 
-			await this.noteReadService.read(me.id, mutedNotes);
-
 			await this.noteThreadMutingsRepository.insert({
 				id: this.idService.gen(),
 				threadId: note.threadId ?? note.id,
diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts
index 253409259f..c0ef589dea 100644
--- a/packages/backend/src/server/api/stream/ChannelsService.ts
+++ b/packages/backend/src/server/api/stream/ChannelsService.ts
@@ -19,6 +19,8 @@ import { AntennaChannelService } from './channels/antenna.js';
 import { DriveChannelService } from './channels/drive.js';
 import { HashtagChannelService } from './channels/hashtag.js';
 import { RoleTimelineChannelService } from './channels/role-timeline.js';
+import { ChatUserChannelService } from './channels/chat-user.js';
+import { ChatRoomChannelService } from './channels/chat-room.js';
 import { ReversiChannelService } from './channels/reversi.js';
 import { ReversiGameChannelService } from './channels/reversi-game.js';
 import { type MiChannelService } from './channel.js';
@@ -40,6 +42,8 @@ export class ChannelsService {
 		private serverStatsChannelService: ServerStatsChannelService,
 		private queueStatsChannelService: QueueStatsChannelService,
 		private adminChannelService: AdminChannelService,
+		private chatUserChannelService: ChatUserChannelService,
+		private chatRoomChannelService: ChatRoomChannelService,
 		private reversiChannelService: ReversiChannelService,
 		private reversiGameChannelService: ReversiGameChannelService,
 	) {
@@ -62,6 +66,8 @@ export class ChannelsService {
 			case 'serverStats': return this.serverStatsChannelService;
 			case 'queueStats': return this.queueStatsChannelService;
 			case 'admin': return this.adminChannelService;
+			case 'chatUser': return this.chatUserChannelService;
+			case 'chatRoom': return this.chatRoomChannelService;
 			case 'reversi': return this.reversiChannelService;
 			case 'reversiGame': return this.reversiGameChannelService;
 
diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts
index 0fb5238c78..c9801d8314 100644
--- a/packages/backend/src/server/api/stream/Connection.ts
+++ b/packages/backend/src/server/api/stream/Connection.ts
@@ -7,7 +7,6 @@ import * as WebSocket from 'ws';
 import type { MiUser } from '@/models/User.js';
 import type { MiAccessToken } from '@/models/AccessToken.js';
 import type { Packed } from '@/misc/json-schema.js';
-import type { NoteReadService } from '@/core/NoteReadService.js';
 import type { NotificationService } from '@/core/NotificationService.js';
 import { bindThis } from '@/decorators.js';
 import { CacheService } from '@/core/CacheService.js';
@@ -45,7 +44,6 @@ export default class Connection {
 
 	constructor(
 		private channelsService: ChannelsService,
-		private noteReadService: NoteReadService,
 		private notificationService: NotificationService,
 		private cacheService: CacheService,
 		private channelFollowingService: ChannelFollowingService,
@@ -119,7 +117,7 @@ export default class Connection {
 			case 'readNotification': this.onReadNotification(body); break;
 			case 'subNote': this.onSubscribeNote(body); break;
 			case 's': this.onSubscribeNote(body); break; // alias
-			case 'sr': this.onSubscribeNote(body); this.readNote(body); break;
+			case 'sr': this.onSubscribeNote(body); break;
 			case 'unsubNote': this.onUnsubscribeNote(body); break;
 			case 'un': this.onUnsubscribeNote(body); break; // alias
 			case 'connect': this.onChannelConnectRequested(body); break;
@@ -154,19 +152,6 @@ export default class Connection {
 		if (note.renote) add(note.renote);
 	}
 
-	@bindThis
-	private readNote(body: JsonValue | undefined) {
-		if (!isJsonObject(body)) return;
-		const id = body.id;
-
-		const note = this.cachedNotes.find(n => n.id === id);
-		if (note == null) return;
-
-		if (this.user && (note.userId !== this.user.id)) {
-			this.noteReadService.read(this.user.id, [note]);
-		}
-	}
-
 	@bindThis
 	private onReadNotification(payload: JsonValue | undefined) {
 		this.notificationService.readAllNotification(this.user!.id);
diff --git a/packages/backend/src/server/api/stream/channels/chat-room.ts b/packages/backend/src/server/api/stream/channels/chat-room.ts
new file mode 100644
index 0000000000..e989969345
--- /dev/null
+++ b/packages/backend/src/server/api/stream/channels/chat-room.ts
@@ -0,0 +1,78 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { bindThis } from '@/decorators.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
+import type { JsonObject } from '@/misc/json-value.js';
+import { ChatService } from '@/core/ChatService.js';
+import Channel, { type MiChannelService } from '../channel.js';
+
+class ChatRoomChannel extends Channel {
+	public readonly chName = 'chatRoom';
+	public static shouldShare = false;
+	public static requireCredential = true as const;
+	public static kind = 'read:chat';
+	private roomId: string;
+
+	constructor(
+		private chatService: ChatService,
+
+		id: string,
+		connection: Channel['connection'],
+	) {
+		super(id, connection);
+	}
+
+	@bindThis
+	public async init(params: JsonObject) {
+		if (typeof params.roomId !== 'string') return;
+		this.roomId = params.roomId;
+
+		this.subscriber.on(`chatRoomStream:${this.roomId}`, this.onEvent);
+	}
+
+	@bindThis
+	private async onEvent(data: GlobalEvents['chat']['payload']) {
+		this.send(data.type, data.body);
+	}
+
+	@bindThis
+	public onMessage(type: string, body: any) {
+		switch (type) {
+			case 'read':
+				if (this.roomId) {
+					this.chatService.readRoomChatMessage(this.user!.id, this.roomId);
+				}
+				break;
+		}
+	}
+
+	@bindThis
+	public dispose() {
+		this.subscriber.off(`chatRoomStream:${this.roomId}`, this.onEvent);
+	}
+}
+
+@Injectable()
+export class ChatRoomChannelService implements MiChannelService<true> {
+	public readonly shouldShare = ChatRoomChannel.shouldShare;
+	public readonly requireCredential = ChatRoomChannel.requireCredential;
+	public readonly kind = ChatRoomChannel.kind;
+
+	constructor(
+		private chatService: ChatService,
+	) {
+	}
+
+	@bindThis
+	public create(id: string, connection: Channel['connection']): ChatRoomChannel {
+		return new ChatRoomChannel(
+			this.chatService,
+			id,
+			connection,
+		);
+	}
+}
diff --git a/packages/backend/src/server/api/stream/channels/chat-user.ts b/packages/backend/src/server/api/stream/channels/chat-user.ts
new file mode 100644
index 0000000000..c4e898cd5b
--- /dev/null
+++ b/packages/backend/src/server/api/stream/channels/chat-user.ts
@@ -0,0 +1,78 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { bindThis } from '@/decorators.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
+import type { JsonObject } from '@/misc/json-value.js';
+import { ChatService } from '@/core/ChatService.js';
+import Channel, { type MiChannelService } from '../channel.js';
+
+class ChatUserChannel extends Channel {
+	public readonly chName = 'chatUser';
+	public static shouldShare = false;
+	public static requireCredential = true as const;
+	public static kind = 'read:chat';
+	private otherId: string;
+
+	constructor(
+		private chatService: ChatService,
+
+		id: string,
+		connection: Channel['connection'],
+	) {
+		super(id, connection);
+	}
+
+	@bindThis
+	public async init(params: JsonObject) {
+		if (typeof params.otherId !== 'string') return;
+		this.otherId = params.otherId;
+
+		this.subscriber.on(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent);
+	}
+
+	@bindThis
+	private async onEvent(data: GlobalEvents['chat']['payload']) {
+		this.send(data.type, data.body);
+	}
+
+	@bindThis
+	public onMessage(type: string, body: any) {
+		switch (type) {
+			case 'read':
+				if (this.otherId) {
+					this.chatService.readUserChatMessage(this.user!.id, this.otherId);
+				}
+				break;
+		}
+	}
+
+	@bindThis
+	public dispose() {
+		this.subscriber.off(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent);
+	}
+}
+
+@Injectable()
+export class ChatUserChannelService implements MiChannelService<true> {
+	public readonly shouldShare = ChatUserChannel.shouldShare;
+	public readonly requireCredential = ChatUserChannel.requireCredential;
+	public readonly kind = ChatUserChannel.kind;
+
+	constructor(
+		private chatService: ChatService,
+	) {
+	}
+
+	@bindThis
+	public create(id: string, connection: Channel['connection']): ChatUserChannel {
+		return new ChatUserChannel(
+			this.chatService,
+			id,
+			connection,
+		);
+	}
+}
diff --git a/packages/backend/test-federation/test/timeline.test.ts b/packages/backend/test-federation/test/timeline.test.ts
index 2250bf4a42..00635e654b 100644
--- a/packages/backend/test-federation/test/timeline.test.ts
+++ b/packages/backend/test-federation/test/timeline.test.ts
@@ -24,7 +24,7 @@ describe('Timeline', () => {
 	});
 
 	type TimelineChannel = keyof Misskey.Channels & (`${string}Timeline` | 'antenna' | 'userList' | 'hashtag');
-	type TimelineEndpoint = keyof Misskey.Endpoints & (`${string}timeline` | 'antennas/notes' | 'roles/notes' | 'notes/search-by-tag');
+	type TimelineEndpoint = keyof Misskey.Endpoints & (`notes/${string}timeline` | 'antennas/notes' | 'roles/notes' | 'notes/search-by-tag');
 	const timelineMap = new Map<TimelineChannel, TimelineEndpoint>([
 		['antenna', 'antennas/notes'],
 		['globalTimeline', 'notes/global-timeline'],
diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts
index f37da288b7..b464c24287 100644
--- a/packages/backend/test/e2e/mute.ts
+++ b/packages/backend/test/e2e/mute.ts
@@ -51,30 +51,8 @@ describe('Mute', () => {
 		assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
 	});
 
-	test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => {
-		// 状態リセット
-		await api('i/read-all-unread-notes', {}, alice);
-
-		await post(carol, { text: '@alice hi' });
-
-		const res = await api('i', {}, alice);
-
-		assert.strictEqual(res.status, 200);
-		assert.strictEqual(res.body.hasUnreadMentions, false);
-	});
-
-	test('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', async () => {
-		// 状態リセット
-		await api('i/read-all-unread-notes', {}, alice);
-
-		const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadMention');
-
-		assert.strictEqual(fired, false);
-	});
-
 	test('ミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてこない', async () => {
 		// 状態リセット
-		await api('i/read-all-unread-notes', {}, alice);
 		await api('notifications/mark-all-as-read', {}, alice);
 
 		const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadNotification');
diff --git a/packages/backend/test/e2e/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts
index 1ac99df884..1edc178fc2 100644
--- a/packages/backend/test/e2e/thread-mute.ts
+++ b/packages/backend/test/e2e/thread-mute.ts
@@ -38,48 +38,6 @@ describe('Note thread mute', () => {
 		assert.strictEqual(res.body.some(note => note.id === carolReplyWithoutMention.id), false);
 	});
 
-	test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => {
-		// 状態リセット
-		await api('i/read-all-unread-notes', {}, alice);
-
-		const bobNote = await post(bob, { text: '@alice @carol root note' });
-
-		await api('notes/thread-muting/create', { noteId: bobNote.id }, alice);
-
-		const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' });
-
-		const res = await api('i', {}, alice);
-
-		assert.strictEqual(res.status, 200);
-		assert.strictEqual(res.body.hasUnreadMentions, false);
-	});
-
-	test('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise<void>(async done => {
-		// 状態リセット
-		await api('i/read-all-unread-notes', {}, alice);
-
-		const bobNote = await post(bob, { text: '@alice @carol root note' });
-
-		await api('notes/thread-muting/create', { noteId: bobNote.id }, alice);
-
-		let fired = false;
-
-		const ws = await connectStream(alice, 'main', async ({ type, body }) => {
-			if (type === 'unreadMention') {
-				if (body === bobNote.id) return;
-				fired = true;
-			}
-		});
-
-		const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' });
-
-		setTimeout(() => {
-			assert.strictEqual(fired, false);
-			ws.close();
-			done();
-		}, 5000);
-	}));
-
 	test('i/notifications にミュートしているスレッドの通知が含まれない', async () => {
 		const bobNote = await post(bob, { text: '@alice @carol root note' });
 		const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' });
diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts
index 822ca14ae6..f82d4c6a30 100644
--- a/packages/backend/test/e2e/users.ts
+++ b/packages/backend/test/e2e/users.ts
@@ -15,7 +15,7 @@ describe('ユーザー', () => {
 	// エンティティとしてのユーザーを主眼においたテストを記述する
 	// (Userを返すエンドポイントとUserエンティティを書き換えるエンドポイントをテストする)
 
-	const stripUndefined = <T extends { [key: string]: any }, >(orig: T): Partial<T> => {
+	const stripUndefined = <T extends { [key: string]: any } >(orig: T): Partial<T> => {
 		return Object.entries({ ...orig })
 			.filter(([, value]) => value !== undefined)
 			.reduce((obj: Partial<T>, [key, value]) => {
@@ -83,6 +83,7 @@ describe('ユーザー', () => {
 			publicReactions: user.publicReactions,
 			followingVisibility: user.followingVisibility,
 			followersVisibility: user.followersVisibility,
+			chatScope: user.chatScope,
 			roles: user.roles,
 			memo: user.memo,
 		});
@@ -132,6 +133,7 @@ describe('ユーザー', () => {
 			hasUnreadAnnouncement: user.hasUnreadAnnouncement,
 			hasUnreadAntenna: user.hasUnreadAntenna,
 			hasUnreadChannel: user.hasUnreadChannel,
+			hasUnreadChatMessages: user.hasUnreadChatMessages,
 			hasUnreadNotification: user.hasUnreadNotification,
 			unreadNotificationsCount: user.unreadNotificationsCount,
 			hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest,
@@ -343,6 +345,7 @@ describe('ユーザー', () => {
 		assert.strictEqual(response.publicReactions, true);
 		assert.strictEqual(response.followingVisibility, 'public');
 		assert.strictEqual(response.followersVisibility, 'public');
+		assert.strictEqual(response.chatScope, 'mutual');
 		assert.deepStrictEqual(response.roles, []);
 		assert.strictEqual(response.memo, null);
 
@@ -369,6 +372,7 @@ describe('ユーザー', () => {
 		assert.strictEqual(response.hasUnreadAnnouncement, false);
 		assert.strictEqual(response.hasUnreadAntenna, false);
 		assert.strictEqual(response.hasUnreadChannel, false);
+		assert.strictEqual(response.hasUnreadChatMessages, false);
 		assert.strictEqual(response.hasUnreadNotification, false);
 		assert.strictEqual(response.unreadNotificationsCount, 0);
 		assert.strictEqual(response.hasPendingReceivedFollowRequest, false);
diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts
index e4f42809f8..6b7eedff55 100644
--- a/packages/backend/test/unit/entities/UserEntityService.ts
+++ b/packages/backend/test/unit/entities/UserEntityService.ts
@@ -50,6 +50,7 @@ import { AccountMoveService } from '@/core/AccountMoveService.js';
 import { ReactionService } from '@/core/ReactionService.js';
 import { NotificationService } from '@/core/NotificationService.js';
 import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
+import { ChatService } from '@/core/ChatService.js';
 
 process.env.NODE_ENV = 'test';
 
@@ -172,6 +173,7 @@ describe('UserEntityService', () => {
 				ReactionService,
 				ReactionsBufferingService,
 				NotificationService,
+				ChatService,
 			];
 
 			app = await Test.createTestingModule({
diff --git a/packages/frontend-embed/src/components/EmPagination.vue b/packages/frontend-embed/src/components/EmPagination.vue
index 5d5317a912..4cf156ba23 100644
--- a/packages/frontend-embed/src/components/EmPagination.vue
+++ b/packages/frontend-embed/src/components/EmPagination.vue
@@ -34,10 +34,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts">
-import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue';
+import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue';
 import * as Misskey from 'misskey-js';
 import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
-import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js';
+import { onScrollTop, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isTailVisible, isHeadVisible } from '@@/js/scroll.js';
+import type { ComputedRef } from 'vue';
 import { misskeyApi } from '@/misskey-api.js';
 import { i18n } from '@/i18n.js';
 
@@ -62,8 +63,6 @@ export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints>
 	reversed?: boolean;
 
 	offsetMode?: boolean;
-
-	pageEl?: HTMLElement;
 };
 
 type MisskeyEntity = {
@@ -135,8 +134,7 @@ const isBackTop = ref(false);
 const empty = computed(() => items.value.size === 0);
 const error = ref(false);
 
-const contentEl = computed(() => props.pagination.pageEl ?? rootEl.value);
-const scrollableElement = computed(() => contentEl.value ? getScrollContainer(contentEl.value) : document.body);
+const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : document.body);
 
 const visibility = useDocumentVisibility();
 
@@ -167,11 +165,11 @@ watch(rootEl, () => {
 	});
 });
 
-watch([backed, contentEl], () => {
+watch([backed, rootEl], () => {
 	if (!backed.value) {
-		if (!contentEl.value) return;
+		if (!rootEl.value) return;
 
-		scrollRemove.value = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl.value, executeQueue, TOLERANCE);
+		scrollRemove.value = (props.pagination.reversed ? onScrollBottom : onScrollTop)(rootEl.value, executeQueue, TOLERANCE);
 	} else {
 		if (scrollRemove.value) scrollRemove.value();
 		scrollRemove.value = null;
@@ -344,7 +342,7 @@ const appearFetchMoreAhead = async (): Promise<void> => {
 	fetchMoreAppearTimeout();
 };
 
-const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl.value!, TOLERANCE);
+const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isTailVisible : isHeadVisible)(rootEl.value!, TOLERANCE);
 
 watch(visibility, () => {
 	if (visibility.value === 'hidden') {
@@ -442,7 +440,7 @@ onDeactivated(() => {
 });
 
 function toBottom() {
-	scrollToBottom(contentEl.value!);
+	scrollToBottom(rootEl.value!);
 }
 
 onBeforeMount(() => {
diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts
index 9b821e650a..0241446784 100644
--- a/packages/frontend-shared/js/const.ts
+++ b/packages/frontend-shared/js/const.ts
@@ -107,6 +107,7 @@ export const ROLE_POLICIES = [
 	'canImportFollowing',
 	'canImportMuting',
 	'canImportUserLists',
+	'canChat',
 ] as const;
 
 export const DEFAULT_SERVER_ERROR_IMAGE_URL = 'https://xn--931a.moe/assets/error.jpg';
diff --git a/packages/frontend-shared/js/scroll.ts b/packages/frontend-shared/js/scroll.ts
index 508864b12c..6c61c582e1 100644
--- a/packages/frontend-shared/js/scroll.ts
+++ b/packages/frontend-shared/js/scroll.ts
@@ -38,7 +38,7 @@ export function getScrollPosition(el: HTMLElement | null): number {
 
 export function onScrollTop(el: HTMLElement, cb: (topVisible: boolean) => unknown, tolerance = 1, once = false) {
 	// とりあえず評価してみる
-	const firstTopVisible = isTopVisible(el);
+	const firstTopVisible = isHeadVisible(el);
 	if (el.isConnected && firstTopVisible) {
 		cb(firstTopVisible);
 		if (once) return null;
@@ -53,7 +53,7 @@ export function onScrollTop(el: HTMLElement, cb: (topVisible: boolean) => unknow
 	const onScroll = () => {
 		if (!document.body.contains(el)) return;
 
-		const topVisible = isTopVisible(el, tolerance);
+		const topVisible = isHeadVisible(el, tolerance);
 		if (topVisible !== prevTopVisible) {
 			prevTopVisible = topVisible;
 			cb(topVisible);
@@ -71,7 +71,7 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1
 	const container = getScrollContainer(el);
 
 	// とりあえず評価してみる
-	if (el.isConnected && isBottomVisible(el, tolerance, container)) {
+	if (el.isConnected && isTailVisible(el, tolerance, container)) {
 		cb();
 		if (once) return null;
 	}
@@ -79,7 +79,7 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1
 	const containerOrWindow = container ?? window;
 	const onScroll = () => {
 		if (!document.body.contains(el)) return;
-		if (isBottomVisible(el, 1, container)) {
+		if (isTailVisible(el, 1, container)) {
 			cb();
 			if (once) removeListener();
 		}
@@ -132,12 +132,12 @@ export function scrollToBottom(
 	}
 }
 
-export function isTopVisible(el: HTMLElement, tolerance = 1): boolean {
+export function isHeadVisible(el: HTMLElement, tolerance = 1): boolean {
 	const scrollTop = getScrollPosition(el);
 	return scrollTop <= tolerance;
 }
 
-export function isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) {
+export function isTailVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) {
 	if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance;
 	return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance;
 }
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
index 134490e317..19371dff0e 100644
--- a/packages/frontend/src/boot/main-boot.ts
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -502,31 +502,16 @@ export async function mainBoot() {
 			});
 		});
 
-		main.on('unreadMention', () => {
-			updateCurrentAccountPartial({ hasUnreadMentions: true });
-		});
-
-		main.on('readAllUnreadMentions', () => {
-			updateCurrentAccountPartial({ hasUnreadMentions: false });
-		});
-
-		main.on('unreadSpecifiedNote', () => {
-			updateCurrentAccountPartial({ hasUnreadSpecifiedNotes: true });
-		});
-
-		main.on('readAllUnreadSpecifiedNotes', () => {
-			updateCurrentAccountPartial({ hasUnreadSpecifiedNotes: false });
-		});
-
-		main.on('readAllAntennas', () => {
-			updateCurrentAccountPartial({ hasUnreadAntenna: false });
-		});
-
 		main.on('unreadAntenna', () => {
 			updateCurrentAccountPartial({ hasUnreadAntenna: true });
 			sound.playMisskeySfx('antenna');
 		});
 
+		main.on('newChatMessage', () => {
+			updateCurrentAccountPartial({ hasUnreadChatMessages: true });
+			sound.playMisskeySfx('chat');
+		});
+
 		main.on('readAllAnnouncements', () => {
 			updateCurrentAccountPartial({ hasUnreadAnnouncement: false });
 		});
diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue
index 5e89dfba12..891af7f696 100644
--- a/packages/frontend/src/components/MkButton.vue
+++ b/packages/frontend/src/components/MkButton.vue
@@ -7,11 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 <button
 	v-if="!link"
 	ref="el" class="_button"
-	:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly }]"
+	:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
 	:type="type"
 	:name="name"
 	:value="value"
-	:disabled="disabled"
+	:disabled="disabled || wait"
 	@click="emit('click', $event)"
 	@mousedown="onMousedown"
 >
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </button>
 <MkA
 	v-else class="_button"
-	:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly }]"
+	:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
 	:to="to ?? '#'"
 	:behavior="linkBehavior"
 	@mousedown="onMousedown"
@@ -256,6 +256,10 @@ function onMousedown(evt: MouseEvent): void {
 		opacity: 0.5;
 	}
 
+	&.wait {
+		cursor: wait !important;
+	}
+
 	&:focus-visible {
 		outline-offset: 2px;
 	}
diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue
index b5842876ac..ec6fcdc311 100644
--- a/packages/frontend/src/components/MkDateSeparatedList.vue
+++ b/packages/frontend/src/components/MkDateSeparatedList.vue
@@ -168,21 +168,17 @@ export default defineComponent({
 	container-type: inline-size;
 
 	&:global {
-	> .list-move {
-		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
-	}
-
-	&.deny-move-transition > .list-move {
-		transition: none !important;
-	}
+		> .list-move {
+			transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+		}
 
-	> .list-enter-active {
-		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
-	}
+		> .list-enter-active {
+			transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+		}
 
-	> *:empty {
-		display: none;
-	}
+		> *:empty {
+			display: none;
+		}
 	}
 
 	&:not(.date-separated-list-nogap) > *:not(:last-child) {
diff --git a/packages/frontend/src/components/MkFukidashi.vue b/packages/frontend/src/components/MkFukidashi.vue
index 8b1c56fca4..e9544afa35 100644
--- a/packages/frontend/src/components/MkFukidashi.vue
+++ b/packages/frontend/src/components/MkFukidashi.vue
@@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		tail === 'left' ? $style.left : $style.right,
 		negativeMargin === true && $style.negativeMargin,
 		shadow === true && $style.shadow,
+		accented === true && $style.accented
 	]"
 >
 	<div :class="$style.bg">
@@ -30,10 +31,12 @@ withDefaults(defineProps<{
 	tail?: 'left' | 'right' | 'none';
 	negativeMargin?: boolean;
 	shadow?: boolean;
+	accented?: boolean;
 }>(), {
 	tail: 'right',
 	negativeMargin: false,
 	shadow: false,
+	accented: false,
 });
 </script>
 
@@ -47,6 +50,10 @@ withDefaults(defineProps<{
 	min-height: calc(var(--fukidashi-radius) * 2);
 	padding-top: calc(var(--fukidashi-radius) * .13);
 
+	&.accented {
+		--fukidashi-bg: var(--MI_THEME-accent);
+	}
+
 	&.shadow {
 		filter: drop-shadow(0 4px 32px var(--MI_THEME-shadow));
 	}
@@ -77,7 +84,7 @@ withDefaults(defineProps<{
 
 .content {
 	position: relative;
-	padding: 8px 12px;
+	padding: 10px 14px;
 }
 
 .tail {
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index ae15776041..4a1100c324 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -227,7 +227,6 @@ defineExpose({
 .container {
 	position: relative;
 	width: 100%;
-	margin-top: 4px;
 }
 
 .medias {
diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue
index a84bd9b256..f2f36308ca 100644
--- a/packages/frontend/src/components/MkMenu.vue
+++ b/packages/frontend/src/components/MkMenu.vue
@@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		[$style.center]: align === 'center',
 		[$style.big]: big,
 		[$style.asDrawer]: asDrawer,
+		[$style.widthSpecified]: width != null,
 	}"
 	@focusin.passive.stop="() => {}"
 >
@@ -29,15 +30,19 @@ SPDX-License-Identifier: AGPL-3.0-only
 	>
 		<template v-for="item in (items2 ?? [])">
 			<div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div>
+
 			<span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item]">
 				<span style="opacity: 0.7;">{{ item.text }}</span>
 			</span>
+
 			<span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]">
 				<span><MkEllipsis/></span>
 			</span>
+
 			<div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1" :class="[$style.componentItem]">
 				<component :is="item.component" v-bind="item.props"/>
 			</div>
+
 			<MkA
 				v-else-if="item.type === 'link'"
 				role="menuitem"
@@ -51,10 +56,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
 				<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
 				<div :class="$style.item_content">
-					<span :class="$style.item_content_text">{{ item.text }}</span>
+					<div :class="$style.item_content_text">
+						<div :class="$style.item_content_text_title">{{ item.text }}</div>
+						<div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
+					</div>
 					<span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
 				</div>
 			</MkA>
+
 			<a
 				v-else-if="item.type === 'a'"
 				role="menuitem"
@@ -70,10 +79,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 			>
 				<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
 				<div :class="$style.item_content">
-					<span :class="$style.item_content_text">{{ item.text }}</span>
+					<div :class="$style.item_content_text">
+						<div :class="$style.item_content_text_title">{{ item.text }}</div>
+						<div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
+					</div>
 					<span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
 				</div>
 			</a>
+
 			<button
 				v-else-if="item.type === 'user'"
 				role="menuitem"
@@ -88,6 +101,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<span :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
 				</div>
 			</button>
+
 			<button
 				v-else-if="item.type === 'switch'"
 				role="menuitemcheckbox"
@@ -101,10 +115,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
 				<MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
 				<div :class="$style.item_content">
-					<span :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]">{{ item.text }}</span>
+					<div :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]">
+						<div :class="$style.item_content_text_title">{{ item.text }}</div>
+						<div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
+					</div>
 					<MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
 				</div>
 			</button>
+
 			<button
 				v-else-if="item.type === 'radio'"
 				role="menuitem"
@@ -117,10 +135,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 			>
 				<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
 				<div :class="$style.item_content">
-					<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
+					<div :class="$style.item_content_text" style="pointer-events: none;">
+						<div :class="$style.item_content_text_title">{{ item.text }}</div>
+						<div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
+					</div>
 					<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
 				</div>
 			</button>
+
 			<button
 				v-else-if="item.type === 'radioOption'"
 				role="menuitemradio"
@@ -134,9 +156,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<span :class="[$style.radioIcon, { [$style.radioChecked]: unref(item.active) }]"></span>
 				</div>
 				<div :class="$style.item_content">
-					<span :class="$style.item_content_text">{{ item.text }}</span>
+					<div :class="$style.item_content_text">
+						<div :class="$style.item_content_text_title">{{ item.text }}</div>
+						<div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
+					</div>
 				</div>
 			</button>
+
 			<button
 				v-else-if="item.type === 'parent'"
 				role="menuitem"
@@ -148,12 +174,17 @@ SPDX-License-Identifier: AGPL-3.0-only
 			>
 				<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
 				<div :class="$style.item_content">
-					<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
+					<div :class="$style.item_content_text" style="pointer-events: none;">
+						<div :class="$style.item_content_text_title">{{ item.text }}</div>
+						<div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
+					</div>
 					<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
 				</div>
 			</button>
+
 			<button
-				v-else role="menuitem"
+				v-else
+				role="menuitem"
 				tabindex="0"
 				:class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }]"
 				@click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)"
@@ -163,11 +194,15 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
 				<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
 				<div :class="$style.item_content">
-					<span :class="$style.item_content_text">{{ item.text }}</span>
+					<div :class="$style.item_content_text">
+						<div :class="$style.item_content_text_title">{{ item.text }}</div>
+						<div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
+					</div>
 					<span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
 				</div>
 			</button>
 		</template>
+
 		<span v-if="items2 == null || items2.length === 0" tabindex="-1" :class="[$style.none, $style.item]">
 			<span>{{ i18n.ts.none }}</span>
 		</span>
@@ -438,6 +473,12 @@ onBeforeUnmount(() => {
 		}
 	}
 
+	&:not(.widthSpecified) {
+		> .menu {
+			max-width: 400px;
+		}
+	}
+
 	&.big:not(.asDrawer) {
 		> .menu {
 			min-width: 230px;
@@ -607,10 +648,19 @@ onBeforeUnmount(() => {
 
 .item_content_text {
 	max-width: calc(100vw - 4rem);
+}
+
+.item_content_text_title {
 	text-overflow: ellipsis;
 	overflow: hidden;
 }
 
+.item_content_text_caption {
+	text-wrap: auto;
+	font-size: 85%;
+	opacity: 0.7;
+}
+
 .switchButton {
 	margin-left: -2px;
 	--height: 1.35em;
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index ab8bda403b..a729619180 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -24,16 +24,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</slot>
 	</div>
 
-	<div v-else ref="rootEl">
-		<div v-show="pagination.reversed && more" key="_more_" class="_margin">
-			<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead">
+	<div v-else ref="rootEl" class="_gaps">
+		<div v-show="pagination.reversed && more" key="_more_">
+			<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMoreAhead">
 				{{ i18n.ts.loadMore }}
 			</MkButton>
 			<MkLoading v-else class="loading"/>
 		</div>
 		<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
-		<div v-show="!pagination.reversed && more" key="_more_" class="_margin">
-			<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
+		<div v-show="!pagination.reversed && more" key="_more_">
+			<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">
 				{{ i18n.ts.loadMore }}
 			</MkButton>
 			<MkLoading v-else class="loading"/>
@@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, useTemplateRef, watch } from 'vue';
 import * as Misskey from 'misskey-js';
 import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
-import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js';
+import { onScrollTop, isHeadVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isTailVisible } from '@@/js/scroll.js';
 import type { ComputedRef } from 'vue';
 import type { MisskeyEntity } from '@/types/date-separated-list.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
@@ -74,8 +74,6 @@ export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints>
 	reversed?: boolean;
 
 	offsetMode?: boolean;
-
-	pageEl?: HTMLElement;
 };
 
 type MisskeyEntityMap = Map<string, MisskeyEntity>;
@@ -141,8 +139,7 @@ const {
 	enableInfiniteScroll,
 } = prefer.r;
 
-const contentEl = computed(() => props.pagination.pageEl ?? rootEl.value);
-const scrollableElement = computed(() => contentEl.value ? getScrollContainer(contentEl.value) : window.document.body);
+const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : window.document.body);
 
 const visibility = useDocumentVisibility();
 
@@ -173,13 +170,13 @@ watch(rootEl, () => {
 	});
 });
 
-watch([backed, contentEl], () => {
+watch([backed, rootEl], () => {
 	if (!backed.value) {
-		if (!contentEl.value) return;
+		if (!rootEl.value) return;
 
 		scrollRemove.value = props.pagination.reversed
-			? onScrollBottom(contentEl.value, executeQueue, TOLERANCE)
-			: onScrollTop(contentEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE);
+			? onScrollBottom(rootEl.value, executeQueue, TOLERANCE)
+			: onScrollTop(rootEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE);
 	} else {
 		if (scrollRemove.value) scrollRemove.value();
 		scrollRemove.value = null;
@@ -349,7 +346,7 @@ const appearFetchMoreAhead = async (): Promise<void> => {
 	fetchMoreAppearTimeout();
 };
 
-const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl.value!, TOLERANCE);
+const isHead = (): boolean => isBackTop.value || (props.pagination.reversed ? isTailVisible : isHeadVisible)(rootEl.value!, TOLERANCE);
 
 watch(visibility, () => {
 	if (visibility.value === 'hidden') {
@@ -364,7 +361,7 @@ watch(visibility, () => {
 			timerForSetPause = null;
 		} else {
 			isPausingUpdate = false;
-			if (isTop()) {
+			if (isHead()) {
 				executeQueue();
 			}
 		}
@@ -376,16 +373,18 @@ watch(visibility, () => {
  * ストリーミングから降ってきたアイテムはこれで追加する
  * @param item アイテム
  */
-const prepend = (item: MisskeyEntity): void => {
+function prepend(item: MisskeyEntity): void {
 	if (items.value.size === 0) {
 		items.value.set(item.id, item);
 		fetching.value = false;
 		return;
 	}
 
-	if (isTop() && !isPausingUpdate) unshiftItems([item]);
+	console.log(isHead(), isPausingUpdate);
+
+	if (isHead() && !isPausingUpdate) unshiftItems([item]);
 	else prependQueue(item);
-};
+}
 
 /**
  * 新着アイテムをitemsの先頭に追加し、displayLimitを適用する
@@ -447,7 +446,7 @@ onDeactivated(() => {
 });
 
 function toBottom() {
-	scrollToBottom(contentEl.value!);
+	scrollToBottom(rootEl.value!);
 }
 
 onBeforeMount(() => {
diff --git a/packages/frontend/src/components/MkPolkadots.vue b/packages/frontend/src/components/MkPolkadots.vue
new file mode 100644
index 0000000000..285c4d0b79
--- /dev/null
+++ b/packages/frontend/src/components/MkPolkadots.vue
@@ -0,0 +1,40 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="[$style.root, accented ? $style.accented : null]"></div>
+</template>
+
+<script lang="ts" setup>
+const props = withDefaults(defineProps<{
+	accented?: boolean;
+}>(), {
+	accented: false,
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	--c: var(--MI_THEME-divider);
+
+	&.accented {
+		--c: var(--MI_THEME-accent);
+		opacity: 0.5;
+	}
+
+	--dot-size: 2px;
+	--gap-size: 40px;
+	--offset: calc(var(--gap-size) / 2);
+
+	height: 200px;
+	margin-bottom: -200px;
+
+	background-image: linear-gradient(transparent 60%, transparent 100%), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size));
+	background-position: 0 0, 0 0, var(--offset) var(--offset);
+	background-size: 100% 100%, var(--gap-size) var(--gap-size), var(--gap-size) var(--gap-size);
+	mask-image: linear-gradient(to bottom, black 0%, transparent 100%);
+	pointer-events: none;
+}
+</style>
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index f20aee0ce3..20dab6f028 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -246,6 +246,7 @@ onUnmounted(() => {
 	box-shadow: 0 0 0 1px var(--MI_THEME-divider);
 	border-radius: 8px;
 	overflow: clip;
+	text-align: left;
 
 	&:hover {
 		text-decoration: none;
diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts
index 099339fbee..f6d6bbf0fb 100644
--- a/packages/frontend/src/local-storage.ts
+++ b/packages/frontend/src/local-storage.ts
@@ -28,7 +28,7 @@ export type Keys = (
 	'theme' |
 	'themeId' |
 	'customCss' |
-	'message_drafts' |
+	'chatMessageDrafts' |
 	'scratchpad' |
 	'debug' |
 	'preferences' |
diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts
index d478ece641..894df83721 100644
--- a/packages/frontend/src/navbar.ts
+++ b/packages/frontend/src/navbar.ts
@@ -4,6 +4,7 @@
  */
 
 import { computed, reactive } from 'vue';
+import { ui } from '@@/js/config.js';
 import { clearCache } from './utility/clear-cache.js';
 import { $i } from '@/i.js';
 import { miLocalStorage } from '@/local-storage.js';
@@ -11,7 +12,6 @@ import { openInstanceMenu, openToolsMenu } from '@/ui/_common_/common.js';
 import { lookup } from '@/utility/lookup.js';
 import * as os from '@/os.js';
 import { i18n } from '@/i18n.js';
-import { ui } from '@@/js/config.js';
 import { unisonReload } from '@/utility/unison-reload.js';
 
 export const navbarItemDef = reactive({
@@ -110,6 +110,12 @@ export const navbarItemDef = reactive({
 		icon: 'ti ti-device-tv',
 		to: '/channels',
 	},
+	chat: {
+		title: i18n.ts.chat,
+		icon: 'ti ti-message',
+		to: '/chat',
+		indicated: computed(() => $i != null && $i.hasUnreadChatMessages),
+	},
 	achievements: {
 		title: i18n.ts.achievements,
 		icon: 'ti ti-medal',
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 4e9f4edb70..d1e823215a 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -160,6 +160,26 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</div>
 			</MkFolder>
 
+			<MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])">
+				<template #label>{{ i18n.ts._role._options.canChat }}</template>
+				<template #suffix>
+					<span v-if="role.policies.canChat.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+					<span v-else>{{ role.policies.canChat.value ? i18n.ts.yes : i18n.ts.no }}</span>
+					<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canChat)"></i></span>
+				</template>
+				<div class="_gaps">
+					<MkSwitch v-model="role.policies.canChat.useDefault" :readonly="readonly">
+						<template #label>{{ i18n.ts._role.useBaseValue }}</template>
+					</MkSwitch>
+					<MkSwitch v-model="role.policies.canChat.value" :disabled="role.policies.canChat.useDefault" :readonly="readonly">
+						<template #label>{{ i18n.ts.enable }}</template>
+					</MkSwitch>
+					<MkRange v-model="role.policies.canChat.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.mentionMax, 'mentionLimit'])">
 				<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
 				<template #suffix>
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 0428352350..df4efd1271 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -51,6 +51,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 							</MkSwitch>
 						</MkFolder>
 
+						<MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])">
+							<template #label>{{ i18n.ts._role._options.canChat }}</template>
+							<template #suffix>{{ policies.canChat ? i18n.ts.yes : i18n.ts.no }}</template>
+							<MkSwitch v-model="policies.canChat">
+								<template #label>{{ i18n.ts.enable }}</template>
+							</MkSwitch>
+						</MkFolder>
+
 						<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
 							<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
 							<template #suffix>{{ policies.mentionLimit }}</template>
diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue
new file mode 100644
index 0000000000..1e7f8e20ea
--- /dev/null
+++ b/packages/frontend/src/pages/chat/XMessage.vue
@@ -0,0 +1,245 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="[$style.root, { [$style.isMe]: isMe }]">
+	<MkAvatar :class="$style.avatar" :user="message.fromUser" :link="!isMe" :preview="false"/>
+	<div :class="$style.body">
+		<MkFukidashi :class="$style.fukidashi" :tail="isMe ? 'right' : 'left'" :accented="isMe">
+			<div v-if="!message.isDeleted" :class="$style.content">
+				<Mfm v-if="message.text" ref="text" class="_selectable" :text="message.text" :i="$i"/>
+				<MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/>
+			</div>
+			<div v-else :class="$style.content">
+				<p>{{ i18n.ts.deleted }}</p>
+			</div>
+		</MkFukidashi>
+		<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
+		<div :class="$style.footer">
+			<button class="_textButton" style="color: currentColor;" @click="showMenu"><i class="ti ti-dots-circle-horizontal"></i></button>
+			<MkTime :class="$style.time" :time="message.createdAt"/>
+			<MkA v-if="isSearchResult && message.toRoomId" :to="`/chat/room/${message.toRoomId}`">{{ message.toRoom.name }}</MkA>
+			<MkA v-if="isSearchResult && message.toUserId && isMe" :to="`/chat/user/${message.toUserId}`">@{{ message.toUser.username }}</MkA>
+		</div>
+		<TransitionGroup
+			:enterActiveClass="prefer.s.animation ? $style.transition_reaction_enterActive : ''"
+			:leaveActiveClass="prefer.s.animation ? $style.transition_reaction_leaveActive : ''"
+			:enterFromClass="prefer.s.animation ? $style.transition_reaction_enterFrom : ''"
+			:leaveToClass="prefer.s.animation ? $style.transition_reaction_leaveTo : ''"
+			:moveClass="prefer.s.animation ? $style.transition_reaction_move : ''"
+			tag="div" :class="$style.reactions"
+		>
+			<div v-for="record in message.reactions" :key="record.reaction + record.user.id" :class="$style.reaction">
+				<MkAvatar :user="record.user" :link="false" :class="$style.reactionAvatar"/>
+				<MkReactionIcon
+					:withTooltip="true"
+					:reaction="record.reaction.replace(/^:(\w+):$/, ':$1@.:')"
+					:noStyle="true"
+					:class="$style.reactionIcon"
+				/>
+			</div>
+		</TransitionGroup>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent } from 'vue';
+import * as mfm from 'mfm-js';
+import * as Misskey from 'misskey-js';
+import { url } from '@@/js/config.js';
+import type { MenuItem } from '@/types/menu.js';
+import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
+import MkUrlPreview from '@/components/MkUrlPreview.vue';
+import { ensureSignin } from '@/i.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { i18n } from '@/i18n.js';
+import MkFukidashi from '@/components/MkFukidashi.vue';
+import * as os from '@/os.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import MkMediaList from '@/components/MkMediaList.vue';
+import { reactionPicker } from '@/utility/reaction-picker.js';
+import * as sound from '@/utility/sound.js';
+import MkReactionIcon from '@/components/MkReactionIcon.vue';
+import { prefer } from '@/preferences.js';
+
+const $i = ensureSignin();
+
+const props = defineProps<{
+	message: Misskey.entities.ChatMessageLite | Misskey.entities.ChatMessage;
+	isSearchResult?: boolean;
+}>();
+
+const isMe = computed(() => props.message.fromUserId === $i.id);
+const urls = computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
+
+function react(ev: MouseEvent) {
+	reactionPicker.show(ev.currentTarget ?? ev.target, null, async (reaction) => {
+		sound.playMisskeySfx('reaction');
+
+		misskeyApi('chat/messages/react', {
+			messageId: props.message.id,
+			reaction: reaction,
+		});
+	});
+}
+
+function showMenu(ev: MouseEvent) {
+	const menu: MenuItem[] = [];
+
+	if (!isMe.value) {
+		menu.push({
+			text: i18n.ts.reaction,
+			icon: 'ti ti-mood-plus',
+			action: (ev) => {
+				react(ev);
+			},
+		});
+
+		menu.push({
+			type: 'divider',
+		});
+	}
+
+	menu.push({
+		text: i18n.ts.copyContent,
+		icon: 'ti ti-copy',
+		action: () => {
+			copyToClipboard(props.message.text);
+		},
+	});
+
+	menu.push({
+		type: 'divider',
+	});
+
+	if (isMe.value) {
+		menu.push({
+			text: i18n.ts.delete,
+			icon: 'ti ti-trash',
+			danger: true,
+			action: () => {
+				misskeyApi('chat/messages/delete', {
+					messageId: props.message.id,
+				});
+			},
+		});
+	} else {
+		menu.push({
+			text: i18n.ts.reportAbuse,
+			icon: 'ti ti-exclamation-circle',
+			action: () => {
+				const localUrl = `${url}/chat/messages/${props.message.id}`;
+				const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
+					user: props.message.fromUser,
+					initialComment: `${localUrl}\n-----\n`,
+				}, {
+					closed: () => dispose(),
+				});
+			},
+		});
+	}
+
+	os.popupMenu(menu, ev.currentTarget ?? ev.target);
+}
+</script>
+
+<style lang="scss" module>
+.transition_reaction_move,
+.transition_reaction_enterActive,
+.transition_reaction_leaveActive {
+	transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important;
+}
+.transition_reaction_enterFrom,
+.transition_reaction_leaveTo {
+	opacity: 0;
+	transform: scale(0.7);
+}
+.transition_reaction_leaveActive {
+	position: absolute;
+}
+
+.root {
+	position: relative;
+	display: flex;
+
+	&.isMe {
+		flex-direction: row-reverse;
+		text-align: right;
+
+		.content {
+			color: var(--MI_THEME-fgOnAccent);
+		}
+
+		.footer {
+			flex-direction: row-reverse;
+		}
+	}
+}
+
+.avatar {
+	position: sticky;
+	top: calc(16px + var(--MI-stickyTop, 0px));
+	display: block;
+	width: 52px;
+	height: 52px;
+}
+
+.body {
+	margin: 0 12px;
+}
+
+.content {
+	overflow: clip;
+	overflow-wrap: break-word;
+	word-break: break-word;
+}
+
+.file {
+}
+
+.footer {
+	display: flex;
+	flex-direction: row;
+	gap: 0.5em;
+	margin-top: 4px;
+	font-size: 75%;
+}
+
+.time {
+	opacity: 0.5;
+}
+
+.reactions {
+	display: flex;
+	flex-wrap: wrap;
+	align-items: center;
+	gap: 8px;
+	margin-top: 8px;
+
+	&:empty {
+		display: none;
+	}
+}
+
+.reaction {
+	display: flex;
+	align-items: center;
+	border: solid 1px var(--MI_THEME-divider);
+	border-radius: 999px;
+	padding: 8px;
+}
+
+.reactionAvatar {
+	width: 24px;
+	height: 24px;
+	margin-right: 8px;
+}
+
+.reactionIcon {
+	width: 24px;
+	height: 24px;
+}
+</style>
diff --git a/packages/frontend/src/pages/chat/XRoom.vue b/packages/frontend/src/pages/chat/XRoom.vue
new file mode 100644
index 0000000000..b063a0cdd1
--- /dev/null
+++ b/packages/frontend/src/pages/chat/XRoom.vue
@@ -0,0 +1,41 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkA :to="`/chat/room/${room.id}`" class="_panel _gaps_s" :class="$style.root">
+	<div :class="$style.header">
+		<div style="font-weight: bold;">{{ room.name }}</div>
+		<MkAvatar :user="room.owner" :link="false" :class="$style.headerAvatar"/>
+	</div>
+	<hr>
+	<div>{{ room.description }}</div>
+</MkA>
+</template>
+
+<script lang="ts" setup>
+import * as Misskey from 'misskey-js';
+
+const props = defineProps<{
+	room: Misskey.entities.ChatRoom;
+}>();
+
+</script>
+
+<style lang="scss" module>
+.root {
+	padding: 16px;
+}
+
+.header {
+	display: flex;
+	align-items: center;
+}
+
+.headerAvatar {
+	width: 30px;
+	height: 30px;
+	margin-left: auto;
+}
+</style>
diff --git a/packages/frontend/src/pages/chat/home.home.vue b/packages/frontend/src/pages/chat/home.home.vue
new file mode 100644
index 0000000000..1d0605136c
--- /dev/null
+++ b/packages/frontend/src/pages/chat/home.home.vue
@@ -0,0 +1,252 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+	<MkButton primary gradate rounded :class="$style.start" @click="start"><i class="ti ti-plus"></i> {{ i18n.ts.startChat }}</MkButton>
+
+	<MkAd :prefer="['horizontal', 'horizontal-big']"/>
+
+	<MkInput
+		v-model="searchQuery"
+		:placeholder="i18n.ts._chat.searchMessages"
+		type="search"
+	>
+		<template #prefix><i class="ti ti-search"></i></template>
+	</MkInput>
+
+	<MkButton v-if="searchQuery.length > 0" primary rounded @click="search">{{ i18n.ts.search }}</MkButton>
+
+	<MkFoldableSection v-if="searched">
+		<template #header>{{ i18n.ts.searchResult }}</template>
+
+		<div class="_gaps_s">
+			<div v-for="message in searchResults" :key="message.id" :class="$style.searchResultItem">
+				<XMessage :message="message" :isSearchResult="true"/>
+			</div>
+		</div>
+	</MkFoldableSection>
+
+	<MkFoldableSection>
+		<template #header>{{ i18n.ts._chat.history }}</template>
+
+		<div v-if="history.length > 0" class="_gaps_s">
+			<MkA
+				v-for="item in history"
+				:key="item.id"
+				:class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]"
+				class="_panel"
+				:to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`"
+			>
+				<MkAvatar v-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/>
+				<div :class="$style.messageBody">
+					<header v-if="item.message.toRoom" :class="$style.messageHeader">
+						<span :class="$style.messageHeaderName">{{ item.message.toRoom.name }}</span>
+						<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
+					</header>
+					<header v-else :class="$style.messageHeader">
+						<MkUserName :class="$style.messageHeaderName" :user="item.other!"/>
+						<MkAcct :class="$style.messageHeaderUsername" :user="item.other!"/>
+						<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
+					</header>
+					<div :class="$style.messageBodyText"><span v-if="item.isMe" :class="$style.youSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</div>
+				</div>
+			</MkA>
+		</div>
+		<div v-if="!fetching && history.length == 0" class="_fullinfo">
+			<div>{{ i18n.ts._chat.noHistory }}</div>
+		</div>
+		<MkLoading v-if="fetching"/>
+	</MkFoldableSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import XMessage from './XMessage.vue';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { ensureSignin } from '@/i.js';
+import { useRouter } from '@/router.js';
+import * as os from '@/os.js';
+import { updateCurrentAccountPartial } from '@/accounts.js';
+import MkInput from '@/components/MkInput.vue';
+import MkFoldableSection from '@/components/MkFoldableSection.vue';
+
+const $i = ensureSignin();
+
+const router = useRouter();
+
+const fetching = ref(true);
+const history = ref<{
+	id: string;
+	message: Misskey.entities.ChatMessage;
+	other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null;
+	isMe: boolean;
+}[]>([]);
+
+const searchQuery = ref('');
+const searched = ref(false);
+const searchResults = ref<Misskey.entities.ChatMessage[]>([]);
+
+function start(ev: MouseEvent) {
+	os.popupMenu([{
+		text: i18n.ts._chat.individualChat,
+		caption: i18n.ts._chat.individualChat_description,
+		icon: 'ti ti-user',
+		action: () => { startUser(); },
+	}, { type: 'divider' }, {
+		type: 'parent',
+		text: i18n.ts._chat.roomChat,
+		caption: i18n.ts._chat.roomChat_description,
+		icon: 'ti ti-users-group',
+		children: [{
+			text: i18n.ts._chat.createRoom,
+			icon: 'ti ti-plus',
+			action: () => { createRoom(); },
+		}],
+	}], ev.currentTarget ?? ev.target);
+}
+
+async function startUser() {
+	os.selectUser().then(user => {
+		router.push(`/chat/user/${user.id}`);
+	});
+}
+
+async function createRoom() {
+	const { canceled, result } = await os.inputText({
+		title: i18n.ts.name,
+		minLength: 1,
+	});
+	if (canceled) return;
+
+	const room = await misskeyApi('chat/rooms/create', {
+		name: result,
+	});
+
+	router.push(`/chat/room/${room.id}`);
+}
+
+async function search() {
+	const res = await misskeyApi('chat/messages/search', {
+		query: searchQuery.value,
+	});
+
+	searchResults.value = res;
+	searched.value = true;
+}
+
+async function fetchHistory() {
+	fetching.value = true;
+
+	const [userMessages, roomMessages] = await Promise.all([
+		misskeyApi('chat/history', { room: false }),
+		misskeyApi('chat/history', { room: true }),
+	]);
+
+	history.value = [...userMessages, ...roomMessages]
+		.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
+		.map(m => ({
+			id: m.id,
+			message: m,
+			other: m.room == null ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null,
+			isMe: m.fromUserId === $i.id,
+		}));
+
+	fetching.value = false;
+
+	updateCurrentAccountPartial({ hasUnreadChatMessages: false });
+}
+
+onMounted(() => {
+	fetchHistory();
+});
+</script>
+
+<style lang="scss" module>
+.start {
+	margin: 0 auto;
+}
+
+.message {
+	position: relative;
+	display: flex;
+	padding: 16px 24px;
+
+	&.isRead,
+	&.isMe {
+		opacity: 0.8;
+	}
+
+	&:not(.isMe):not(.isRead) {
+		&::before {
+			content: '';
+			position: absolute;
+			top: 8px;
+			right: 8px;
+			width: 8px;
+			height: 8px;
+			border-radius: 100%;
+			background-color: var(--MI_THEME-accent);
+		}
+	}
+}
+
+.messageAvatar {
+	width: 50px;
+	height: 50px;
+	margin: 0 16px 0 0;
+}
+
+.messageBody {
+	flex: 1;
+	min-width: 0;
+}
+
+.messageHeader {
+	display: flex;
+	align-items: center;
+	margin-bottom: 2px;
+	white-space: nowrap;
+	overflow: clip;
+}
+
+.messageHeaderName {
+	margin: 0;
+	padding: 0;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	font-size: 1em;
+	font-weight: bold;
+}
+
+.messageHeaderUsername {
+	margin: 0 8px;
+}
+
+.messageHeaderTime {
+	margin-left: auto;
+}
+
+.messageBodyText {
+	overflow: hidden;
+	overflow-wrap: break-word;
+	font-size: 1.1em;
+}
+
+.youSaid {
+	font-weight: bold;
+	margin-right: 0.5em;
+}
+
+.searchResultItem {
+	padding: 12px;
+	border: solid 1px var(--MI_THEME-divider);
+	border-radius: 12px;
+}
+</style>
diff --git a/packages/frontend/src/pages/chat/home.invitations.vue b/packages/frontend/src/pages/chat/home.invitations.vue
new file mode 100644
index 0000000000..4c3c0b282e
--- /dev/null
+++ b/packages/frontend/src/pages/chat/home.invitations.vue
@@ -0,0 +1,98 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+	<div v-if="invitations.length > 0" class="_gaps_s">
+		<MkFolder v-for="invitation in invitations" :key="invitation.id" :defaultOpen="true">
+			<template #icon><i class="ti ti-users-group"></i></template>
+			<template #label>{{ invitation.room.name }}</template>
+			<template #suffix><MkTime :time="invitation.createdAt"/></template>
+			<template #footer>
+				<div class="_buttons">
+					<MkButton primary @click="join(invitation)"><i class="ti ti-plus"></i> {{ i18n.ts._chat.join }}</MkButton>
+					<MkButton danger @click="ignore(invitation)"><i class="ti ti-x"></i> {{ i18n.ts._chat.ignore }}</MkButton>
+				</div>
+			</template>
+
+			<div :class="$style.invitationBody">
+				<MkAvatar :user="invitation.room.owner" :class="$style.invitationBodyAvatar" link/>
+				<div style="flex: 1;" class="_gaps_s">
+					<MkUserName :user="invitation.room.owner"/>
+					<hr>
+					<div>{{ invitation.room.description === '' ? i18n.ts.noDescription : invitation.room.description }}</div>
+				</div>
+			</div>
+		</MkFolder>
+	</div>
+	<div v-if="!fetching && invitations.length == 0" class="_fullinfo">
+		<div>{{ i18n.ts._chat.noInvitations }}</div>
+	</div>
+	<MkLoading v-if="fetching"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { ensureSignin } from '@/i.js';
+import { useRouter } from '@/router.js';
+import * as os from '@/os.js';
+import MkFolder from '@/components/MkFolder.vue';
+
+const $i = ensureSignin();
+
+const router = useRouter();
+
+const fetching = ref(true);
+const invitations = ref<Misskey.entities.ChatRoomInvitation[]>([]);
+
+async function fetchInvitations() {
+	fetching.value = true;
+
+	const res = await misskeyApi('chat/rooms/invitations/inbox', {
+	});
+
+	invitations.value = res;
+
+	fetching.value = false;
+}
+
+async function join(invitation: Misskey.entities.ChatRoomInvitation) {
+	await misskeyApi('chat/rooms/join', {
+		roomId: invitation.room.id,
+	});
+
+	router.push(`/chat/room/${invitation.room.id}`);
+}
+
+async function ignore(invitation: Misskey.entities.ChatRoomInvitation) {
+	await misskeyApi('chat/rooms/invitations/ignore', {
+		roomId: invitation.room.id,
+	});
+
+	invitations.value = invitations.value.filter(i => i.id !== invitation.id);
+}
+
+onMounted(() => {
+	fetchInvitations();
+});
+</script>
+
+<style lang="scss" module>
+.invitationBody {
+	display: flex;
+	align-items: center;
+}
+
+.invitationBodyAvatar {
+	margin-right: 12px;
+	width: 45px;
+	height: 45px;
+}
+</style>
diff --git a/packages/frontend/src/pages/chat/home.joiningRooms.vue b/packages/frontend/src/pages/chat/home.joiningRooms.vue
new file mode 100644
index 0000000000..63e4d2adf8
--- /dev/null
+++ b/packages/frontend/src/pages/chat/home.joiningRooms.vue
@@ -0,0 +1,54 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+	<div v-if="memberships.length > 0" class="_gaps_s">
+		<XRoom v-for="membership in memberships" :key="membership.id" :room="membership.room"/>
+	</div>
+	<div v-if="!fetching && memberships.length == 0" class="_fullinfo">
+		<div>{{ i18n.ts._chat.noRooms }}</div>
+	</div>
+	<MkLoading v-if="fetching"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import XRoom from './XRoom.vue';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { ensureSignin } from '@/i.js';
+import { useRouter } from '@/router.js';
+import * as os from '@/os.js';
+
+const $i = ensureSignin();
+
+const router = useRouter();
+
+const fetching = ref(true);
+const memberships = ref<Misskey.entities.ChatRoomMembership[]>([]);
+
+async function fetchRooms() {
+	fetching.value = true;
+
+	const res = await misskeyApi('chat/rooms/joining', {
+	});
+
+	memberships.value = res;
+
+	fetching.value = false;
+}
+
+onMounted(() => {
+	fetchRooms();
+});
+</script>
+
+<style lang="scss" module>
+
+</style>
diff --git a/packages/frontend/src/pages/chat/home.ownedRooms.vue b/packages/frontend/src/pages/chat/home.ownedRooms.vue
new file mode 100644
index 0000000000..b0449fb373
--- /dev/null
+++ b/packages/frontend/src/pages/chat/home.ownedRooms.vue
@@ -0,0 +1,54 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+	<div v-if="rooms.length > 0" class="_gaps_s">
+		<XRoom v-for="room in rooms" :key="room.id" :room="room"/>
+	</div>
+	<div v-if="!fetching && rooms.length == 0" class="_fullinfo">
+		<div>{{ i18n.ts._chat.noRooms }}</div>
+	</div>
+	<MkLoading v-if="fetching"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import XRoom from './XRoom.vue';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { ensureSignin } from '@/i.js';
+import { useRouter } from '@/router.js';
+import * as os from '@/os.js';
+
+const $i = ensureSignin();
+
+const router = useRouter();
+
+const fetching = ref(true);
+const rooms = ref<Misskey.entities.ChatRoom[]>([]);
+
+async function fetchRooms() {
+	fetching.value = true;
+
+	const res = await misskeyApi('chat/rooms/owned', {
+	});
+
+	rooms.value = res;
+
+	fetching.value = false;
+}
+
+onMounted(() => {
+	fetchRooms();
+});
+</script>
+
+<style lang="scss" module>
+
+</style>
diff --git a/packages/frontend/src/pages/chat/home.vue b/packages/frontend/src/pages/chat/home.vue
new file mode 100644
index 0000000000..c2b272a42d
--- /dev/null
+++ b/packages/frontend/src/pages/chat/home.vue
@@ -0,0 +1,60 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
+	<MkPolkadots v-if="tab === 'home'" accented/>
+	<MkSpacer :contentMax="700">
+		<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
+			<XHome v-if="tab === 'home'"/>
+			<XInvitations v-else-if="tab === 'invitations'"/>
+			<XJoiningRooms v-else-if="tab === 'joiningRooms'"/>
+			<XOwnedRooms v-else-if="tab === 'ownedRooms'"/>
+		</MkHorizontalSwipe>
+	</MkSpacer>
+</PageWithHeader>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from 'vue';
+import XHome from './home.home.vue';
+import XInvitations from './home.invitations.vue';
+import XJoiningRooms from './home.joiningRooms.vue';
+import XOwnedRooms from './home.ownedRooms.vue';
+import { i18n } from '@/i18n.js';
+import { definePage } from '@/page.js';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
+import MkPolkadots from '@/components/MkPolkadots.vue';
+
+const tab = ref('home');
+
+const headerActions = computed(() => []);
+
+const headerTabs = computed(() => [{
+	key: 'home',
+	title: i18n.ts._chat.home,
+	icon: 'ti ti-home',
+}, {
+	key: 'invitations',
+	title: i18n.ts._chat.invitations,
+	icon: 'ti ti-ticket',
+}, {
+	key: 'joiningRooms',
+	title: i18n.ts._chat.joiningRooms,
+	icon: 'ti ti-users-group',
+}, {
+	key: 'ownedRooms',
+	title: i18n.ts._chat.yourRooms,
+	icon: 'ti ti-settings',
+}]);
+
+definePage(() => ({
+	title: i18n.ts.chat + ' (beta)',
+	icon: 'ti ti-message',
+}));
+</script>
+
+<style lang="scss" module>
+</style>
diff --git a/packages/frontend/src/pages/chat/message.vue b/packages/frontend/src/pages/chat/message.vue
new file mode 100644
index 0000000000..be8be7e5d1
--- /dev/null
+++ b/packages/frontend/src/pages/chat/message.vue
@@ -0,0 +1,55 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<PageWithHeader>
+	<MkSpacer :contentMax="700">
+		<div v-if="initializing">
+			<MkLoading/>
+		</div>
+		<div v-else>
+			<XMessage :message="message"/>
+		</div>
+	</MkSpacer>
+</PageWithHeader>
+</template>
+
+<script lang="ts" setup>
+import { ref, useTemplateRef, computed, watch, onMounted, nextTick, onBeforeUnmount, onDeactivated, onActivated } from 'vue';
+import * as Misskey from 'misskey-js';
+import XMessage from './XMessage.vue';
+import * as os from '@/os.js';
+import { useStream } from '@/stream.js';
+import { i18n } from '@/i18n.js';
+import { ensureSignin } from '@/i.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePage } from '@/page.js';
+import MkButton from '@/components/MkButton.vue';
+
+const props = defineProps<{
+	messageId?: string;
+}>();
+
+const initializing = ref(true);
+const message = ref<Misskey.entities.ChatMessage>();
+
+async function initialize() {
+	initializing.value = true;
+
+	message.value = await misskeyApi('chat/messages/show', {
+		messageId: props.messageId,
+	});
+
+	initializing.value = false;
+}
+
+onMounted(() => {
+	initialize();
+});
+
+definePage({
+	title: i18n.ts.chat,
+});
+</script>
diff --git a/packages/frontend/src/pages/chat/room.form.vue b/packages/frontend/src/pages/chat/room.form.vue
new file mode 100644
index 0000000000..aba9d6061f
--- /dev/null
+++ b/packages/frontend/src/pages/chat/room.form.vue
@@ -0,0 +1,333 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+	:class="$style.root"
+	@dragover.stop="onDragover"
+	@drop.stop="onDrop"
+>
+	<textarea
+		ref="textareaEl"
+		v-model="text"
+		:class="$style.textarea"
+		class="_acrylic"
+		:placeholder="i18n.ts.inputMessageHere"
+		:readonly="textareaReadOnly"
+		@keydown="onKeydown"
+		@paste="onPaste"
+	></textarea>
+	<footer :class="$style.footer">
+		<div v-if="file" :class="$style.file" @click="file = null">{{ file.name }}</div>
+		<div :class="$style.buttons">
+			<button class="_button" :class="$style.button" @click="chooseFile"><i class="ti ti-photo-plus"></i></button>
+			<button class="_button" :class="$style.button" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
+			<button class="_button" :class="[$style.button, $style.send]" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send">
+				<template v-if="!sending"><i class="ti ti-send"></i></template><template v-if="sending"><MkLoading :em="true"/></template>
+			</button>
+		</div>
+	</footer>
+	<input ref="fileEl" style="display: none;" type="file" @change="onChangeFile"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, watch, ref, shallowRef, computed, nextTick, readonly } from 'vue';
+import * as Misskey from 'misskey-js';
+//import insertTextAtCursor from 'insert-text-at-cursor';
+import { throttle } from 'throttle-debounce';
+import { formatTimeString } from '@/utility/format-time-string.js';
+import { selectFile } from '@/utility/select-file.js';
+import * as os from '@/os.js';
+import { useStream } from '@/stream.js';
+import { i18n } from '@/i18n.js';
+import { uploadFile } from '@/utility/upload.js';
+import { miLocalStorage } from '@/local-storage.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { prefer } from '@/preferences.js';
+import { Autocomplete } from '@/utility/autocomplete.js';
+import { emojiPicker } from '@/utility/emoji-picker.js';
+
+const props = defineProps<{
+	user?: Misskey.entities.UserDetailed | null;
+	room?: Misskey.entities.ChatRoom | null;
+}>();
+
+const textareaEl = shallowRef<HTMLTextAreaElement>();
+const fileEl = shallowRef<HTMLInputElement>();
+
+const text = ref<string>('');
+const file = ref<Misskey.entities.DriveFile | null>(null);
+const sending = ref(false);
+const textareaReadOnly = ref(false);
+
+const canSend = computed(() => (text.value != null && text.value !== '') || file.value != null);
+
+function getDraftKey() {
+	return props.user ? 'user:' + props.user.id : 'room:' + props.room?.id;
+}
+
+watch([text, file], saveDraft);
+
+async function onPaste(ev: ClipboardEvent) {
+	if (!ev.clipboardData) return;
+
+	const pastedFileName = 'yyyy-MM-dd HH-mm-ss [{{number}}]';
+
+	const clipboardData = ev.clipboardData;
+	const items = clipboardData.items;
+
+	if (items.length === 1) {
+		if (items[0].kind === 'file') {
+			const pastedFile = items[0].getAsFile();
+			if (!pastedFile) return;
+			const lio = pastedFile.name.lastIndexOf('.');
+			const ext = lio >= 0 ? pastedFile.name.slice(lio) : '';
+			const formatted = formatTimeString(new Date(pastedFile.lastModified), pastedFileName).replace(/{{number}}/g, '1') + ext;
+			if (formatted) upload(pastedFile, formatted);
+		}
+	} else {
+		if (items[0].kind === 'file') {
+			os.alert({
+				type: 'error',
+				text: i18n.ts.onlyOneFileCanBeAttached,
+			});
+		}
+	}
+}
+
+function onDragover(ev: DragEvent) {
+	if (!ev.dataTransfer) return;
+
+	const isFile = ev.dataTransfer.items[0].kind === 'file';
+	const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
+	if (isFile || isDriveFile) {
+		ev.preventDefault();
+		switch (ev.dataTransfer.effectAllowed) {
+			case 'all':
+			case 'uninitialized':
+			case 'copy':
+			case 'copyLink':
+			case 'copyMove':
+				ev.dataTransfer.dropEffect = 'copy';
+				break;
+			case 'linkMove':
+			case 'move':
+				ev.dataTransfer.dropEffect = 'move';
+				break;
+			default:
+				ev.dataTransfer.dropEffect = 'none';
+				break;
+		}
+	}
+}
+
+function onDrop(ev: DragEvent): void {
+	if (!ev.dataTransfer) return;
+
+	// ファイルだったら
+	if (ev.dataTransfer.files.length === 1) {
+		ev.preventDefault();
+		upload(ev.dataTransfer.files[0]);
+		return;
+	} else if (ev.dataTransfer.files.length > 1) {
+		ev.preventDefault();
+		os.alert({
+			type: 'error',
+			text: i18n.ts.onlyOneFileCanBeAttached,
+		});
+		return;
+	}
+
+	//#region ドライブのファイル
+	const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+	if (driveFile != null && driveFile !== '') {
+		file.value = JSON.parse(driveFile);
+		ev.preventDefault();
+	}
+	//#endregion
+}
+
+function onKeydown(ev: KeyboardEvent) {
+	if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey)) {
+		send();
+	}
+}
+
+function chooseFile(ev: MouseEvent) {
+	selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => {
+		file.value = selectedFile;
+	});
+}
+
+function onChangeFile() {
+	if (fileEl.value.files![0]) upload(fileEl.value.files[0]);
+}
+
+function upload(fileToUpload: File, name?: string) {
+	uploadFile(fileToUpload, prefer.s.uploadFolder, name).then(res => {
+		file.value = res;
+	});
+}
+
+function send() {
+	if (!canSend.value) return;
+
+	sending.value = true;
+
+	if (props.user) {
+		misskeyApi('chat/messages/create-to-user', {
+			toUserId: props.user.id,
+			text: text.value ? text.value : undefined,
+			fileId: file.value ? file.value.id : undefined,
+		}).then(message => {
+			clear();
+		}).catch(err => {
+			console.error(err);
+		}).then(() => {
+			sending.value = false;
+		});
+	} else if (props.room) {
+		misskeyApi('chat/messages/create-to-room', {
+			toRoomId: props.room.id,
+			text: text.value ? text.value : undefined,
+			fileId: file.value ? file.value.id : undefined,
+		}).then(message => {
+			clear();
+		}).catch(err => {
+			console.error(err);
+		}).then(() => {
+			sending.value = false;
+		});
+	}
+}
+
+function clear() {
+	text.value = '';
+	file.value = null;
+	deleteDraft();
+}
+
+function saveDraft() {
+	const drafts = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}');
+
+	drafts[getDraftKey()] = {
+		updatedAt: new Date(),
+		data: {
+			text: text.value,
+			file: file.value,
+		},
+	};
+
+	miLocalStorage.setItem('chatMessageDrafts', JSON.stringify(drafts));
+}
+
+function deleteDraft() {
+	const drafts = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}');
+
+	delete drafts[getDraftKey()];
+
+	miLocalStorage.setItem('chatMessageDrafts', JSON.stringify(drafts));
+}
+
+async function insertEmoji(ev: MouseEvent) {
+	textareaReadOnly.value = true;
+	const target = ev.currentTarget ?? ev.target;
+	if (target == null) return;
+
+	// emojiPickerはダイアログが閉じずにtextareaとやりとりするので、
+	// focustrapをかけているとinsertTextAtCursorが効かない
+	// そのため、投稿フォームのテキストに直接注入する
+	// See: https://github.com/misskey-dev/misskey/pull/14282
+	//      https://github.com/misskey-dev/misskey/issues/14274
+
+	let pos = textareaEl.value?.selectionStart ?? 0;
+	let posEnd = textareaEl.value?.selectionEnd ?? text.value.length;
+	emojiPicker.show(
+		target as HTMLElement,
+		emoji => {
+			const textBefore = text.value.substring(0, pos);
+			const textAfter = text.value.substring(posEnd);
+			text.value = textBefore + emoji + textAfter;
+			pos += emoji.length;
+			posEnd += emoji.length;
+		},
+		() => {
+			textareaReadOnly.value = false;
+			nextTick(() => focus());
+		},
+	);
+}
+
+onMounted(() => {
+	// TODO: detach when unmount
+	new Autocomplete(textareaEl.value, text);
+
+	// 書きかけの投稿を復元
+	const draft = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}')[getDraftKey()];
+	if (draft) {
+		text.value = draft.data.text;
+		file.value = draft.data.file;
+	}
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	position: relative;
+	border-bottom: none;
+	border-radius: 14px 14px 0 0;
+	overflow: clip;
+}
+
+.textarea {
+	cursor: auto;
+	display: block;
+	width: 100%;
+	min-width: 100%;
+	max-width: 100%;
+	min-height: 80px;
+	margin: 0;
+	padding: 16px 16px 0 16px;
+	resize: none;
+	font-size: 1em;
+	font-family: inherit;
+	outline: none;
+	border: none;
+	border-radius: 0;
+	box-shadow: none;
+	box-sizing: border-box;
+	color: var(--MI_THEME-fg);
+	field-sizing: content;
+}
+
+.footer {
+	position: sticky;
+	bottom: 0;
+	background: var(--MI_THEME-panel);
+}
+
+.file {
+	padding: 8px;
+	cursor: pointer;
+}
+
+.buttons {
+	display: flex;
+}
+
+.button {
+	height: 50px;
+	aspect-ratio: 1;
+
+	&:hover {
+		color: var(--MI_THEME-accent);
+	}
+}
+.send {
+	margin-left: auto;
+	color: var(--MI_THEME-accent);
+}
+</style>
diff --git a/packages/frontend/src/pages/chat/room.info.vue b/packages/frontend/src/pages/chat/room.info.vue
new file mode 100644
index 0000000000..7d38d07b3a
--- /dev/null
+++ b/packages/frontend/src/pages/chat/room.info.vue
@@ -0,0 +1,87 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+	<MkInput v-model="name_" :disabled="!isOwner">
+		<template #label>{{ i18n.ts.name }}</template>
+	</MkInput>
+
+	<MkTextarea v-model="description_" :disabled="!isOwner">
+		<template #label>{{ i18n.ts.description }}</template>
+	</MkTextarea>
+
+	<MkButton v-if="isOwner" primary @click="save">{{ i18n.ts.save }}</MkButton>
+
+	<hr>
+
+	<MkSwitch v-if="!isOwner" v-model="isMuted">
+		<template #label>{{ i18n.ts._chat.muteThisRoom }}</template>
+	</MkSwitch>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref, watch } from 'vue';
+import * as Misskey from 'misskey-js';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import * as os from '@/os.js';
+import { ensureSignin } from '@/i.js';
+import MkInput from '@/components/MkInput.vue';
+import MkTextarea from '@/components/MkTextarea.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+
+const $i = ensureSignin();
+
+const props = defineProps<{
+	room: Misskey.entities.ChatRoom;
+}>();
+
+const isOwner = computed(() => {
+	return props.room.ownerId === $i.id;
+});
+
+const name_ = ref(props.room.name);
+const description_ = ref(props.room.description);
+
+function save() {
+	os.apiWithDialog('chat/rooms/update', {
+		roomId: props.room.id,
+		name: name_.value,
+		description: description_.value,
+	});
+}
+
+const isMuted = ref(props.room.isMuted);
+
+watch(isMuted, async () => {
+	await os.apiWithDialog('chat/rooms/mute', {
+		roomId: props.room.id,
+		mute: isMuted.value,
+	});
+});
+
+onMounted(async () => {
+
+});
+</script>
+
+<style lang="scss" module>
+.membership {
+	display: flex;
+}
+
+.membershipBody {
+	flex: 1;
+	min-width: 0;
+	margin-right: 8px;
+
+	&:hover {
+		text-decoration: none;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/chat/room.members.vue b/packages/frontend/src/pages/chat/room.members.vue
new file mode 100644
index 0000000000..d20216a81c
--- /dev/null
+++ b/packages/frontend/src/pages/chat/room.members.vue
@@ -0,0 +1,73 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+	<MkButton v-if="isOwner" primary rounded style="margin: 0 auto;" @click="emit('inviteUser')"><i class="ti ti-plus"></i> {{ i18n.ts._chat.inviteUser }}</MkButton>
+
+	<MkA :class="$style.membershipBody" :to="`${userPage(room.owner)}`">
+		<MkUserCardMini :user="room.owner"/>
+	</MkA>
+
+	<hr>
+
+	<div v-for="membership in memberships" :key="membership.id" :class="$style.membership">
+		<MkA :class="$style.membershipBody" :to="`${userPage(membership.user)}`">
+			<MkUserCardMini :user="membership.user"/>
+		</MkA>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import * as os from '@/os.js';
+import MkUserCardMini from '@/components/MkUserCardMini.vue';
+import { userPage } from '@/filters/user.js';
+import { ensureSignin } from '@/i.js';
+
+const $i = ensureSignin();
+
+const props = defineProps<{
+	room: Misskey.entities.ChatRoom;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'inviteUser'): void,
+}>();
+
+const isOwner = computed(() => {
+	return props.room.ownerId === $i.id;
+});
+
+const memberships = ref<Misskey.entities.ChatRoomMembership[]>([]);
+
+onMounted(async () => {
+	memberships.value = await misskeyApi('chat/rooms/members', {
+		roomId: props.room.id,
+		limit: 50,
+	});
+});
+</script>
+
+<style lang="scss" module>
+.membership {
+	display: flex;
+}
+
+.membershipBody {
+	flex: 1;
+	min-width: 0;
+	margin-right: 8px;
+
+	&:hover {
+		text-decoration: none;
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/chat/room.search.vue b/packages/frontend/src/pages/chat/room.search.vue
new file mode 100644
index 0000000000..de5e7156ca
--- /dev/null
+++ b/packages/frontend/src/pages/chat/room.search.vue
@@ -0,0 +1,68 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+	<MkInput
+		v-model="searchQuery"
+		:placeholder="i18n.ts._chat.searchMessages"
+		type="search"
+	>
+		<template #prefix><i class="ti ti-search"></i></template>
+	</MkInput>
+
+	<MkButton v-if="searchQuery.length > 0" primary rounded @click="search">{{ i18n.ts.search }}</MkButton>
+
+	<MkFoldableSection v-if="searched">
+		<template #header>{{ i18n.ts.searchResult }}</template>
+
+		<div class="_gaps_s">
+			<div v-for="message in searchResults" :key="message.id" :class="$style.searchResultItem">
+				<XMessage :message="message" :user="message.fromUser" :isSearchResult="true"/>
+			</div>
+		</div>
+	</MkFoldableSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import XMessage from './XMessage.vue';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import * as os from '@/os.js';
+import MkInput from '@/components/MkInput.vue';
+import MkFoldableSection from '@/components/MkFoldableSection.vue';
+
+const props = defineProps<{
+	userId?: string;
+	roomId?: string;
+}>();
+
+const searchQuery = ref('');
+const searched = ref(false);
+const searchResults = ref<Misskey.entities.ChatMessage[]>([]);
+
+async function search() {
+	const res = await misskeyApi('chat/messages/search', {
+		query: searchQuery.value,
+		roomId: props.roomId,
+		userId: props.userId,
+	});
+
+	searchResults.value = res;
+	searched.value = true;
+}
+</script>
+
+<style lang="scss" module>
+.searchResultItem {
+	padding: 12px;
+	border: solid 1px var(--MI_THEME-divider);
+	border-radius: 12px;
+}
+</style>
diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue
new file mode 100644
index 0000000000..15e9f43db2
--- /dev/null
+++ b/packages/frontend/src/pages/chat/room.vue
@@ -0,0 +1,426 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<PageWithHeader v-model:tab="tab" :reversed="tab === 'chat'" :tabs="headerTabs" :actions="headerActions">
+	<MkSpacer v-if="tab === 'chat'" :contentMax="700">
+		<div v-if="initializing">
+			<MkLoading/>
+		</div>
+
+		<div v-else-if="messages.length === 0">
+			<div class="_gaps" style="text-align: center;">
+				<div>{{ i18n.ts._chat.noMessagesYet }}</div>
+				<template v-if="user">
+					<div v-if="user.chatScope === 'followers'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowers }}</div>
+					<div v-else-if="user.chatScope === 'following'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowing }}</div>
+					<div v-else-if="user.chatScope === 'mutual'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromMutualFollowing }}</div>
+					<div v-else>{{ i18n.ts._chat.thisUserNotAllowedChatAnyone }}</div>
+				</template>
+				<template v-else-if="room">
+					<div>{{ i18n.ts._chat.inviteUserToChat }}</div>
+				</template>
+			</div>
+		</div>
+
+		<div v-else class="_gaps">
+			<div v-if="canFetchMore">
+				<MkButton :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">{{ i18n.ts.loadMore }}</MkButton>
+			</div>
+
+			<TransitionGroup
+				:enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''"
+				:leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''"
+				:enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''"
+				:leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''"
+				:moveClass="prefer.s.animation ? $style.transition_x_move : ''"
+				tag="div" class="_gaps"
+			>
+				<XMessage v-for="message in messages.toReversed()" :key="message.id" :message="message"/>
+			</TransitionGroup>
+		</div>
+	</MkSpacer>
+
+	<MkSpacer v-else-if="tab === 'search'" :contentMax="700">
+		<XSearch :userId="userId" :roomId="roomId"/>
+	</MkSpacer>
+
+	<MkSpacer v-else-if="tab === 'members'" :contentMax="700">
+		<XMembers v-if="room != null" :room="room" @inviteUser="inviteUser"/>
+	</MkSpacer>
+
+	<MkSpacer v-else-if="tab === 'info'" :contentMax="700">
+		<XInfo v-if="room != null" :room="room"/>
+	</MkSpacer>
+
+	<template #footer>
+		<div v-if="tab === 'chat'" :class="$style.footer">
+			<div class="_gaps">
+				<Transition name="fade">
+					<div v-show="showIndicator" :class="$style.new">
+						<button class="_buttonPrimary" :class="$style.newButton" @click="onIndicatorClick">
+							<i class="fas ti-fw fa-arrow-circle-down" :class="$style.newIcon"></i>{{ i18n.ts.newMessageExists }}
+						</button>
+					</div>
+				</Transition>
+				<XForm v-if="!initializing" :user="user" :room="room" :class="$style.form"/>
+			</div>
+		</div>
+	</template>
+</PageWithHeader>
+</template>
+
+<script lang="ts" setup>
+import { ref, useTemplateRef, computed, watch, onMounted, nextTick, onBeforeUnmount, onDeactivated, onActivated } from 'vue';
+import * as Misskey from 'misskey-js';
+import { isTailVisible } from '@@/js/scroll.js';
+import XMessage from './XMessage.vue';
+import XForm from './room.form.vue';
+import XSearch from './room.search.vue';
+import XMembers from './room.members.vue';
+import XInfo from './room.info.vue';
+import type { MenuItem } from '@/types/menu.js';
+import * as os from '@/os.js';
+import { useStream } from '@/stream.js';
+import * as sound from '@/utility/sound.js';
+import { i18n } from '@/i18n.js';
+import { ensureSignin } from '@/i.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePage } from '@/page.js';
+import { prefer } from '@/preferences.js';
+import MkButton from '@/components/MkButton.vue';
+import { useRouter } from '@/router.js';
+
+const $i = ensureSignin();
+const router = useRouter();
+
+const props = defineProps<{
+	userId?: string;
+	roomId?: string;
+}>();
+
+const initializing = ref(true);
+const moreFetching = ref(false);
+const messages = ref<Misskey.entities.ChatMessage[]>([]);
+const canFetchMore = ref(false);
+const user = ref<Misskey.entities.UserDetailed | null>(null);
+const room = ref<Misskey.entities.ChatRoom | null>(null);
+const connection = ref<Misskey.ChannelConnection<Misskey.Channels['chatUser'] | Misskey.Channels['chatRoom']> | null>(null);
+const showIndicator = ref(false);
+
+function normalizeMessage(message: Misskey.entities.ChatMessageLite | Misskey.entities.ChatMessage) {
+	const reactions = [...message.reactions];
+	for (const record of reactions) {
+		if (room.value == null && record.user == null) { // 1on1の時はuserは省略される
+			record.user = message.fromUserId === $i.id ? user.value : $i;
+		}
+	}
+
+	return {
+		...message,
+		fromUser: message.fromUser ?? (message.fromUserId === $i.id ? $i : user),
+		reactions,
+	};
+}
+
+async function initialize() {
+	const LIMIT = 20;
+
+	initializing.value = true;
+
+	if (props.userId) {
+		const [u, m] = await Promise.all([
+			misskeyApi('users/show', { userId: props.userId }),
+			misskeyApi('chat/messages/user-timeline', { userId: props.userId, limit: LIMIT }),
+		]);
+
+		user.value = u;
+		messages.value = m.map(x => normalizeMessage(x));
+
+		if (messages.value.length === LIMIT) {
+			canFetchMore.value = true;
+		}
+
+		connection.value = useStream().useChannel('chatUser', {
+			otherId: user.value.id,
+		});
+		connection.value.on('message', onMessage);
+		connection.value.on('deleted', onDeleted);
+		connection.value.on('react', onReact);
+	} else {
+		const [r, m] = await Promise.all([
+			misskeyApi('chat/rooms/show', { roomId: props.roomId }),
+			misskeyApi('chat/messages/room-timeline', { roomId: props.roomId, limit: LIMIT }),
+		]);
+
+		room.value = r;
+		messages.value = m.map(x => normalizeMessage(x));
+
+		if (messages.value.length === LIMIT) {
+			canFetchMore.value = true;
+		}
+
+		connection.value = useStream().useChannel('chatRoom', {
+			roomId: room.value.id,
+		});
+		connection.value.on('message', onMessage);
+		connection.value.on('deleted', onDeleted);
+		connection.value.on('react', onReact);
+	}
+
+	window.document.addEventListener('visibilitychange', onVisibilitychange);
+
+	initializing.value = false;
+}
+
+let isActivated = true;
+
+onActivated(() => {
+	isActivated = true;
+});
+
+onDeactivated(() => {
+	isActivated = false;
+});
+
+async function fetchMore() {
+	const LIMIT = 30;
+
+	moreFetching.value = true;
+
+	const newMessages = props.userId ? await misskeyApi('chat/messages/user-timeline', {
+		userId: user.value.id,
+		limit: LIMIT,
+		untilId: messages.value[messages.value.length - 1].id,
+	}) : await misskeyApi('chat/messages/room-timeline', {
+		roomId: room.value.id,
+		limit: LIMIT,
+		untilId: messages.value[messages.value.length - 1].id,
+	});
+
+	messages.value.push(...newMessages.map(x => normalizeMessage(x)));
+
+	canFetchMore.value = newMessages.length === LIMIT;
+	moreFetching.value = false;
+}
+
+function onMessage(message: Misskey.entities.ChatMessage) {
+	sound.playMisskeySfx('chatMessage');
+
+	messages.value.unshift(normalizeMessage(message));
+
+	// TODO: DOM的にバックグラウンドになっていないかどうかも考慮する
+	if (message.fromUserId !== $i.id && !window.document.hidden && isActivated) {
+		connection.value?.send('read', {
+			id: message.id,
+		});
+	}
+
+	if (message.fromUserId !== $i.id) {
+		//notifyNewMessage();
+	}
+}
+
+function onDeleted(id) {
+	const index = messages.value.findIndex(m => m.id === id);
+	if (index !== -1) {
+		messages.value.splice(index, 1);
+	}
+}
+
+function onReact(ctx) {
+	const message = messages.value.find(m => m.id === ctx.messageId);
+	if (message) {
+		if (room.value == null) { // 1on1の時はuserは省略される
+			message.reactions.push({
+				reaction: ctx.reaction,
+				user: message.fromUserId === $i.id ? user : $i,
+			});
+		} else {
+			message.reactions.push({
+				reaction: ctx.reaction,
+				user: ctx.user,
+			});
+		}
+	}
+}
+
+function onIndicatorClick() {
+	showIndicator.value = false;
+}
+
+function notifyNewMessage() {
+	showIndicator.value = true;
+}
+
+function onVisibilitychange() {
+	if (window.document.hidden) return;
+	// TODO
+}
+
+onMounted(() => {
+	initialize();
+});
+
+onBeforeUnmount(() => {
+	connection.value?.dispose();
+	window.document.removeEventListener('visibilitychange', onVisibilitychange);
+});
+
+async function inviteUser() {
+	const invitee = await os.selectUser({ includeSelf: false, localOnly: true });
+	os.apiWithDialog('chat/rooms/invitations/create', {
+		roomId: room.value?.id,
+		userId: invitee.id,
+	});
+}
+
+async function leaveRoom() {
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.areYouSure,
+	});
+	if (canceled) return;
+
+	misskeyApi('chat/rooms/leave', {
+		roomId: room.value?.id,
+	});
+	router.push('/chat');
+}
+
+function showMenu(ev: MouseEvent) {
+	const menuItems: MenuItem[] = [];
+
+	if (room.value) {
+		if (room.value.ownerId === $i.id) {
+			menuItems.push({
+				text: i18n.ts._chat.inviteUser,
+				icon: 'ti ti-user-plus',
+				action: () => {
+					inviteUser();
+				},
+			});
+		} else {
+			menuItems.push({
+				text: i18n.ts._chat.leave,
+				icon: 'ti ti-x',
+				action: () => {
+					leaveRoom();
+				},
+			});
+		}
+	}
+
+	os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
+}
+
+const tab = ref('chat');
+
+const headerTabs = computed(() => room.value ? [{
+	key: 'chat',
+	title: i18n.ts.chat,
+	icon: 'ti ti-messages',
+}, {
+	key: 'members',
+	title: i18n.ts._chat.members,
+	icon: 'ti ti-users',
+}, {
+	key: 'search',
+	title: i18n.ts.search,
+	icon: 'ti ti-search',
+}, {
+	key: 'info',
+	title: i18n.ts.info,
+	icon: 'ti ti-info-circle',
+}] : [{
+	key: 'chat',
+	title: i18n.ts.chat,
+	icon: 'ti ti-messages',
+}, {
+	key: 'search',
+	title: i18n.ts.search,
+	icon: 'ti ti-search',
+}]);
+
+const headerActions = computed(() => [{
+	icon: 'ti ti-dots',
+	handler: showMenu,
+}]);
+
+definePage(computed(() => !initializing.value ? user.value ? {
+	userName: user,
+	avatar: user,
+} : {
+	title: room.value?.name,
+	icon: 'ti ti-users',
+} : null));
+</script>
+
+<style lang="scss" module>
+.transition_x_move,
+.transition_x_enterActive,
+.transition_x_leaveActive {
+	transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important;
+}
+.transition_x_enterFrom,
+.transition_x_leaveTo {
+	opacity: 0;
+	transform: translateY(80px);
+}
+.transition_x_leaveActive {
+	position: absolute;
+}
+
+.root {
+}
+
+.more {
+	margin: 0 auto;
+}
+
+.footer {
+	width: 100%;
+	padding-top: 8px;
+}
+
+.new {
+	width: 100%;
+	padding-bottom: 8px;
+	text-align: center;
+}
+
+.newButton {
+	display: inline-block;
+	margin: 0;
+	padding: 0 12px;
+	line-height: 32px;
+	font-size: 12px;
+	border-radius: 16px;
+}
+
+.newIcon {
+	display: inline-block;
+	margin-right: 8px;
+}
+
+.footer {
+
+}
+
+.form {
+	margin: 0 auto;
+	width: 100%;
+	max-width: 700px;
+}
+
+.fade-enter-active, .fade-leave-active {
+	transition: opacity 0.1s;
+}
+
+.fade-enter-from, .fade-leave-to {
+	transition: opacity 0.5s;
+	opacity: 0;
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue
index 530b63b701..93a41e9ddd 100644
--- a/packages/frontend/src/pages/settings/notifications.vue
+++ b/packages/frontend/src/pages/settings/notifications.vue
@@ -38,7 +38,6 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<FormSection>
 		<div class="_gaps_m">
 			<FormLink @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink>
-			<FormLink @click="readAllUnreadNotes">{{ i18n.ts.markAsReadAllUnreadNotes }}</FormLink>
 		</div>
 	</FormSection>
 	<FormSection>
@@ -93,10 +92,6 @@ const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrat
 const sendReadMessage = computed(() => pushRegistrationInServer.value?.sendReadMessage || false);
 const userLists = await misskeyApi('users/lists/list');
 
-async function readAllUnreadNotes() {
-	await os.apiWithDialog('i/read-all-unread-notes');
-}
-
 async function readAllNotifications() {
 	await os.apiWithDialog('notifications/mark-all-as-read');
 }
diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue
index f6eb203095..2f8a697d74 100644
--- a/packages/frontend/src/pages/settings/privacy.vue
+++ b/packages/frontend/src/pages/settings/privacy.vue
@@ -78,6 +78,20 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</MkSwitch>
 		</SearchMarker>
 
+		<FormSection>
+			<SearchMarker :keywords="['chat']">
+				<MkSelect v-model="chatScope" @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>
+		</FormSection>
+
 		<SearchMarker :keywords="['lockdown']">
 			<FormSection>
 				<template #label><SearchLabel>{{ i18n.ts.lockdown }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template>
@@ -208,6 +222,7 @@ 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 makeNotesFollowersOnlyBefore_type = computed(() => {
 	if (makeNotesFollowersOnlyBefore.value == null) {
@@ -260,6 +275,7 @@ function save() {
 		publicReactions: !!publicReactions.value,
 		followingVisibility: followingVisibility.value,
 		followersVisibility: followersVisibility.value,
+		chatScope: chatScope.value,
 	});
 }
 
diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue
index 9e5c82a266..4461ee1ab1 100644
--- a/packages/frontend/src/pages/settings/sounds.vue
+++ b/packages/frontend/src/pages/settings/sounds.vue
@@ -85,6 +85,7 @@ const sounds = ref<Record<OperationType, Ref<SoundStore>>>({
 	noteMy: prefer.r['sound.on.noteMy'],
 	notification: prefer.r['sound.on.notification'],
 	reaction: prefer.r['sound.on.reaction'],
+	chatMessage: prefer.r['sound.on.chatMessage'],
 });
 
 function getSoundTypeName(f: SoundType): string {
diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts
index c3c37553d7..127ebeef0c 100644
--- a/packages/frontend/src/preferences/def.ts
+++ b/packages/frontend/src/preferences/def.ts
@@ -136,6 +136,7 @@ export const PREF_DEF = {
 			'clips',
 			'drive',
 			'followRequests',
+			'chat',
 			'-',
 			'explore',
 			'announcements',
@@ -331,6 +332,7 @@ export const PREF_DEF = {
 	plugins: {
 		default: [] as Plugin[],
 	},
+
 	'sound.masterVolume': {
 		default: 0.3,
 	},
@@ -352,6 +354,10 @@ export const PREF_DEF = {
 	'sound.on.reaction': {
 		default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore,
 	},
+	'sound.on.chatMessage': {
+		default: { type: 'syuilo/waon', volume: 1 } as SoundStore,
+	},
+
 	'deck.alwaysShowMainColumn': {
 		default: true,
 	},
@@ -364,6 +370,7 @@ export const PREF_DEF = {
 	'deck.columnAlign': {
 		default: 'left' as 'left' | 'right' | 'center',
 	},
+
 	'game.dropAndFusion': {
 		default: {
 			bgmVolume: 0.25,
diff --git a/packages/frontend/src/router.definition.ts b/packages/frontend/src/router.definition.ts
index 3b60ee68e3..0585a31fd1 100644
--- a/packages/frontend/src/router.definition.ts
+++ b/packages/frontend/src/router.definition.ts
@@ -40,6 +40,22 @@ export const ROUTE_DEF = [{
 }, {
 	path: '/clips/:clipId',
 	component: page(() => import('@/pages/clip.vue')),
+}, {
+	path: '/chat',
+	component: page(() => import('@/pages/chat/home.vue')),
+	loginRequired: true,
+}, {
+	path: '/chat/user/:userId',
+	component: page(() => import('@/pages/chat/room.vue')),
+	loginRequired: true,
+}, {
+	path: '/chat/room/:roomId',
+	component: page(() => import('@/pages/chat/room.vue')),
+	loginRequired: true,
+}, {
+	path: '/chat/messages/:messageId',
+	component: page(() => import('@/pages/chat/message.vue')),
+	loginRequired: true,
 }, {
 	path: '/instance-info/:host',
 	component: page(() => import('@/pages/instance-info.vue')),
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index 8c5617b72e..f122f47c06 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -113,10 +113,6 @@ a {
 		outline-offset: 2px;
 	}
 
-	&:hover {
-		text-decoration: underline;
-	}
-
 	&[target="_blank"] {
 		-webkit-touch-callout: default;
 	}
@@ -335,13 +331,13 @@ rt {
 ._gaps_m {
 	display: flex;
 	flex-direction: column;
-	gap: 1.5em;
+	gap: 21px;
 }
 
 ._gaps_s {
 	display: flex;
 	flex-direction: column;
-	gap: 0.75em;
+	gap: 10px;
 }
 
 ._gaps {
diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts
index 5d1fc1fe72..820759ce61 100644
--- a/packages/frontend/src/types/menu.ts
+++ b/packages/frontend/src/types/menu.ts
@@ -15,16 +15,16 @@ export type MenuAction = (ev: MouseEvent) => void;
 
 export type MenuDivider = { type: 'divider' };
 export type MenuNull = undefined;
-export type MenuLabel = { type: 'label', text: string };
-export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User };
-export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean };
+export type MenuLabel = { type: 'label', text: string, caption?: string };
+export type MenuLink = { type: 'link', to: string, text: string, caption?: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User };
+export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, caption?: string, icon?: string, indicate?: boolean };
 export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction };
-export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, icon?: string, disabled?: boolean | Ref<boolean> };
-export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction };
-export type MenuRadio = { type: 'radio', text: string, icon?: string, ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>, options: MenuRadioOptionsDef, disabled?: boolean | Ref<boolean> };
-export type MenuRadioOption = { type: 'radioOption', text: string, action: MenuAction; active?: boolean | ComputedRef<boolean> };
+export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, caption?: string, icon?: string, disabled?: boolean | Ref<boolean> };
+export type MenuButton = { type?: 'button', text: string, caption?: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction };
+export type MenuRadio = { type: 'radio', text: string, caption?: string, icon?: string, ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>, options: MenuRadioOptionsDef, disabled?: boolean | Ref<boolean> };
+export type MenuRadioOption = { type: 'radioOption', text: string, caption?: string, action: MenuAction; active?: boolean | ComputedRef<boolean> };
 export type MenuComponent<T extends Component = any> = { type: 'component', component: T, props?: ComponentProps<T> };
-export type MenuParent = { type: 'parent', text: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) };
+export type MenuParent = { type: 'parent', text: string, caption?: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) };
 
 export type MenuPending = { type: 'pending' };
 
diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue
index 98d6f329ab..88ce3a96e2 100644
--- a/packages/frontend/src/ui/_common_/navbar.vue
+++ b/packages/frontend/src/ui/_common_/navbar.vue
@@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	</svg>
 	-->
 
-	<div :class="$style.subButtons">
+	<div v-if="!forceIconOnly" :class="$style.subButtons">
 		<div :class="[$style.subButton, $style.menuEditButton]">
 			<svg viewBox="0 0 16 64" :class="$style.subButtonShape">
 				<g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)">
@@ -74,9 +74,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</svg>
 			<button class="_button" :class="$style.subButtonClickable" @click="menuEdit"><i :class="$style.subButtonIcon" class="ti ti-settings-2"></i></button>
 		</div>
-		<div v-if="!forceIconOnly" :class="$style.subButtonGapFill"></div>
-		<div v-if="!forceIconOnly" :class="$style.subButtonGapFillDivider"></div>
-		<div v-if="!forceIconOnly" :class="[$style.subButton, $style.toggleButton]">
+		<div :class="$style.subButtonGapFill"></div>
+		<div :class="$style.subButtonGapFillDivider"></div>
+		<div :class="[$style.subButton, $style.toggleButton]">
 			<svg viewBox="0 0 16 64" :class="$style.subButtonShape">
 				<g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)">
 					<path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/>
diff --git a/packages/frontend/src/utility/autogen/settings-search-index.ts b/packages/frontend/src/utility/autogen/settings-search-index.ts
index fd92876880..64fe328478 100644
--- a/packages/frontend/src/utility/autogen/settings-search-index.ts
+++ b/packages/frontend/src/utility/autogen/settings-search-index.ts
@@ -240,20 +240,25 @@ export const searchIndexes: SearchIndexItem[] = [
 				keywords: ['explore', i18n.ts.makeExplorableDescription],
 			},
 			{
-				id: '7vr04wKol',
+				id: 'xEYlOghao',
+				label: i18n.ts._chat.chatAllowedUsers,
+				keywords: ['chat'],
+			},
+			{
+				id: 'BnOtlyaAh',
 				children: [
 					{
-						id: 'Av7fAaHv8',
+						id: 'BzMIVBpL0',
 						label: i18n.ts._accountSettings.requireSigninToViewContents,
 						keywords: ['login', 'signin'],
 					},
 					{
-						id: '5RbESWefG',
+						id: 'jJUqPqBAv',
 						label: i18n.ts._accountSettings.makeNotesFollowersOnlyBefore,
 						keywords: ['follower', i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription],
 					},
 					{
-						id: 'hdzwDs3qd',
+						id: 'ra10txIFV',
 						label: i18n.ts._accountSettings.makeNotesHiddenBefore,
 						keywords: ['hidden', i18n.ts._accountSettings.makeNotesHiddenBeforeDescription],
 					},
diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts
index de20f2678e..37c88c9665 100644
--- a/packages/frontend/src/utility/get-user-menu.ts
+++ b/packages/frontend/src/utility/get-user-menu.ts
@@ -151,24 +151,6 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
 
 	const menuItems: MenuItem[] = [];
 
-	menuItems.push({
-		icon: 'ti ti-at',
-		text: i18n.ts.copyUsername,
-		action: () => {
-			copyToClipboard(`@${user.username}@${user.host ?? host}`);
-		},
-	});
-
-	if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) {
-		menuItems.push({
-			icon: 'ti ti-search',
-			text: i18n.ts.searchThisUsersNotes,
-			action: () => {
-				router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`);
-			},
-		});
-	}
-
 	if (iAmModerator) {
 		menuItems.push({
 			icon: 'ti ti-user-exclamation',
@@ -176,9 +158,26 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
 			action: () => {
 				router.push(`/admin/user/${user.id}`);
 			},
-		});
+		}, { type: 'divider' });
 	}
 
+	menuItems.push({
+		icon: 'ti ti-at',
+		text: i18n.ts.copyUsername,
+		action: () => {
+			copyToClipboard(`@${user.username}@${user.host ?? host}`);
+		},
+	});
+
+	menuItems.push({
+		icon: 'ti ti-share',
+		text: i18n.ts.copyProfileUrl,
+		action: () => {
+			const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
+			copyToClipboard(`${url}/${canonical}`);
+		},
+	});
+
 	menuItems.push({
 		icon: 'ti ti-rss',
 		text: i18n.ts.copyRSS,
@@ -210,24 +209,18 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
 		});
 	}
 
-	menuItems.push({
-		icon: 'ti ti-share',
-		text: i18n.ts.copyProfileUrl,
-		action: () => {
-			const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
-			copyToClipboard(`${url}/${canonical}`);
-		},
-	});
-
-	if ($i) {
+	if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) {
 		menuItems.push({
-			icon: 'ti ti-mail',
-			text: i18n.ts.sendMessage,
+			icon: 'ti ti-search',
+			text: i18n.ts.searchThisUsersNotes,
 			action: () => {
-				const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`;
-				os.post({ specified: user, initialText: `${canonical} ` });
+				router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`);
 			},
-		}, { type: 'divider' }, {
+		});
+	}
+
+	if ($i) {
+		menuItems.push({ type: 'divider' }, {
 			icon: 'ti ti-pencil',
 			text: i18n.ts.editMemo,
 			action: editMemo,
@@ -363,6 +356,18 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
 		//}
 
 		menuItems.push({ type: 'divider' }, {
+			icon: 'ti ti-mail',
+			text: i18n.ts.sendMessage,
+			action: () => {
+				const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`;
+				os.post({ specified: user, initialText: `${canonical} ` });
+			},
+		}, {
+			type: 'link',
+			icon: 'ti ti-messages',
+			text: i18n.ts._chat.chatWithThisUser,
+			to: `/chat/user/${user.id}`,
+		}, { type: 'divider' }, {
 			icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
 			text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
 			action: toggleMute,
diff --git a/packages/frontend/src/utility/sound.ts b/packages/frontend/src/utility/sound.ts
index 796af0e5ca..f217bdfcd5 100644
--- a/packages/frontend/src/utility/sound.ts
+++ b/packages/frontend/src/utility/sound.ts
@@ -77,6 +77,7 @@ export const operationTypes = [
 	'note',
 	'notification',
 	'reaction',
+	'chatMessage',
 ] as const;
 
 /** サウンドの種類 */
diff --git a/packages/frontend/src/utility/upload.ts b/packages/frontend/src/utility/upload.ts
index eb3cbd3dfa..e13d793ffb 100644
--- a/packages/frontend/src/utility/upload.ts
+++ b/packages/frontend/src/utility/upload.ts
@@ -32,7 +32,7 @@ const mimeTypeMap = {
 
 export function uploadFile(
 	file: File,
-	folder?: string | Misskey.entities.DriveFolder,
+	folder?: string | Misskey.entities.DriveFolder | null,
 	name?: string,
 	keepOriginal: boolean = prefer.s.keepOriginalUploading,
 ): Promise<Misskey.entities.DriveFile> {
diff --git a/packages/misskey-js/README.md b/packages/misskey-js/README.md
index 4753e2434b..5ab2787c47 100644
--- a/packages/misskey-js/README.md
+++ b/packages/misskey-js/README.md
@@ -83,8 +83,8 @@ const mainChannel = stream.useChannel('main');
 ``` ts
 const stream = new Misskey.Stream('https://misskey.test', { token: 'TOKEN' });
 
-const messagingChannel = stream.useChannel('messaging', {
-	otherparty: 'xxxxxxxxxx',
+const chatChannel = stream.useChannel('chat', {
+	other: 'xxxxxxxxxx',
 });
 ```
 
@@ -115,11 +115,11 @@ mainChannel.on('notification', notification => {
 
 ``` ts
 const stream = new Misskey.Stream('https://misskey.test', { token: 'TOKEN' });
-const messagingChannel = stream.useChannel('messaging', {
-	otherparty: 'xxxxxxxxxx',
+const chatChannel = stream.useChannel('chat', {
+	other: 'xxxxxxxxxx',
 });
 
-messagingChannel.send('read', {
+chatChannel.send('read', {
 	id: 'xxxxxxxxxx'
 });
 ```
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 6060812b53..e79cd794a6 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -619,13 +619,9 @@ export type Channels = {
             }) => void;
             readAllNotifications: () => void;
             unreadNotification: (payload: Notification_2) => void;
-            unreadMention: (payload: Note['id']) => void;
-            readAllUnreadMentions: () => void;
             notificationFlushed: () => void;
-            unreadSpecifiedNote: (payload: Note['id']) => void;
-            readAllUnreadSpecifiedNotes: () => void;
-            readAllAntennas: () => void;
             unreadAntenna: (payload: Antenna) => void;
+            newChatMessage: (payload: ChatMessage) => void;
             readAllAnnouncements: () => void;
             myTokenRegenerated: () => void;
             signin: (payload: Signin) => void;
@@ -951,6 +947,153 @@ type ChartsUsersRequest = operations['charts___users']['requestBody']['content']
 // @public (undocumented)
 type ChartsUsersResponse = operations['charts___users']['responses']['200']['content']['application/json'];
 
+// @public (undocumented)
+type ChatHistoryRequest = operations['chat___history']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatHistoryResponse = operations['chat___history']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessage = components['schemas']['ChatMessage'];
+
+// @public (undocumented)
+type ChatMessageLite = components['schemas']['ChatMessageLite'];
+
+// @public (undocumented)
+type ChatMessagesCreateToRoomRequest = operations['chat___messages___create-to-room']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesCreateToRoomResponse = operations['chat___messages___create-to-room']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesCreateToUserRequest = operations['chat___messages___create-to-user']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesCreateToUserResponse = operations['chat___messages___create-to-user']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesDeleteRequest = operations['chat___messages___delete']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesDeleteResponse = operations['chat___messages___delete']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesReactRequest = operations['chat___messages___react']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesReactResponse = operations['chat___messages___react']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesRoomTimelineRequest = operations['chat___messages___room-timeline']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesRoomTimelineResponse = operations['chat___messages___room-timeline']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesSearchRequest = operations['chat___messages___search']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesSearchResponse = operations['chat___messages___search']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesShowRequest = operations['chat___messages___show']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesShowResponse = operations['chat___messages___show']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesUserTimelineRequest = operations['chat___messages___user-timeline']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesUserTimelineResponse = operations['chat___messages___user-timeline']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoom = components['schemas']['ChatRoom'];
+
+// @public (undocumented)
+type ChatRoomInvitation = components['schemas']['ChatRoomInvitation'];
+
+// @public (undocumented)
+type ChatRoomMembership = components['schemas']['ChatRoomMembership'];
+
+// @public (undocumented)
+type ChatRoomsCreateRequest = operations['chat___rooms___create']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsCreateResponse = operations['chat___rooms___create']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsDeleteRequest = operations['chat___rooms___delete']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsDeleteResponse = operations['chat___rooms___delete']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsInvitationsCreateRequest = operations['chat___rooms___invitations___create']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsInvitationsCreateResponse = operations['chat___rooms___invitations___create']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsInvitationsIgnoreRequest = operations['chat___rooms___invitations___ignore']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsInvitationsIgnoreResponse = operations['chat___rooms___invitations___ignore']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsInvitationsInboxRequest = operations['chat___rooms___invitations___inbox']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsInvitationsInboxResponse = operations['chat___rooms___invitations___inbox']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsJoiningRequest = operations['chat___rooms___joining']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsJoiningResponse = operations['chat___rooms___joining']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsJoinRequest = operations['chat___rooms___join']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsJoinResponse = operations['chat___rooms___join']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsLeaveRequest = operations['chat___rooms___leave']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsLeaveResponse = operations['chat___rooms___leave']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsMembersRequest = operations['chat___rooms___members']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsMembersResponse = operations['chat___rooms___members']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsMuteRequest = operations['chat___rooms___mute']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsMuteResponse = operations['chat___rooms___mute']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsOwnedRequest = operations['chat___rooms___owned']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsOwnedResponse = operations['chat___rooms___owned']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsShowRequest = operations['chat___rooms___show']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsShowResponse = operations['chat___rooms___show']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsUpdateRequest = operations['chat___rooms___update']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsUpdateResponse = operations['chat___rooms___update']['responses']['200']['content']['application/json'];
+
 // @public (undocumented)
 type Clip = components['schemas']['Clip'];
 
@@ -1448,6 +1591,50 @@ declare namespace entities {
         ChartsUserReactionsResponse,
         ChartsUsersRequest,
         ChartsUsersResponse,
+        ChatHistoryRequest,
+        ChatHistoryResponse,
+        ChatMessagesCreateToRoomRequest,
+        ChatMessagesCreateToRoomResponse,
+        ChatMessagesCreateToUserRequest,
+        ChatMessagesCreateToUserResponse,
+        ChatMessagesDeleteRequest,
+        ChatMessagesDeleteResponse,
+        ChatMessagesReactRequest,
+        ChatMessagesReactResponse,
+        ChatMessagesRoomTimelineRequest,
+        ChatMessagesRoomTimelineResponse,
+        ChatMessagesSearchRequest,
+        ChatMessagesSearchResponse,
+        ChatMessagesShowRequest,
+        ChatMessagesShowResponse,
+        ChatMessagesUserTimelineRequest,
+        ChatMessagesUserTimelineResponse,
+        ChatRoomsCreateRequest,
+        ChatRoomsCreateResponse,
+        ChatRoomsDeleteRequest,
+        ChatRoomsDeleteResponse,
+        ChatRoomsInvitationsCreateRequest,
+        ChatRoomsInvitationsCreateResponse,
+        ChatRoomsInvitationsIgnoreRequest,
+        ChatRoomsInvitationsIgnoreResponse,
+        ChatRoomsInvitationsInboxRequest,
+        ChatRoomsInvitationsInboxResponse,
+        ChatRoomsJoinRequest,
+        ChatRoomsJoinResponse,
+        ChatRoomsJoiningRequest,
+        ChatRoomsJoiningResponse,
+        ChatRoomsLeaveRequest,
+        ChatRoomsLeaveResponse,
+        ChatRoomsMembersRequest,
+        ChatRoomsMembersResponse,
+        ChatRoomsMuteRequest,
+        ChatRoomsMuteResponse,
+        ChatRoomsOwnedRequest,
+        ChatRoomsOwnedResponse,
+        ChatRoomsShowRequest,
+        ChatRoomsShowResponse,
+        ChatRoomsUpdateRequest,
+        ChatRoomsUpdateResponse,
         ClipsAddNoteRequest,
         ClipsCreateRequest,
         ClipsCreateResponse,
@@ -1880,7 +2067,12 @@ declare namespace entities {
         MetaDetailedOnly,
         MetaDetailed,
         SystemWebhook,
-        AbuseReportNotificationRecipient
+        AbuseReportNotificationRecipient,
+        ChatMessage,
+        ChatMessageLite,
+        ChatRoom,
+        ChatRoomInvitation,
+        ChatRoomMembership
     }
 }
 export { entities }
@@ -2903,7 +3095,7 @@ type PartialRolePolicyOverride = Partial<{
 }>;
 
 // @public (undocumented)
-export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"];
+export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse", "write:chat", "read:chat"];
 
 // @public (undocumented)
 type PingResponse = operations['ping']['responses']['200']['content']['application/json'];
@@ -3444,8 +3636,8 @@ type V2AdminEmojiListResponse = operations['v2___admin___emoji___list']['respons
 //
 // src/entities.ts:50:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
 // src/streaming.ts:57:3 - (ae-forgotten-export) The symbol "ReconnectingWebSocket" needs to be exported by the entry point index.d.ts
-// src/streaming.types.ts:220:4 - (ae-forgotten-export) The symbol "ReversiUpdateKey" needs to be exported by the entry point index.d.ts
-// src/streaming.types.ts:230:4 - (ae-forgotten-export) The symbol "ReversiUpdateSettings" needs to be exported by the entry point index.d.ts
+// src/streaming.types.ts:217:4 - (ae-forgotten-export) The symbol "ReversiUpdateKey" needs to be exported by the entry point index.d.ts
+// src/streaming.types.ts:227:4 - (ae-forgotten-export) The symbol "ReversiUpdateSettings" needs to be exported by the entry point index.d.ts
 
 // (No @packageDocumentation comment for this package)
 
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index a1543952fc..e12ba0d6b2 100644
--- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts
+++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
@@ -1534,6 +1534,248 @@ declare module '../api.js' {
       credential?: string | null,
     ): Promise<SwitchCaseResponseType<E, P>>;
 
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    request<E extends 'chat/history', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    request<E extends 'chat/messages/create-to-room', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    request<E extends 'chat/messages/create-to-user', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    request<E extends 'chat/messages/delete', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    request<E extends 'chat/messages/react', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    request<E extends 'chat/messages/room-timeline', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    request<E extends 'chat/messages/search', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    request<E extends 'chat/messages/show', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    request<E extends 'chat/messages/user-timeline', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    request<E extends 'chat/rooms/create', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    request<E extends 'chat/rooms/delete', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    request<E extends 'chat/rooms/invitations/create', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    request<E extends 'chat/rooms/invitations/ignore', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    request<E extends 'chat/rooms/invitations/inbox', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    request<E extends 'chat/rooms/join', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    request<E extends 'chat/rooms/joining', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    request<E extends 'chat/rooms/leave', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    request<E extends 'chat/rooms/members', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    request<E extends 'chat/rooms/mute', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    request<E extends 'chat/rooms/owned', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    request<E extends 'chat/rooms/show', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    request<E extends 'chat/rooms/update', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
     /**
      * No description provided.
      * 
@@ -2827,17 +3069,6 @@ declare module '../api.js' {
       credential?: string | null,
     ): Promise<SwitchCaseResponseType<E, P>>;
 
-    /**
-     * No description provided.
-     * 
-     * **Credential required**: *Yes* / **Permission**: *write:account*
-     */
-    request<E extends 'i/read-all-unread-notes', P extends Endpoints[E]['req']>(
-      endpoint: E,
-      params: P,
-      credential?: string | null,
-    ): Promise<SwitchCaseResponseType<E, P>>;
-
     /**
      * No description provided.
      * 
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index 9bb8fb2225..558f4ab514 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -207,6 +207,50 @@ import type {
 	ChartsUserReactionsResponse,
 	ChartsUsersRequest,
 	ChartsUsersResponse,
+	ChatHistoryRequest,
+	ChatHistoryResponse,
+	ChatMessagesCreateToRoomRequest,
+	ChatMessagesCreateToRoomResponse,
+	ChatMessagesCreateToUserRequest,
+	ChatMessagesCreateToUserResponse,
+	ChatMessagesDeleteRequest,
+	ChatMessagesDeleteResponse,
+	ChatMessagesReactRequest,
+	ChatMessagesReactResponse,
+	ChatMessagesRoomTimelineRequest,
+	ChatMessagesRoomTimelineResponse,
+	ChatMessagesSearchRequest,
+	ChatMessagesSearchResponse,
+	ChatMessagesShowRequest,
+	ChatMessagesShowResponse,
+	ChatMessagesUserTimelineRequest,
+	ChatMessagesUserTimelineResponse,
+	ChatRoomsCreateRequest,
+	ChatRoomsCreateResponse,
+	ChatRoomsDeleteRequest,
+	ChatRoomsDeleteResponse,
+	ChatRoomsInvitationsCreateRequest,
+	ChatRoomsInvitationsCreateResponse,
+	ChatRoomsInvitationsIgnoreRequest,
+	ChatRoomsInvitationsIgnoreResponse,
+	ChatRoomsInvitationsInboxRequest,
+	ChatRoomsInvitationsInboxResponse,
+	ChatRoomsJoinRequest,
+	ChatRoomsJoinResponse,
+	ChatRoomsJoiningRequest,
+	ChatRoomsJoiningResponse,
+	ChatRoomsLeaveRequest,
+	ChatRoomsLeaveResponse,
+	ChatRoomsMembersRequest,
+	ChatRoomsMembersResponse,
+	ChatRoomsMuteRequest,
+	ChatRoomsMuteResponse,
+	ChatRoomsOwnedRequest,
+	ChatRoomsOwnedResponse,
+	ChatRoomsShowRequest,
+	ChatRoomsShowResponse,
+	ChatRoomsUpdateRequest,
+	ChatRoomsUpdateResponse,
 	ClipsAddNoteRequest,
 	ClipsCreateRequest,
 	ClipsCreateResponse,
@@ -726,6 +770,28 @@ export type Endpoints = {
 	'charts/user/pv': { req: ChartsUserPvRequest; res: ChartsUserPvResponse };
 	'charts/user/reactions': { req: ChartsUserReactionsRequest; res: ChartsUserReactionsResponse };
 	'charts/users': { req: ChartsUsersRequest; res: ChartsUsersResponse };
+	'chat/history': { req: ChatHistoryRequest; res: ChatHistoryResponse };
+	'chat/messages/create-to-room': { req: ChatMessagesCreateToRoomRequest; res: ChatMessagesCreateToRoomResponse };
+	'chat/messages/create-to-user': { req: ChatMessagesCreateToUserRequest; res: ChatMessagesCreateToUserResponse };
+	'chat/messages/delete': { req: ChatMessagesDeleteRequest; res: ChatMessagesDeleteResponse };
+	'chat/messages/react': { req: ChatMessagesReactRequest; res: ChatMessagesReactResponse };
+	'chat/messages/room-timeline': { req: ChatMessagesRoomTimelineRequest; res: ChatMessagesRoomTimelineResponse };
+	'chat/messages/search': { req: ChatMessagesSearchRequest; res: ChatMessagesSearchResponse };
+	'chat/messages/show': { req: ChatMessagesShowRequest; res: ChatMessagesShowResponse };
+	'chat/messages/user-timeline': { req: ChatMessagesUserTimelineRequest; res: ChatMessagesUserTimelineResponse };
+	'chat/rooms/create': { req: ChatRoomsCreateRequest; res: ChatRoomsCreateResponse };
+	'chat/rooms/delete': { req: ChatRoomsDeleteRequest; res: ChatRoomsDeleteResponse };
+	'chat/rooms/invitations/create': { req: ChatRoomsInvitationsCreateRequest; res: ChatRoomsInvitationsCreateResponse };
+	'chat/rooms/invitations/ignore': { req: ChatRoomsInvitationsIgnoreRequest; res: ChatRoomsInvitationsIgnoreResponse };
+	'chat/rooms/invitations/inbox': { req: ChatRoomsInvitationsInboxRequest; res: ChatRoomsInvitationsInboxResponse };
+	'chat/rooms/join': { req: ChatRoomsJoinRequest; res: ChatRoomsJoinResponse };
+	'chat/rooms/joining': { req: ChatRoomsJoiningRequest; res: ChatRoomsJoiningResponse };
+	'chat/rooms/leave': { req: ChatRoomsLeaveRequest; res: ChatRoomsLeaveResponse };
+	'chat/rooms/members': { req: ChatRoomsMembersRequest; res: ChatRoomsMembersResponse };
+	'chat/rooms/mute': { req: ChatRoomsMuteRequest; res: ChatRoomsMuteResponse };
+	'chat/rooms/owned': { req: ChatRoomsOwnedRequest; res: ChatRoomsOwnedResponse };
+	'chat/rooms/show': { req: ChatRoomsShowRequest; res: ChatRoomsShowResponse };
+	'chat/rooms/update': { req: ChatRoomsUpdateRequest; res: ChatRoomsUpdateResponse };
 	'clips/add-note': { req: ClipsAddNoteRequest; res: EmptyResponse };
 	'clips/create': { req: ClipsCreateRequest; res: ClipsCreateResponse };
 	'clips/delete': { req: ClipsDeleteRequest; res: EmptyResponse };
@@ -841,7 +907,6 @@ export type Endpoints = {
 	'i/page-likes': { req: IPageLikesRequest; res: IPageLikesResponse };
 	'i/pages': { req: IPagesRequest; res: IPagesResponse };
 	'i/pin': { req: IPinRequest; res: IPinResponse };
-	'i/read-all-unread-notes': { req: EmptyRequest; res: EmptyResponse };
 	'i/read-announcement': { req: IReadAnnouncementRequest; res: EmptyResponse };
 	'i/regenerate-token': { req: IRegenerateTokenRequest; res: EmptyResponse };
 	'i/registry/get': { req: IRegistryGetRequest; res: IRegistryGetResponse };
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index f3d26efa69..bb21272e01 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -210,6 +210,50 @@ export type ChartsUserReactionsRequest = operations['charts___user___reactions']
 export type ChartsUserReactionsResponse = operations['charts___user___reactions']['responses']['200']['content']['application/json'];
 export type ChartsUsersRequest = operations['charts___users']['requestBody']['content']['application/json'];
 export type ChartsUsersResponse = operations['charts___users']['responses']['200']['content']['application/json'];
+export type ChatHistoryRequest = operations['chat___history']['requestBody']['content']['application/json'];
+export type ChatHistoryResponse = operations['chat___history']['responses']['200']['content']['application/json'];
+export type ChatMessagesCreateToRoomRequest = operations['chat___messages___create-to-room']['requestBody']['content']['application/json'];
+export type ChatMessagesCreateToRoomResponse = operations['chat___messages___create-to-room']['responses']['200']['content']['application/json'];
+export type ChatMessagesCreateToUserRequest = operations['chat___messages___create-to-user']['requestBody']['content']['application/json'];
+export type ChatMessagesCreateToUserResponse = operations['chat___messages___create-to-user']['responses']['200']['content']['application/json'];
+export type ChatMessagesDeleteRequest = operations['chat___messages___delete']['requestBody']['content']['application/json'];
+export type ChatMessagesDeleteResponse = operations['chat___messages___delete']['responses']['200']['content']['application/json'];
+export type ChatMessagesReactRequest = operations['chat___messages___react']['requestBody']['content']['application/json'];
+export type ChatMessagesReactResponse = operations['chat___messages___react']['responses']['200']['content']['application/json'];
+export type ChatMessagesRoomTimelineRequest = operations['chat___messages___room-timeline']['requestBody']['content']['application/json'];
+export type ChatMessagesRoomTimelineResponse = operations['chat___messages___room-timeline']['responses']['200']['content']['application/json'];
+export type ChatMessagesSearchRequest = operations['chat___messages___search']['requestBody']['content']['application/json'];
+export type ChatMessagesSearchResponse = operations['chat___messages___search']['responses']['200']['content']['application/json'];
+export type ChatMessagesShowRequest = operations['chat___messages___show']['requestBody']['content']['application/json'];
+export type ChatMessagesShowResponse = operations['chat___messages___show']['responses']['200']['content']['application/json'];
+export type ChatMessagesUserTimelineRequest = operations['chat___messages___user-timeline']['requestBody']['content']['application/json'];
+export type ChatMessagesUserTimelineResponse = operations['chat___messages___user-timeline']['responses']['200']['content']['application/json'];
+export type ChatRoomsCreateRequest = operations['chat___rooms___create']['requestBody']['content']['application/json'];
+export type ChatRoomsCreateResponse = operations['chat___rooms___create']['responses']['200']['content']['application/json'];
+export type ChatRoomsDeleteRequest = operations['chat___rooms___delete']['requestBody']['content']['application/json'];
+export type ChatRoomsDeleteResponse = operations['chat___rooms___delete']['responses']['200']['content']['application/json'];
+export type ChatRoomsInvitationsCreateRequest = operations['chat___rooms___invitations___create']['requestBody']['content']['application/json'];
+export type ChatRoomsInvitationsCreateResponse = operations['chat___rooms___invitations___create']['responses']['200']['content']['application/json'];
+export type ChatRoomsInvitationsIgnoreRequest = operations['chat___rooms___invitations___ignore']['requestBody']['content']['application/json'];
+export type ChatRoomsInvitationsIgnoreResponse = operations['chat___rooms___invitations___ignore']['responses']['200']['content']['application/json'];
+export type ChatRoomsInvitationsInboxRequest = operations['chat___rooms___invitations___inbox']['requestBody']['content']['application/json'];
+export type ChatRoomsInvitationsInboxResponse = operations['chat___rooms___invitations___inbox']['responses']['200']['content']['application/json'];
+export type ChatRoomsJoinRequest = operations['chat___rooms___join']['requestBody']['content']['application/json'];
+export type ChatRoomsJoinResponse = operations['chat___rooms___join']['responses']['200']['content']['application/json'];
+export type ChatRoomsJoiningRequest = operations['chat___rooms___joining']['requestBody']['content']['application/json'];
+export type ChatRoomsJoiningResponse = operations['chat___rooms___joining']['responses']['200']['content']['application/json'];
+export type ChatRoomsLeaveRequest = operations['chat___rooms___leave']['requestBody']['content']['application/json'];
+export type ChatRoomsLeaveResponse = operations['chat___rooms___leave']['responses']['200']['content']['application/json'];
+export type ChatRoomsMembersRequest = operations['chat___rooms___members']['requestBody']['content']['application/json'];
+export type ChatRoomsMembersResponse = operations['chat___rooms___members']['responses']['200']['content']['application/json'];
+export type ChatRoomsMuteRequest = operations['chat___rooms___mute']['requestBody']['content']['application/json'];
+export type ChatRoomsMuteResponse = operations['chat___rooms___mute']['responses']['200']['content']['application/json'];
+export type ChatRoomsOwnedRequest = operations['chat___rooms___owned']['requestBody']['content']['application/json'];
+export type ChatRoomsOwnedResponse = operations['chat___rooms___owned']['responses']['200']['content']['application/json'];
+export type ChatRoomsShowRequest = operations['chat___rooms___show']['requestBody']['content']['application/json'];
+export type ChatRoomsShowResponse = operations['chat___rooms___show']['responses']['200']['content']['application/json'];
+export type ChatRoomsUpdateRequest = operations['chat___rooms___update']['requestBody']['content']['application/json'];
+export type ChatRoomsUpdateResponse = operations['chat___rooms___update']['responses']['200']['content']['application/json'];
 export type ClipsAddNoteRequest = operations['clips___add-note']['requestBody']['content']['application/json'];
 export type ClipsCreateRequest = operations['clips___create']['requestBody']['content']['application/json'];
 export type ClipsCreateResponse = operations['clips___create']['responses']['200']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts
index 1a30da4437..0ff9749602 100644
--- a/packages/misskey-js/src/autogen/models.ts
+++ b/packages/misskey-js/src/autogen/models.ts
@@ -54,3 +54,8 @@ export type MetaDetailedOnly = components['schemas']['MetaDetailedOnly'];
 export type MetaDetailed = components['schemas']['MetaDetailed'];
 export type SystemWebhook = components['schemas']['SystemWebhook'];
 export type AbuseReportNotificationRecipient = components['schemas']['AbuseReportNotificationRecipient'];
+export type ChatMessage = components['schemas']['ChatMessage'];
+export type ChatMessageLite = components['schemas']['ChatMessageLite'];
+export type ChatRoom = components['schemas']['ChatRoom'];
+export type ChatRoomInvitation = components['schemas']['ChatRoomInvitation'];
+export type ChatRoomMembership = components['schemas']['ChatRoomMembership'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 743aaf1608..31bc34e473 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -1358,6 +1358,204 @@ export type paths = {
      */
     post: operations['charts___users'];
   };
+  '/chat/history': {
+    /**
+     * chat/history
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    post: operations['chat___history'];
+  };
+  '/chat/messages/create-to-room': {
+    /**
+     * chat/messages/create-to-room
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    post: operations['chat___messages___create-to-room'];
+  };
+  '/chat/messages/create-to-user': {
+    /**
+     * chat/messages/create-to-user
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    post: operations['chat___messages___create-to-user'];
+  };
+  '/chat/messages/delete': {
+    /**
+     * chat/messages/delete
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    post: operations['chat___messages___delete'];
+  };
+  '/chat/messages/react': {
+    /**
+     * chat/messages/react
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    post: operations['chat___messages___react'];
+  };
+  '/chat/messages/room-timeline': {
+    /**
+     * chat/messages/room-timeline
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    post: operations['chat___messages___room-timeline'];
+  };
+  '/chat/messages/search': {
+    /**
+     * chat/messages/search
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    post: operations['chat___messages___search'];
+  };
+  '/chat/messages/show': {
+    /**
+     * chat/messages/show
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    post: operations['chat___messages___show'];
+  };
+  '/chat/messages/user-timeline': {
+    /**
+     * chat/messages/user-timeline
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    post: operations['chat___messages___user-timeline'];
+  };
+  '/chat/rooms/create': {
+    /**
+     * chat/rooms/create
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    post: operations['chat___rooms___create'];
+  };
+  '/chat/rooms/delete': {
+    /**
+     * chat/rooms/delete
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    post: operations['chat___rooms___delete'];
+  };
+  '/chat/rooms/invitations/create': {
+    /**
+     * chat/rooms/invitations/create
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    post: operations['chat___rooms___invitations___create'];
+  };
+  '/chat/rooms/invitations/ignore': {
+    /**
+     * chat/rooms/invitations/ignore
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    post: operations['chat___rooms___invitations___ignore'];
+  };
+  '/chat/rooms/invitations/inbox': {
+    /**
+     * chat/rooms/invitations/inbox
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    post: operations['chat___rooms___invitations___inbox'];
+  };
+  '/chat/rooms/join': {
+    /**
+     * chat/rooms/join
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    post: operations['chat___rooms___join'];
+  };
+  '/chat/rooms/joining': {
+    /**
+     * chat/rooms/joining
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    post: operations['chat___rooms___joining'];
+  };
+  '/chat/rooms/leave': {
+    /**
+     * chat/rooms/leave
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    post: operations['chat___rooms___leave'];
+  };
+  '/chat/rooms/members': {
+    /**
+     * chat/rooms/members
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    post: operations['chat___rooms___members'];
+  };
+  '/chat/rooms/mute': {
+    /**
+     * chat/rooms/mute
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    post: operations['chat___rooms___mute'];
+  };
+  '/chat/rooms/owned': {
+    /**
+     * chat/rooms/owned
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    post: operations['chat___rooms___owned'];
+  };
+  '/chat/rooms/show': {
+    /**
+     * chat/rooms/show
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *read:chat*
+     */
+    post: operations['chat___rooms___show'];
+  };
+  '/chat/rooms/update': {
+    /**
+     * chat/rooms/update
+     * @description No description provided.
+     *
+     * **Credential required**: *Yes* / **Permission**: *write:chat*
+     */
+    post: operations['chat___rooms___update'];
+  };
   '/clips/add-note': {
     /**
      * clips/add-note
@@ -2470,15 +2668,6 @@ export type paths = {
      */
     post: operations['i___pin'];
   };
-  '/i/read-all-unread-notes': {
-    /**
-     * i/read-all-unread-notes
-     * @description No description provided.
-     *
-     * **Credential required**: *Yes* / **Permission**: *write:account*
-     */
-    post: operations['i___read-all-unread-notes'];
-  };
   '/i/read-announcement': {
     /**
      * i/read-announcement
@@ -3848,6 +4037,8 @@ export type components = {
       followingVisibility: 'public' | 'followers' | 'private';
       /** @enum {string} */
       followersVisibility: 'public' | 'followers' | 'private';
+      /** @enum {string} */
+      chatScope: 'everyone' | 'following' | 'followers' | 'mutual' | 'none';
       roles: components['schemas']['RoleLite'][];
       followedMessage?: string | null;
       memo: string | null;
@@ -3894,6 +4085,7 @@ export type components = {
       unreadAnnouncements: components['schemas']['Announcement'][];
       hasUnreadAntenna: boolean;
       hasUnreadChannel: boolean;
+      hasUnreadChatMessages: boolean;
       hasUnreadNotification: boolean;
       hasPendingReceivedFollowRequest: boolean;
       unreadNotificationsCount: number;
@@ -4947,6 +5139,7 @@ export type components = {
       canImportFollowing: boolean;
       canImportMuting: boolean;
       canImportUserLists: boolean;
+      canChat: boolean;
     };
     ReversiGameLite: {
       /** Format: id */
@@ -5146,6 +5339,69 @@ export type components = {
       systemWebhookId?: string;
       systemWebhook?: components['schemas']['SystemWebhook'];
     };
+    ChatMessage: {
+      id: string;
+      /** Format: date-time */
+      createdAt: string;
+      fromUserId: string;
+      fromUser: components['schemas']['UserLite'];
+      toUserId?: string | null;
+      toUser?: components['schemas']['UserLite'] | null;
+      toRoomId?: string | null;
+      toRoom?: components['schemas']['ChatRoom'] | null;
+      text?: string | null;
+      fileId?: string | null;
+      file?: components['schemas']['DriveFile'] | null;
+      isRead?: boolean;
+      reactions: ({
+          reaction: string;
+          user?: components['schemas']['UserLite'] | null;
+        })[];
+    };
+    ChatMessageLite: {
+      id: string;
+      /** Format: date-time */
+      createdAt: string;
+      fromUserId: string;
+      fromUser?: components['schemas']['UserLite'];
+      toUserId?: string | null;
+      toRoomId?: string | null;
+      text?: string | null;
+      fileId?: string | null;
+      file?: components['schemas']['DriveFile'] | null;
+      reactions: ({
+          reaction: string;
+          user?: components['schemas']['UserLite'] | null;
+        })[];
+    };
+    ChatRoom: {
+      id: string;
+      /** Format: date-time */
+      createdAt: string;
+      ownerId: string;
+      owner: components['schemas']['UserLite'];
+      name: string;
+      description: string;
+      isMuted?: boolean;
+    };
+    ChatRoomInvitation: {
+      id: string;
+      /** Format: date-time */
+      createdAt: string;
+      userId: string;
+      user: components['schemas']['UserLite'];
+      roomId: string;
+      room: components['schemas']['ChatRoom'];
+    };
+    ChatRoomMembership: {
+      id: string;
+      /** Format: date-time */
+      createdAt: string;
+      userId: string;
+      user?: components['schemas']['UserLite'];
+      roomId: string;
+      room?: components['schemas']['ChatRoom'];
+    };
   };
   responses: never;
   parameters: never;
@@ -13670,26 +13926,28 @@ export type operations = {
     };
   };
   /**
-   * clips/add-note
+   * chat/history
    * @description No description provided.
    *
-   * **Credential required**: *Yes* / **Permission**: *write:account*
+   * **Credential required**: *Yes* / **Permission**: *read:chat*
    */
-  'clips___add-note': {
+  chat___history: {
     requestBody: {
       content: {
         'application/json': {
-          /** Format: misskey:id */
-          clipId: string;
-          /** Format: misskey:id */
-          noteId: string;
+          /** @default 10 */
+          limit?: number;
+          /** @default false */
+          room?: boolean;
         };
       };
     };
     responses: {
-      /** @description OK (without any results) */
-      204: {
-        content: never;
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['ChatMessage'][];
+        };
       };
       /** @description Client error */
       400: {
@@ -13715,12 +13973,6 @@ export type operations = {
           'application/json': components['schemas']['Error'];
         };
       };
-      /** @description Too many requests */
-      429: {
-        content: {
-          'application/json': components['schemas']['Error'];
-        };
-      };
       /** @description Internal server error */
       500: {
         content: {
@@ -13730,19 +13982,20 @@ export type operations = {
     };
   };
   /**
-   * clips/create
+   * chat/messages/create-to-room
    * @description No description provided.
    *
-   * **Credential required**: *Yes* / **Permission**: *write:account*
+   * **Credential required**: *Yes* / **Permission**: *write:chat*
    */
-  clips___create: {
+  'chat___messages___create-to-room': {
     requestBody: {
       content: {
         'application/json': {
-          name: string;
-          /** @default false */
-          isPublic?: boolean;
-          description?: string | null;
+          text?: string | null;
+          /** Format: misskey:id */
+          fileId?: string;
+          /** Format: misskey:id */
+          toRoomId: string;
         };
       };
     };
@@ -13750,7 +14003,7 @@ export type operations = {
       /** @description OK (with results) */
       200: {
         content: {
-          'application/json': components['schemas']['Clip'];
+          'application/json': components['schemas']['ChatMessageLite'];
         };
       };
       /** @description Client error */
@@ -13777,6 +14030,12 @@ export type operations = {
           'application/json': components['schemas']['Error'];
         };
       };
+      /** @description Too many requests */
+      429: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
       /** @description Internal server error */
       500: {
         content: {
@@ -13786,24 +14045,29 @@ export type operations = {
     };
   };
   /**
-   * clips/delete
+   * chat/messages/create-to-user
    * @description No description provided.
    *
-   * **Credential required**: *Yes* / **Permission**: *write:account*
+   * **Credential required**: *Yes* / **Permission**: *write:chat*
    */
-  clips___delete: {
+  'chat___messages___create-to-user': {
     requestBody: {
       content: {
         'application/json': {
+          text?: string | null;
           /** Format: misskey:id */
-          clipId: string;
+          fileId?: string;
+          /** Format: misskey:id */
+          toUserId: string;
         };
       };
     };
     responses: {
-      /** @description OK (without any results) */
-      204: {
-        content: never;
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['ChatMessageLite'];
+        };
       };
       /** @description Client error */
       400: {
@@ -13829,6 +14093,12 @@ export type operations = {
           'application/json': components['schemas']['Error'];
         };
       };
+      /** @description Too many requests */
+      429: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
       /** @description Internal server error */
       500: {
         content: {
@@ -13838,24 +14108,26 @@ export type operations = {
     };
   };
   /**
-   * clips/favorite
+   * chat/messages/delete
    * @description No description provided.
    *
-   * **Credential required**: *Yes* / **Permission**: *write:clip-favorite*
+   * **Credential required**: *Yes* / **Permission**: *write:chat*
    */
-  clips___favorite: {
+  chat___messages___delete: {
     requestBody: {
       content: {
         'application/json': {
           /** Format: misskey:id */
-          clipId: string;
+          messageId: string;
         };
       };
     };
     responses: {
-      /** @description OK (without any results) */
-      204: {
-        content: never;
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': unknown;
+        };
       };
       /** @description Client error */
       400: {
@@ -13890,17 +14162,26 @@ export type operations = {
     };
   };
   /**
-   * clips/list
+   * chat/messages/react
    * @description No description provided.
    *
-   * **Credential required**: *Yes* / **Permission**: *read:account*
+   * **Credential required**: *Yes* / **Permission**: *write:chat*
    */
-  clips___list: {
+  chat___messages___react: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          messageId: string;
+          reaction: string;
+        };
+      };
+    };
     responses: {
       /** @description OK (with results) */
       200: {
         content: {
-          'application/json': components['schemas']['Clip'][];
+          'application/json': unknown;
         };
       };
       /** @description Client error */
@@ -13936,17 +14217,31 @@ export type operations = {
     };
   };
   /**
-   * clips/my-favorites
+   * chat/messages/room-timeline
    * @description No description provided.
    *
-   * **Credential required**: *Yes* / **Permission**: *read:clip-favorite*
+   * **Credential required**: *Yes* / **Permission**: *read:chat*
    */
-  'clips___my-favorites': {
+  'chat___messages___room-timeline': {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** @default 10 */
+          limit?: number;
+          /** Format: misskey:id */
+          sinceId?: string;
+          /** Format: misskey:id */
+          untilId?: string;
+          /** Format: misskey:id */
+          roomId: string;
+        };
+      };
+    };
     responses: {
       /** @description OK (with results) */
       200: {
         content: {
-          'application/json': components['schemas']['Clip'][];
+          'application/json': components['schemas']['ChatMessageLite'][];
         };
       };
       /** @description Client error */
@@ -13982,17 +14277,1239 @@ export type operations = {
     };
   };
   /**
-   * clips/notes
+   * chat/messages/search
    * @description No description provided.
    *
-   * **Credential required**: *No* / **Permission**: *read:account*
+   * **Credential required**: *Yes* / **Permission**: *read:chat*
    */
-  clips___notes: {
+  chat___messages___search: {
     requestBody: {
       content: {
         'application/json': {
-          /** Format: misskey:id */
-          clipId: string;
+          query: string;
+          /** @default 10 */
+          limit?: number;
+          /** Format: misskey:id */
+          userId?: string | null;
+          /** Format: misskey:id */
+          roomId?: string | null;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['ChatMessage'][];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * chat/messages/show
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *read:chat*
+   */
+  chat___messages___show: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          messageId: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['ChatMessage'];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * chat/messages/user-timeline
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *read:chat*
+   */
+  'chat___messages___user-timeline': {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** @default 10 */
+          limit?: number;
+          /** Format: misskey:id */
+          sinceId?: string;
+          /** Format: misskey:id */
+          untilId?: string;
+          /** Format: misskey:id */
+          userId: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['ChatMessageLite'][];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * chat/rooms/create
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:chat*
+   */
+  chat___rooms___create: {
+    requestBody: {
+      content: {
+        'application/json': {
+          name: string;
+          description?: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['ChatRoom'];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Too many requests */
+      429: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * chat/rooms/delete
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:chat*
+   */
+  chat___rooms___delete: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          roomId: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': unknown;
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * chat/rooms/invitations/create
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:chat*
+   */
+  chat___rooms___invitations___create: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          roomId: string;
+          /** Format: misskey:id */
+          userId: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['ChatRoomInvitation'];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Too many requests */
+      429: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * chat/rooms/invitations/ignore
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:chat*
+   */
+  chat___rooms___invitations___ignore: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          roomId: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': unknown;
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * chat/rooms/invitations/inbox
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *read:chat*
+   */
+  chat___rooms___invitations___inbox: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** @default 30 */
+          limit?: number;
+          /** Format: misskey:id */
+          sinceId?: string;
+          /** Format: misskey:id */
+          untilId?: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['ChatRoomInvitation'][];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * chat/rooms/join
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:chat*
+   */
+  chat___rooms___join: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          roomId: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': unknown;
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * chat/rooms/joining
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *read:chat*
+   */
+  chat___rooms___joining: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** @default 30 */
+          limit?: number;
+          /** Format: misskey:id */
+          sinceId?: string;
+          /** Format: misskey:id */
+          untilId?: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['ChatRoomMembership'][];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * chat/rooms/leave
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:chat*
+   */
+  chat___rooms___leave: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          roomId: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': unknown;
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * chat/rooms/members
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:chat*
+   */
+  chat___rooms___members: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          roomId: string;
+          /** @default 30 */
+          limit?: number;
+          /** Format: misskey:id */
+          sinceId?: string;
+          /** Format: misskey:id */
+          untilId?: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['ChatRoomMembership'][];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * chat/rooms/mute
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:chat*
+   */
+  chat___rooms___mute: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          roomId: string;
+          mute: boolean;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': unknown;
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * chat/rooms/owned
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *read:chat*
+   */
+  chat___rooms___owned: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** @default 30 */
+          limit?: number;
+          /** Format: misskey:id */
+          sinceId?: string;
+          /** Format: misskey:id */
+          untilId?: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['ChatRoom'][];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * chat/rooms/show
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *read:chat*
+   */
+  chat___rooms___show: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          roomId: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['ChatRoom'];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * chat/rooms/update
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:chat*
+   */
+  chat___rooms___update: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          roomId: string;
+          name?: string;
+          description?: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['ChatRoom'];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * clips/add-note
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:account*
+   */
+  'clips___add-note': {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          clipId: string;
+          /** Format: misskey:id */
+          noteId: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (without any results) */
+      204: {
+        content: never;
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Too many requests */
+      429: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * clips/create
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:account*
+   */
+  clips___create: {
+    requestBody: {
+      content: {
+        'application/json': {
+          name: string;
+          /** @default false */
+          isPublic?: boolean;
+          description?: string | null;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['Clip'];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * clips/delete
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:account*
+   */
+  clips___delete: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          clipId: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (without any results) */
+      204: {
+        content: never;
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * clips/favorite
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *write:clip-favorite*
+   */
+  clips___favorite: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          clipId: string;
+        };
+      };
+    };
+    responses: {
+      /** @description OK (without any results) */
+      204: {
+        content: never;
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * clips/list
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *read:account*
+   */
+  clips___list: {
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['Clip'][];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * clips/my-favorites
+   * @description No description provided.
+   *
+   * **Credential required**: *Yes* / **Permission**: *read:clip-favorite*
+   */
+  'clips___my-favorites': {
+    responses: {
+      /** @description OK (with results) */
+      200: {
+        content: {
+          'application/json': components['schemas']['Clip'][];
+        };
+      };
+      /** @description Client error */
+      400: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Authentication error */
+      401: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Forbidden error */
+      403: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description I'm Ai */
+      418: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+      /** @description Internal server error */
+      500: {
+        content: {
+          'application/json': components['schemas']['Error'];
+        };
+      };
+    };
+  };
+  /**
+   * clips/notes
+   * @description No description provided.
+   *
+   * **Credential required**: *No* / **Permission**: *read:account*
+   */
+  clips___notes: {
+    requestBody: {
+      content: {
+        'application/json': {
+          /** Format: misskey:id */
+          clipId: string;
           /** @default 10 */
           limit?: number;
           /** Format: misskey:id */
@@ -20279,50 +21796,6 @@ export type operations = {
       };
     };
   };
-  /**
-   * i/read-all-unread-notes
-   * @description No description provided.
-   *
-   * **Credential required**: *Yes* / **Permission**: *write:account*
-   */
-  'i___read-all-unread-notes': {
-    responses: {
-      /** @description OK (without any results) */
-      204: {
-        content: never;
-      };
-      /** @description Client error */
-      400: {
-        content: {
-          'application/json': components['schemas']['Error'];
-        };
-      };
-      /** @description Authentication error */
-      401: {
-        content: {
-          'application/json': components['schemas']['Error'];
-        };
-      };
-      /** @description Forbidden error */
-      403: {
-        content: {
-          'application/json': components['schemas']['Error'];
-        };
-      };
-      /** @description I'm Ai */
-      418: {
-        content: {
-          'application/json': components['schemas']['Error'];
-        };
-      };
-      /** @description Internal server error */
-      500: {
-        content: {
-          'application/json': components['schemas']['Error'];
-        };
-      };
-    };
-  };
   /**
    * i/read-announcement
    * @description No description provided.
@@ -21089,6 +22562,8 @@ export type operations = {
           followingVisibility?: 'public' | 'followers' | 'private';
           /** @enum {string} */
           followersVisibility?: 'public' | 'followers' | 'private';
+          /** @enum {string} */
+          chatScope?: 'everyone' | 'followers' | 'following' | 'mutual' | 'none';
           /** Format: misskey:id */
           pinnedPageId?: string | null;
           mutedWords?: (string[] | string)[];
diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts
index c5911a70eb..9d0fe5e781 100644
--- a/packages/misskey-js/src/consts.ts
+++ b/packages/misskey-js/src/consts.ts
@@ -37,8 +37,8 @@ export const permissions = [
 	'write:favorites',
 	'read:following',
 	'write:following',
-	'read:messaging',
-	'write:messaging',
+	'read:messaging', // deprecated
+	'write:messaging', // deprecated
 	'read:mutes',
 	'write:mutes',
 	'write:notes',
@@ -110,6 +110,8 @@ export const permissions = [
 	'read:clip-favorite',
 	'read:federation',
 	'write:report-abuse',
+	'write:chat',
+	'read:chat',
 ] as const;
 
 export const moderationLogTypes = [
diff --git a/packages/misskey-js/src/streaming.types.ts b/packages/misskey-js/src/streaming.types.ts
index 26a50f9fa4..8e5438eeb6 100644
--- a/packages/misskey-js/src/streaming.types.ts
+++ b/packages/misskey-js/src/streaming.types.ts
@@ -1,5 +1,6 @@
 import {
 	Antenna,
+	ChatMessage,
 	DriveFile,
 	DriveFolder,
 	Note,
@@ -46,13 +47,9 @@ export type Channels = {
 			urlUploadFinished: (payload: { marker: string; file: DriveFile; }) => void;
 			readAllNotifications: () => void;
 			unreadNotification: (payload: Notification) => void;
-			unreadMention: (payload: Note['id']) => void;
-			readAllUnreadMentions: () => void;
 			notificationFlushed: () => void;
-			unreadSpecifiedNote: (payload: Note['id']) => void;
-			readAllUnreadSpecifiedNotes: () => void;
-			readAllAntennas: () => void;
 			unreadAntenna: (payload: Antenna) => void;
+			newChatMessage: (payload: ChatMessage) => void;
 			readAllAnnouncements: () => void;
 			myTokenRegenerated: () => void;
 			signin: (payload: Signin) => void;
diff --git a/packages/misskey-js/test/streaming.ts b/packages/misskey-js/test/streaming.ts
index 06b55cd8af..7e784cd20c 100644
--- a/packages/misskey-js/test/streaming.ts
+++ b/packages/misskey-js/test/streaming.ts
@@ -42,26 +42,26 @@ describe('Streaming', () => {
 	test('useChannel with parameters', async () => {
 		const server = new WS('wss://misskey.test/streaming');
 		const stream = new Stream('https://misskey.test', { token: 'TOKEN' });
-		const messagingChannelReceived: any[] = [];
-		const messaging = stream.useChannel('messaging', { otherparty: 'aaa' });
-		messaging.on('message', payload => {
-			messagingChannelReceived.push(payload);
+		const chatChannelReceived: any[] = [];
+		const chat = stream.useChannel('chat', { other: 'aaa' });
+		chat.on('message', payload => {
+			chatChannelReceived.push(payload);
 		});
 
 		const ws = await server.connected;
 		expect(new URLSearchParams(new URL(ws.url).search).get('i')).toEqual('TOKEN');
 
 		const msg = JSON.parse(await server.nextMessage as string);
-		const messagingChannelId = msg.body.id;
+		const chatChannelId = msg.body.id;
 		expect(msg.type).toEqual('connect');
-		expect(msg.body.channel).toEqual('messaging');
-		expect(msg.body.params).toEqual({ otherparty: 'aaa' });
-		expect(messagingChannelId != null).toEqual(true);
+		expect(msg.body.channel).toEqual('chat');
+		expect(msg.body.params).toEqual({ other: 'aaa' });
+		expect(chatChannelId != null).toEqual(true);
 
 		server.send(JSON.stringify({
 			type: 'channel',
 			body: {
-				id: messagingChannelId,
+				id: chatChannelId,
 				type: 'message',
 				body: {
 					id: 'foo'
@@ -69,7 +69,7 @@ describe('Streaming', () => {
 			}
 		}));
 
-		expect(messagingChannelReceived[0]).toEqual({
+		expect(chatChannelReceived[0]).toEqual({
 			id: 'foo'
 		});
 
@@ -81,20 +81,20 @@ describe('Streaming', () => {
 		const server = new WS('wss://misskey.test/streaming');
 		const stream = new Stream('https://misskey.test', { token: 'TOKEN' });
 
-		stream.useChannel('messaging', { otherparty: 'aaa' });
-		stream.useChannel('messaging', { otherparty: 'bbb' });
+		stream.useChannel('chat', { other: 'aaa' });
+		stream.useChannel('chat', { other: 'bbb' });
 
 		const ws = await server.connected;
 		expect(new URLSearchParams(new URL(ws.url).search).get('i')).toEqual('TOKEN');
 
 		const msg = JSON.parse(await server.nextMessage as string);
-		const messagingChannelId = msg.body.id;
+		const chatChannelId = msg.body.id;
 		const msg2 = JSON.parse(await server.nextMessage as string);
-		const messagingChannelId2 = msg2.body.id;
+		const chatChannelId2 = msg2.body.id;
 
-		expect(messagingChannelId != null).toEqual(true);
-		expect(messagingChannelId2 != null).toEqual(true);
-		expect(messagingChannelId).not.toEqual(messagingChannelId2);
+		expect(chatChannelId != null).toEqual(true);
+		expect(chatChannelId2 != null).toEqual(true);
+		expect(chatChannelId).not.toEqual(chatChannelId2);
 
 		stream.close();
 		server.close();
@@ -104,8 +104,8 @@ describe('Streaming', () => {
 		const server = new WS('wss://misskey.test/streaming');
 		const stream = new Stream('https://misskey.test', { token: 'TOKEN' });
 
-		const messaging = stream.useChannel('messaging', { otherparty: 'aaa' });
-		messaging.send('read', { id: 'aaa' });
+		const chat = stream.useChannel('chat', { other: 'aaa' });
+		chat.send('read', { id: 'aaa' });
 
 		const ws = await server.connected;
 		expect(new URLSearchParams(new URL(ws.url).search).get('i')).toEqual('TOKEN');
-- 
cgit v1.2.3-freya

