diff --git a/package.json b/package.json
index a035745727..fd565f7cec 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "misskey",
-	"version": "12.111.1",
+	"version": "12.111.1-test.1",
 	"codename": "indigo",
 	"repository": {
 		"type": "git",
diff --git a/packages/client/src/components/global/sticky-container.vue b/packages/client/src/components/global/sticky-container.vue
index 89d397f082..98a7ee9c30 100644
--- a/packages/client/src/components/global/sticky-container.vue
+++ b/packages/client/src/components/global/sticky-container.vue
@@ -1,71 +1,63 @@
 <template>
 <div ref="rootEl">
 	<slot name="header"></slot>
-	<div ref="bodyEl">
+	<div ref="bodyEl" :data-sticky-container-header-height="headerHeight">
 		<slot></slot>
 	</div>
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
+<script lang="ts" setup>
+import { onMounted, onUnmounted } from 'vue';
 
-export default defineComponent({
-	props: {
-		autoSticky: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-	},
+const props = withDefaults(defineProps<{
+	autoSticky?: boolean;
+}>(), {
+	autoSticky: false,
+});
 
-	setup(props, context) {
-		const rootEl = ref<HTMLElement>(null);
-		const bodyEl = ref<HTMLElement>(null);
+const rootEl = $ref<HTMLElement>();
+const bodyEl = $ref<HTMLElement>();
 
-		const calc = () => {
-			const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px';
+let headerHeight = $ref<string | undefined>();
 
-			const header = rootEl.value.children[0];
-			if (header === bodyEl.value) {
-				bodyEl.value.style.setProperty('--stickyTop', currentStickyTop);
-			} else {
-				bodyEl.value.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
+const calc = () => {
+	const currentStickyTop = getComputedStyle(rootEl).getPropertyValue('--stickyTop') || '0px';
 
-				if (props.autoSticky) {
-					header.style.setProperty('--stickyTop', currentStickyTop);
-					header.style.position = 'sticky';
-					header.style.top = 'var(--stickyTop)';
-					header.style.zIndex = '1';
-				}
-			}
-		};
+	const header = rootEl.children[0] as HTMLElement;
+	if (header === bodyEl) {
+		bodyEl.style.setProperty('--stickyTop', currentStickyTop);
+	} else {
+		bodyEl.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
+		headerHeight = header.offsetHeight.toString();
 
-		onMounted(() => {
-			calc();
+		if (props.autoSticky) {
+			header.style.setProperty('--stickyTop', currentStickyTop);
+			header.style.position = 'sticky';
+			header.style.top = 'var(--stickyTop)';
+			header.style.zIndex = '1';
+		}
+	}
+};
 
-			const observer = new MutationObserver(() => {
-				window.setTimeout(() => {
-					calc();
-				}, 100);
-			});
+const observer = new MutationObserver(() => {
+	window.setTimeout(() => {
+		calc();
+	}, 100);
+});
 
-			observer.observe(rootEl.value, {
-				attributes: false,
-				childList: true,
-				subtree: false,
-			});
+onMounted(() => {
+	calc();
 
-			onUnmounted(() => {
-				observer.disconnect();
-			});
-		});
+	observer.observe(rootEl, {
+		attributes: false,
+		childList: true,
+		subtree: false,
+	});
+});
 
-		return {
-			rootEl,
-			bodyEl,
-		};
-	},
+onUnmounted(() => {
+	observer.disconnect();
 });
 </script>
 
diff --git a/packages/client/src/directives/sticky-container.ts b/packages/client/src/directives/sticky-container.ts
index 9610eba4da..3cf813054b 100644
--- a/packages/client/src/directives/sticky-container.ts
+++ b/packages/client/src/directives/sticky-container.ts
@@ -5,8 +5,10 @@ export default {
 		//const query = binding.value;
 
 		const header = src.children[0];
+		const body = src.children[1];
 		const currentStickyTop = getComputedStyle(src).getPropertyValue('--stickyTop') || '0px';
 		src.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
+		if (body) body.dataset.stickyContainerHeaderHeight = header.offsetHeight.toString();
 		header.style.setProperty('--stickyTop', currentStickyTop);
 		header.style.position = 'sticky';
 		header.style.top = 'var(--stickyTop)';
diff --git a/packages/client/src/pages/messaging/messaging-room.form.vue b/packages/client/src/pages/messaging/messaging-room.form.vue
index 8e779c4f39..38bab90502 100644
--- a/packages/client/src/pages/messaging/messaging-room.form.vue
+++ b/packages/client/src/pages/messaging/messaging-room.form.vue
@@ -1,222 +1,222 @@
 <template>
-<div class="pemppnzi _block"
+<div
+	class="pemppnzi _block"
 	@dragover.stop="onDragover"
 	@drop.stop="onDrop"
 >
 	<textarea
-		ref="text"
+		ref="textEl"
 		v-model="text"
-		:placeholder="$ts.inputMessageHere"
+		:placeholder="i18n.ts.inputMessageHere"
 		@keydown="onKeydown"
 		@compositionupdate="onCompositionUpdate"
 		@paste="onPaste"
 	></textarea>
-	<div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
-	<button class="send _button" :disabled="!canSend || sending" :title="$ts.send" @click="send">
-		<template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>
-	</button>
-	<button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button>
-	<button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button>
-	<input ref="file" type="file" @change="onChangeFile"/>
+	<footer>
+		<div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
+		<div class="buttons">
+			<button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button>
+			<button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button>
+			<button class="send _button" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send">
+				<template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>
+			</button>
+		</div>
+	</footer>
+	<input ref="fileEl" type="file" @change="onChangeFile"/>
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
-import insertTextAtCursor from 'insert-text-at-cursor';
+<script lang="ts" setup>
+import { onMounted, watch } from 'vue';
+import * as Misskey from 'misskey-js';
 import autosize from 'autosize';
+//import insertTextAtCursor from 'insert-text-at-cursor';
+import { throttle } from 'throttle-debounce';
 import { formatTimeString } from '@/scripts/format-time-string';
 import { selectFile } from '@/scripts/select-file';
 import * as os from '@/os';
 import { stream } from '@/stream';
-import { Autocomplete } from '@/scripts/autocomplete';
-import { throttle } from 'throttle-debounce';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+//import { Autocomplete } from '@/scripts/autocomplete';
 import { uploadFile } from '@/scripts/upload';
 
-export default defineComponent({
-	props: {
-		user: {
-			type: Object,
-			requird: false,
-		},
-		group: {
-			type: Object,
-			requird: false,
-		},
-	},
-	data() {
-		return {
-			text: null,
-			file: null,
-			sending: false,
-			typing: throttle(3000, () => {
-				stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id });
-			}),
-		};
-	},
-	computed: {
-		draftKey(): string {
-			return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
-		},
-		canSend(): boolean {
-			return (this.text != null && this.text !== '') || this.file != null;
-		},
-		room(): any {
-			return this.$parent;
+const props = defineProps<{
+	user?: Misskey.entities.UserDetailed | null;
+	group?: Misskey.entities.UserGroup | null;
+}>();
+
+let textEl = $ref<HTMLTextAreaElement>();
+let fileEl = $ref<HTMLInputElement>();
+
+let text = $ref<string>('');
+let file = $ref<Misskey.entities.DriveFile | null>(null);
+let sending = $ref(false);
+const typing = throttle(3000, () => {
+	stream.send('typingOnMessaging', props.user ? { partner: props.user.id } : { group: props.group?.id });
+});
+
+let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id);
+let canSend = $computed(() => (text != null && text !== '') || file != null);
+
+watch([$$(text), $$(file)], saveDraft);
+
+async function onPaste(ev: ClipboardEvent) {
+	if (!ev.clipboardData) return;
+
+	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), defaultStore.state.pastedFileName).replace(/{{number}}/g, '1') + ext;
+			if (formatted) upload(pastedFile, formatted);
 		}
-	},
-	watch: {
-		text() {
-			this.saveDraft();
-		},
-		file() {
-			this.saveDraft();
-		}
-	},
-	mounted() {
-		autosize(this.$refs.text);
-
-		// TODO: detach when unmount
-		// TODO
-		//new Autocomplete(this.$refs.text, this, { model: 'text' });
-
-		// 書きかけの投稿を復元
-		const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey];
-		if (draft) {
-			this.text = draft.data.text;
-			this.file = draft.data.file;
-		}
-	},
-	methods: {
-		async onPaste(evt: ClipboardEvent) {
-			const items = evt.clipboardData.items;
-
-			if (items.length === 1) {
-				if (items[0].kind === 'file') {
-					const file = items[0].getAsFile();
-					const lio = file.name.lastIndexOf('.');
-					const ext = lio >= 0 ? file.name.slice(lio) : '';
-					const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, '1')}${ext}`;
-					if (formatted) this.upload(file, formatted);
-				}
-			} else {
-				if (items[0].kind === 'file') {
-					os.alert({
-						type: 'error',
-						text: this.$ts.onlyOneFileCanBeAttached
-					});
-				}
-			}
-		},
-
-		onDragover(evt) {
-			const isFile = evt.dataTransfer.items[0].kind === 'file';
-			const isDriveFile = evt.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
-			if (isFile || isDriveFile) {
-				evt.preventDefault();
-				evt.dataTransfer.dropEffect = evt.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
-			}
-		},
-
-		onDrop(evt): void {
-			// ファイルだったら
-			if (evt.dataTransfer.files.length === 1) {
-				evt.preventDefault();
-				this.upload(evt.dataTransfer.files[0]);
-				return;
-			} else if (evt.dataTransfer.files.length > 1) {
-				evt.preventDefault();
-				os.alert({
-					type: 'error',
-					text: this.$ts.onlyOneFileCanBeAttached
-				});
-				return;
-			}
-
-			//#region ドライブのファイル
-			const driveFile = evt.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
-			if (driveFile != null && driveFile !== '') {
-				this.file = JSON.parse(driveFile);
-				evt.preventDefault();
-			}
-			//#endregion
-		},
-
-		onKeydown(evt) {
-			this.typing();
-			if ((evt.which === 10 || evt.which === 13) && (evt.ctrlKey || evt.metaKey) && this.canSend) {
-				this.send();
-			}
-		},
-
-		onCompositionUpdate() {
-			this.typing();
-		},
-
-		chooseFile(evt) {
-			selectFile(evt.currentTarget ?? evt.target, this.$ts.selectFile).then(file => {
-				this.file = file;
+	} else {
+		if (items[0].kind === 'file') {
+			os.alert({
+				type: 'error',
+				text: i18n.ts.onlyOneFileCanBeAttached,
 			});
-		},
-
-		onChangeFile() {
-			this.upload((this.$refs.file as any).files[0]);
-		},
-
-		upload(file: File, name?: string) {
-			uploadFile(file, this.$store.state.uploadFolder, name).then(res => {
-				this.file = res;
-			});
-		},
-
-		send() {
-			this.sending = true;
-			os.api('messaging/messages/create', {
-				userId: this.user ? this.user.id : undefined,
-				groupId: this.group ? this.group.id : undefined,
-				text: this.text ? this.text : undefined,
-				fileId: this.file ? this.file.id : undefined
-			}).then(message => {
-				this.clear();
-			}).catch(err => {
-				console.error(err);
-			}).then(() => {
-				this.sending = false;
-			});
-		},
-
-		clear() {
-			this.text = '';
-			this.file = null;
-			this.deleteDraft();
-		},
-
-		saveDraft() {
-			const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
-
-			drafts[this.draftKey] = {
-				updatedAt: new Date(),
-				data: {
-					text: this.text,
-					file: this.file
-				}
-			};
-
-			localStorage.setItem('message_drafts', JSON.stringify(drafts));
-		},
-
-		deleteDraft() {
-			const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
-
-			delete drafts[this.draftKey];
-
-			localStorage.setItem('message_drafts', JSON.stringify(drafts));
-		},
-
-		async insertEmoji(ev) {
-			os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, this.$refs.text);
 		}
 	}
+}
+
+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();
+		ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
+	}
+}
+
+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 = JSON.parse(driveFile);
+		ev.preventDefault();
+	}
+	//#endregion
+}
+
+function onKeydown(ev: KeyboardEvent) {
+	typing();
+	if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey) && canSend) {
+		send();
+	}
+}
+
+function onCompositionUpdate() {
+	typing();
+}
+
+function chooseFile(ev: MouseEvent) {
+	selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => {
+		file = selectedFile;
+	});
+}
+
+function onChangeFile() {
+	if (fileEl.files![0]) upload(fileEl.files[0]);
+}
+
+function upload(fileToUpload: File, name?: string) {
+	uploadFile(fileToUpload, defaultStore.state.uploadFolder, name).then(res => {
+		file = res;
+	});
+}
+
+function send() {
+	sending = true;
+	os.api('messaging/messages/create', {
+		userId: props.user ? props.user.id : undefined,
+		groupId: props.group ? props.group.id : undefined,
+		text: text ? text : undefined,
+		fileId: file ? file.id : undefined,
+	}).then(message => {
+		clear();
+	}).catch(err => {
+		console.error(err);
+	}).then(() => {
+		sending = false;
+	});
+}
+
+function clear() {
+	text = '';
+	file = null;
+	deleteDraft();
+}
+
+function saveDraft() {
+	const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
+
+	drafts[draftKey] = {
+		updatedAt: new Date(),
+		// eslint-disable-next-line id-denylist
+		data: {
+			text: text,
+			file: file,
+		},
+	};
+
+	localStorage.setItem('message_drafts', JSON.stringify(drafts));
+}
+
+function deleteDraft() {
+	const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
+
+	delete drafts[draftKey];
+
+	localStorage.setItem('message_drafts', JSON.stringify(drafts));
+}
+
+async function insertEmoji(ev: MouseEvent) {
+	os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textEl);
+}
+
+onMounted(() => {
+	autosize(textEl);
+
+	// TODO: detach when unmount
+	// TODO
+	//new Autocomplete(textEl, this, { model: 'text' });
+
+	// 書きかけの投稿を復元
+	const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[draftKey];
+	if (draft) {
+		text = draft.data.text;
+		file = draft.data.file;
+	}
+});
+
+defineExpose({
+	file,
+	upload,
 });
 </script>
 
@@ -230,7 +230,7 @@ export default defineComponent({
 		width: 100%;
 		min-width: 100%;
 		max-width: 100%;
-		height: 80px;
+		min-height: 80px;
 		margin: 0;
 		padding: 16px 16px 0 16px;
 		resize: none;
@@ -245,26 +245,16 @@ export default defineComponent({
 		color: var(--fg);
 	}
 
-	> .file {
-		padding: 8px;
-		color: #444;
-		background: #eee;
-		cursor: pointer;
-	}
-
-	> .send {
-		position: absolute;
+	footer {
+		position: sticky;
 		bottom: 0;
-		right: 0;
-		margin: 0;
-		padding: 16px;
-		font-size: 1em;
-		transition: color 0.1s ease;
-		color: var(--accent);
+		background: var(--panel);
 
-		&:active {
-			color: var(--accentDarken);
-			transition: color 0s ease;
+		> .file {
+			padding: 8px;
+			color: var(--fg);
+			background: transparent;
+			cursor: pointer;
 		}
 	}
 
@@ -316,21 +306,39 @@ export default defineComponent({
 		}
 	}
 
-	._button {
-		margin: 0;
-		padding: 16px;
-		font-size: 1em;
-		font-weight: normal;
-		text-decoration: none;
-		transition: color 0.1s ease;
+	.buttons {
+		display: flex;
 
-		&:hover {
-			color: var(--accent);
+		._button {
+			margin: 0;
+			padding: 16px;
+			font-size: 1em;
+			font-weight: normal;
+			text-decoration: none;
+			transition: color 0.1s ease;
+
+			&:hover {
+				color: var(--accent);
+			}
+
+			&:active {
+				color: var(--accentDarken);
+				transition: color 0s ease;
+			}
 		}
 
-		&:active {
-			color: var(--accentDarken);
-			transition: color 0s ease;
+		> .send {
+			margin-left: auto;
+			color: var(--accent);
+
+			&:hover {
+				color: var(--accentLighten);
+			}
+
+			&:active {
+				color: var(--accentDarken);
+				transition: color 0s ease;
+			}
 		}
 	}
 
diff --git a/packages/client/src/pages/messaging/messaging-room.message.vue b/packages/client/src/pages/messaging/messaging-room.message.vue
index 4315bbecdb..393d2a17b2 100644
--- a/packages/client/src/pages/messaging/messaging-room.message.vue
+++ b/packages/client/src/pages/messaging/messaging-room.message.vue
@@ -35,45 +35,28 @@
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
 import * as mfm from 'mfm-js';
+import * as Misskey from 'misskey-js';
 import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
 import MkUrlPreview from '@/components/url-preview.vue';
 import * as os from '@/os';
+import { $i } from '@/account';
 
-export default defineComponent({
-	components: {
-		MkUrlPreview
-	},
-	props: {
-		message: {
-			required: true
-		},
-		isGroup: {
-			required: false
-		}
-	},
-	computed: {
-		isMe(): boolean {
-			return this.message.userId === this.$i.id;
-		},
-		urls(): string[] {
-			if (this.message.text) {
-				return extractUrlFromMfm(mfm.parse(this.message.text));
-			} else {
-				return [];
-			}
-		}
-	},
-	methods: {
-		del() {
-			os.api('messaging/messages/delete', {
-				messageId: this.message.id
-			});
-		}
-	}
-});
+const props = defineProps<{
+	message: Misskey.entities.MessagingMessage;
+	isGroup?: boolean;
+}>();
+
+const isMe = $computed(() => props.message.userId === $i?.id);
+const urls = $computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
+
+function del(): void {
+	os.api('messaging/messages/delete', {
+		messageId: props.message.id,
+	});
+}
 </script>
 
 <style lang="scss" scoped>
@@ -266,6 +249,7 @@ export default defineComponent({
 	&.isMe {
 		flex-direction: row-reverse;
 		padding-right: var(--margin);
+		right: var(--margin); // 削除時にposition: absoluteになったときに使う
 
 		> .content {
 			padding-right: 16px;
diff --git a/packages/client/src/pages/messaging/messaging-room.vue b/packages/client/src/pages/messaging/messaging-room.vue
index fd1962218a..65c67e6354 100644
--- a/packages/client/src/pages/messaging/messaging-room.vue
+++ b/packages/client/src/pages/messaging/messaging-room.vue
@@ -1,379 +1,302 @@
 <template>
-<div class="_section"
+<div
+	ref="rootEl"
+	class="_section"
 	@dragover.prevent.stop="onDragover"
 	@drop.prevent.stop="onDrop"
 >
 	<div class="_content mk-messaging-room">
 		<div class="body">
-			<MkLoading v-if="fetching"/>
-			<p v-if="!fetching && messages.length == 0" class="empty"><i class="fas fa-info-circle"></i>{{ $ts.noMessagesYet }}</p>
-			<p v-if="!fetching && messages.length > 0 && !existMoreMessages" class="no-history"><i class="fas fa-flag"></i>{{ $ts.noMoreHistory }}</p>
-			<button v-show="existMoreMessages" ref="loadMore" class="more _button" :class="{ fetching: fetchingMoreMessages }" :disabled="fetchingMoreMessages" @click="fetchMoreMessages">
-				<template v-if="fetchingMoreMessages"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ fetchingMoreMessages ? $ts.loading : $ts.loadMore }}
-			</button>
-			<XList v-if="messages.length > 0" v-slot="{ item: message }" class="messages" :items="messages" direction="up" reversed>
-				<XMessage :key="message.id" :message="message" :is-group="group != null"/>
-			</XList>
+			<MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination">
+				<template #empty>
+					<div class="_fullinfo">
+						<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+						<div>{{ i18n.ts.noMessagesYet }}</div>
+					</div>
+				</template>
+
+				<template #default="{ items: messages, fetching: pFetching }">
+					<XList
+						v-if="messages.length > 0"
+						v-slot="{ item: message }"
+						:class="{ messages: true, 'deny-move-transition': pFetching }"
+						:items="messages"
+						direction="up"
+						reversed
+					>
+						<XMessage :key="message.id" :message="message" :is-group="group != null"/>
+					</XList>
+				</template>
+			</MkPagination>
 		</div>
 		<footer>
 			<div v-if="typers.length > 0" class="typers">
-				<I18n :src="$ts.typingUsers" text-tag="span" class="users">
+				<I18n :src="i18n.ts.typingUsers" text-tag="span" class="users">
 					<template #users>
-						<b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b>
+						<b v-for="typer in typers" :key="typer.id" class="user">{{ typer.username }}</b>
 					</template>
 				</I18n>
 				<MkEllipsis/>
 			</div>
-			<transition :name="$store.state.animation ? 'fade' : ''">
+			<transition :name="animation ? 'fade' : ''">
 				<div v-show="showIndicator" class="new-message">
-					<button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-arrow-circle-down"></i>{{ $ts.newMessageExists }}</button>
+					<button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button>
 				</div>
 			</transition>
-			<XForm v-if="!fetching" ref="form" :user="user" :group="group" class="form"/>
+			<XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/>
 		</footer>
 	</div>
 </div>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent, markRaw } from 'vue';
-import XList from '@/components/date-separated-list.vue';
+<script lang="ts" setup>
+import { computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue';
+import * as Misskey from 'misskey-js';
+import * as Acct from 'misskey-js/built/acct';
 import XMessage from './messaging-room.message.vue';
 import XForm from './messaging-room.form.vue';
-import * as Acct from 'misskey-js/built/acct';
-import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll';
+import XList from '@/components/date-separated-list.vue';
+import MkPagination, { Paging } from '@/components/ui/pagination.vue';
+import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scroll';
 import * as os from '@/os';
 import { stream } from '@/stream';
-import { popout } from '@/scripts/popout';
 import * as sound from '@/scripts/sound';
 import * as symbols from '@/symbols';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
+import { defaultStore } from '@/store';
 
-const Component = defineComponent({
-	components: {
-		XMessage,
-		XForm,
-		XList,
-	},
+const props = defineProps<{
+	userAcct?: string;
+	groupId?: string;
+}>();
 
-	inject: ['inWindow'],
+let rootEl = $ref<HTMLDivElement>();
+let formEl = $ref<InstanceType<typeof XForm>>();
+let pagingComponent = $ref<InstanceType<typeof MkPagination>>();
 
-	props: {
-		userAcct: {
-			type: String,
-			required: false,
-		},
-		groupId: {
-			type: String,
-			required: false,
-		},
-	},
+let fetching = $ref(true);
+let user: Misskey.entities.UserDetailed | null = $ref(null);
+let group: Misskey.entities.UserGroup | null = $ref(null);
+let typers: Misskey.entities.User[] = $ref([]);
+let connection: Misskey.ChannelConnection<Misskey.Channels['messaging']> | null = $ref(null);
+let showIndicator = $ref(false);
+const {
+	animation,
+} = defaultStore.reactiveState;
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => !this.fetching ? this.user ? {
-				userName: this.user,
-				avatar: this.user,
-				action: {
-					icon: 'fas fa-ellipsis-h',
-					handler: this.menu,
-				},
-			} : {
-				title: this.group.name,
-				icon: 'fas fa-users',
-				action: {
-					icon: 'fas fa-ellipsis-h',
-					handler: this.menu,
-				},
-			} : null),
-			fetching: true,
-			user: null,
-			group: null,
-			fetchingMoreMessages: false,
-			messages: [],
-			existMoreMessages: false,
-			connection: null,
-			showIndicator: false,
-			timer: null,
-			typers: [],
-			ilObserver: new IntersectionObserver(
-				(entries) => entries.some((entry) => entry.isIntersecting)
-					&& !this.fetching
-					&& !this.fetchingMoreMessages
-					&& this.existMoreMessages
-					&& this.fetchMoreMessages()
-			),
-		};
-	},
+let pagination: Paging | null = $ref(null);
 
-	computed: {
-		form(): any {
-			return this.$refs.form;
-		}
-	},
-
-	watch: {
-		userAcct: 'fetch',
-		groupId: 'fetch',
-	},
-
-	mounted() {
-		this.fetch();
-		if (this.$store.state.enableInfiniteScroll) {
-			this.$nextTick(() => this.ilObserver.observe(this.$refs.loadMore as Element));
-		}
-	},
-
-	beforeUnmount() {
-		this.connection.dispose();
-
-		document.removeEventListener('visibilitychange', this.onVisibilitychange);
-
-		this.ilObserver.disconnect();
-	},
-
-	methods: {
-		async fetch() {
-			this.fetching = true;
-			if (this.userAcct) {
-				const user = await os.api('users/show', Acct.parse(this.userAcct));
-				this.user = user;
-			} else {
-				const group = await os.api('users/groups/show', { groupId: this.groupId });
-				this.group = group;
-			}
-
-			this.connection = markRaw(stream.useChannel('messaging', {
-				otherparty: this.user ? this.user.id : undefined,
-				group: this.group ? this.group.id : undefined,
-			}));
-
-			this.connection.on('message', this.onMessage);
-			this.connection.on('read', this.onRead);
-			this.connection.on('deleted', this.onDeleted);
-			this.connection.on('typers', typers => {
-				this.typers = typers.filter(u => u.id !== this.$i.id);
-			});
-
-			document.addEventListener('visibilitychange', this.onVisibilitychange);
-
-			this.fetchMessages().then(() => {
-				this.scrollToBottom();
-
-				// もっと見るの交差検知を発火させないためにfetchは
-				// スクロールが終わるまでfalseにしておく
-				// scrollendのようなイベントはないのでsetTimeoutで
-				window.setTimeout(() => this.fetching = false, 300);
-			});
-		},
-
-		onDragover(evt) {
-			const isFile = evt.dataTransfer.items[0].kind === 'file';
-			const isDriveFile = evt.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
-
-			if (isFile || isDriveFile) {
-				evt.dataTransfer.dropEffect = evt.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
-			} else {
-				evt.dataTransfer.dropEffect = 'none';
-			}
-		},
-
-		onDrop(evt): void {
-			// ファイルだったら
-			if (evt.dataTransfer.files.length === 1) {
-				this.form.upload(evt.dataTransfer.files[0]);
-				return;
-			} else if (evt.dataTransfer.files.length > 1) {
-				os.alert({
-					type: 'error',
-					text: this.$ts.onlyOneFileCanBeAttached
-				});
-				return;
-			}
-
-			//#region ドライブのファイル
-			const driveFile = evt.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
-			if (driveFile != null && driveFile !== '') {
-				const file = JSON.parse(driveFile);
-				this.form.file = file;
-			}
-			//#endregion
-		},
-
-		fetchMessages() {
-			return new Promise((resolve, reject) => {
-				const max = this.existMoreMessages ? 20 : 10;
-
-				os.api('messaging/messages', {
-					userId: this.user ? this.user.id : undefined,
-					groupId: this.group ? this.group.id : undefined,
-					limit: max + 1,
-					untilId: this.existMoreMessages ? this.messages[0].id : undefined
-				}).then(messages => {
-					if (messages.length === max + 1) {
-						this.existMoreMessages = true;
-						messages.pop();
-					} else {
-						this.existMoreMessages = false;
-					}
-
-					this.messages.unshift.apply(this.messages, messages.reverse());
-					resolve();
-				});
-			});
-		},
-
-		fetchMoreMessages() {
-			this.fetchingMoreMessages = true;
-			this.fetchMessages().then(() => {
-				this.fetchingMoreMessages = false;
-			});
-		},
-
-		onMessage(message) {
-			sound.play('chat');
-
-			const _isBottom = isBottom(this.$el, 64);
-
-			this.messages.push(message);
-			if (message.userId !== this.$i.id && !document.hidden) {
-				this.connection.send('read', {
-					id: message.id
-				});
-			}
-
-			if (_isBottom) {
-				// Scroll to bottom
-				this.$nextTick(() => {
-					this.scrollToBottom();
-				});
-			} else if (message.userId !== this.$i.id) {
-				// Notify
-				this.notifyNewMessage();
-			}
-		},
-
-		onRead(x) {
-			if (this.user) {
-				if (!Array.isArray(x)) x = [x];
-				for (const id of x) {
-					if (this.messages.some(x => x.id === id)) {
-						const exist = this.messages.map(x => x.id).indexOf(id);
-						this.messages[exist] = {
-							...this.messages[exist],
-							isRead: true,
-						};
-					}
-				}
-			} else if (this.group) {
-				for (const id of x.ids) {
-					if (this.messages.some(x => x.id === id)) {
-						const exist = this.messages.map(x => x.id).indexOf(id);
-						this.messages[exist] = {
-							...this.messages[exist],
-							reads: [...this.messages[exist].reads, x.userId]
-						};
-					}
-				}
-			}
-		},
-
-		onDeleted(id) {
-			const msg = this.messages.find(m => m.id === id);
-			if (msg) {
-				this.messages = this.messages.filter(m => m.id !== msg.id);
-			}
-		},
-
-		scrollToBottom() {
-			scroll(this.$el, { top: this.$el.offsetHeight });
-		},
-
-		onIndicatorClick() {
-			this.showIndicator = false;
-			this.scrollToBottom();
-		},
-
-		notifyNewMessage() {
-			this.showIndicator = true;
-
-			onScrollBottom(this.$el, () => {
-				this.showIndicator = false;
-			});
-
-			if (this.timer) window.clearTimeout(this.timer);
-
-			this.timer = window.setTimeout(() => {
-				this.showIndicator = false;
-			}, 4000);
-		},
-
-		onVisibilitychange() {
-			if (document.hidden) return;
-			for (const message of this.messages) {
-				if (message.userId !== this.$i.id && !message.isRead) {
-					this.connection.send('read', {
-						id: message.id
-					});
-				}
-			}
-		},
-
-		menu(ev) {
-			const path = this.groupId ? `/my/messaging/group/${this.groupId}` : `/my/messaging/${this.userAcct}`;
-
-			os.popupMenu([this.inWindow ? undefined : {
-				text: this.$ts.openInWindow,
-				icon: 'fas fa-window-maximize',
-				action: () => {
-					os.pageWindow(path);
-					this.$router.back();
-				},
-			}, this.inWindow ? undefined : {
-				text: this.$ts.popout,
-				icon: 'fas fa-external-link-alt',
-				action: () => {
-					popout(path);
-					this.$router.back();
-				},
-			}], ev.currentTarget ?? ev.target);
-		}
-	}
+watch([() => props.userAcct, () => props.groupId], () => {
+	if (connection) connection.dispose();
+	fetch();
 });
 
-export default Component;
+async function fetch() {
+	fetching = true;
+
+	if (props.userAcct) {
+		const acct = Acct.parse(props.userAcct);
+		user = await os.api('users/show', { username: acct.username, host: acct.host || undefined });
+		group = null;
+		
+		pagination = {
+			endpoint: 'messaging/messages',
+			limit: 20,
+			params: {
+				userId: user.id,
+			},
+			reversed: true,
+			pageEl: $$(rootEl).value,
+		};
+		connection = stream.useChannel('messaging', {
+			otherparty: user.id,
+		});
+	} else {
+		user = null;
+		group = await os.api('users/groups/show', { groupId: props.groupId });
+
+		pagination = {
+			endpoint: 'messaging/messages',
+			limit: 20,
+			params: {
+				groupId: group?.id,
+			},
+			reversed: true,
+			pageEl: $$(rootEl).value,
+		};
+		connection = stream.useChannel('messaging', {
+			group: group?.id,
+		});
+	}
+
+	connection.on('message', onMessage);
+	connection.on('read', onRead);
+	connection.on('deleted', onDeleted);
+	connection.on('typers', _typers => {
+		typers = _typers.filter(u => u.id !== $i?.id);
+	});
+
+	document.addEventListener('visibilitychange', onVisibilitychange);
+
+	nextTick(() => {
+		thisScrollToBottom();
+		window.setTimeout(() => {
+			fetching = false;
+		}, 300);
+	});
+}
+
+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.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
+	} else {
+		ev.dataTransfer.dropEffect = 'none';
+	}
+}
+
+function onDrop(ev: DragEvent): void {
+	if (!ev.dataTransfer) return;
+
+	// ファイルだったら
+	if (ev.dataTransfer.files.length === 1) {
+		formEl.upload(ev.dataTransfer.files[0]);
+		return;
+	} else if (ev.dataTransfer.files.length > 1) {
+		os.alert({
+			type: 'error',
+			text: i18n.ts.onlyOneFileCanBeAttached,
+		});
+		return;
+	}
+
+	//#region ドライブのファイル
+	const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+	if (driveFile != null && driveFile !== '') {
+		const file = JSON.parse(driveFile);
+		formEl.file = file;
+	}
+	//#endregion
+}
+
+function onMessage(message) {
+	sound.play('chat');
+
+	const _isBottom = isBottomVisible(rootEl, 64);
+
+	pagingComponent.prepend(message);
+	if (message.userId !== $i?.id && !document.hidden) {
+		connection?.send('read', {
+			id: message.id,
+		});
+	}
+
+	if (_isBottom) {
+		// Scroll to bottom
+		nextTick(() => {
+			thisScrollToBottom();
+		});
+	} else if (message.userId !== $i?.id) {
+		// Notify
+		notifyNewMessage();
+	}
+}
+
+function onRead(x) {
+	if (user) {
+		if (!Array.isArray(x)) x = [x];
+		for (const id of x) {
+			if (pagingComponent.items.some(y => y.id === id)) {
+				const exist = pagingComponent.items.map(y => y.id).indexOf(id);
+				pagingComponent.items[exist] = {
+					...pagingComponent.items[exist],
+					isRead: true,
+				};
+			}
+		}
+	} else if (group) {
+		for (const id of x.ids) {
+			if (pagingComponent.items.some(y => y.id === id)) {
+				const exist = pagingComponent.items.map(y => y.id).indexOf(id);
+				pagingComponent.items[exist] = {
+					...pagingComponent.items[exist],
+					reads: [...pagingComponent.items[exist].reads, x.userId],
+				};
+			}
+		}
+	}
+}
+
+function onDeleted(id) {
+	const msg = pagingComponent.items.find(m => m.id === id);
+	if (msg) {
+		pagingComponent.items = pagingComponent.items.filter(m => m.id !== msg.id);
+	}
+}
+
+function thisScrollToBottom() {
+	scrollToBottom($$(rootEl).value, { behavior: 'smooth' });
+}
+
+function onIndicatorClick() {
+	showIndicator = false;
+	thisScrollToBottom();
+}
+
+let scrollRemove: (() => void) | null = $ref(null);
+
+function notifyNewMessage() {
+	showIndicator = true;
+
+	scrollRemove = onScrollBottom(rootEl, () => {
+		showIndicator = false;
+		scrollRemove = null;
+	});
+}
+
+function onVisibilitychange() {
+	if (document.hidden) return;
+	for (const message of pagingComponent.items) {
+		if (message.userId !== $i?.id && !message.isRead) {
+			connection?.send('read', {
+				id: message.id,
+			});
+		}
+	}
+}
+
+onMounted(() => {
+	fetch();
+});
+
+onBeforeUnmount(() => {
+	connection?.dispose();
+	document.removeEventListener('visibilitychange', onVisibilitychange);
+	if (scrollRemove) scrollRemove();
+});
+
+defineExpose({
+	[symbols.PAGE_INFO]: computed(() => !fetching ? user ? {
+		userName: user,
+		avatar: user,
+	} : {
+		title: group?.name,
+		icon: 'fas fa-users',
+	} : null),
+});
 </script>
 
 <style lang="scss" scoped>
 .mk-messaging-room {
+	position: relative;
+
 	> .body {
-		> .empty {
-			width: 100%;
-			margin: 0;
-			padding: 16px 8px 8px 8px;
-			text-align: center;
-			font-size: 0.8em;
-			opacity: 0.5;
-
-			i {
-				margin-right: 4px;
-			}
-		}
-
-		> .no-history {
-			display: block;
-			margin: 0;
-			padding: 16px;
-			text-align: center;
-			font-size: 0.8em;
-			color: var(--messagingRoomInfo);
-			opacity: 0.5;
-
-			i {
-				margin-right: 4px;
-			}
-		}
-
-		> .more {
+		.more {
 			display: block;
 			margin: 16px auto;
 			padding: 0 12px;
@@ -399,7 +322,9 @@ export default Component;
 			}
 		}
 
-		> .messages {
+		.messages {
+			padding: 8px 0;
+
 			> ::v-deep(*) {
 				margin-bottom: 16px;
 			}
@@ -408,29 +333,31 @@ export default Component;
 
 	> footer {
 		width: 100%;
-		position: relative;
+		position: sticky;
+		z-index: 2;
+		bottom: 0;
+		padding-top: 8px;
+
+		@media (max-width: 500px) {
+			bottom: calc(env(safe-area-inset-bottom, 0px) + 92px);
+		}
 
 		> .new-message {
-			position: absolute;
-			top: -48px;
 			width: 100%;
-			padding: 8px 0;
+			padding-bottom: 8px;
 			text-align: center;
 
 			> button {
 				display: inline-block;
 				margin: 0;
-				padding: 0 12px 0 30px;
+				padding: 0 12px;
 				line-height: 32px;
 				font-size: 12px;
 				border-radius: 16px;
 
 				> i {
-					position: absolute;
-					top: 0;
-					left: 10px;
-					line-height: 32px;
-					font-size: 16px;
+					display: inline-block;
+					margin-right: 8px;
 				}
 			}
 		}
@@ -455,6 +382,8 @@ export default Component;
 		}
 
 		> .form {
+			max-height: 12em;
+			overflow-y: scroll;
 			border-top: solid 0.5px var(--divider);
 		}
 	}
diff --git a/packages/client/src/scripts/scroll.ts b/packages/client/src/scripts/scroll.ts
index 621fe88105..0643bad2fb 100644
--- a/packages/client/src/scripts/scroll.ts
+++ b/packages/client/src/scripts/scroll.ts
@@ -1,9 +1,13 @@
 type ScrollBehavior = 'auto' | 'smooth' | 'instant';
 
-export function getScrollContainer(el: Element | null): Element | null {
-	if (el == null || el.tagName === 'BODY') return null;
+export function getScrollContainer(el: HTMLElement | null): HTMLElement | null {
+	if (el == null || el.tagName === 'HTML') return null;
 	const overflow = window.getComputedStyle(el).getPropertyValue('overflow');
-	if (overflow.endsWith('auto')) { // xとyを個別に指定している場合、hidden auto みたいな値になる
+	if (
+		// xとyを個別に指定している場合、`hidden scroll`みたいな値になる
+		overflow.endsWith('scroll') ||
+		overflow.endsWith('auto')
+	) {
 		return el;
 	} else {
 		return getScrollContainer(el.parentElement);
@@ -22,6 +26,11 @@ export function isTopVisible(el: Element | null): boolean {
 	return scrollTop <= topPosition;
 }
 
+export function isBottomVisible(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;
+}
+
 export function onScrollTop(el: Element, cb) {
 	const container = getScrollContainer(el) || window;
 	const onScroll = ev => {