diff --git a/src/client/components/global/avatar.vue b/src/client/components/global/avatar.vue
index 9f8b0eeca..d2f25fa41 100644
--- a/src/client/components/global/avatar.vue
+++ b/src/client/components/global/avatar.vue
@@ -1,8 +1,8 @@
 <template>
-<span class="eiwwqkts" :class="{ cat }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick">
+<span class="eiwwqkts _noSelect" :class="{ cat }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick">
 	<img class="inner" :src="url" decoding="async"/>
 </span>
-<MkA class="eiwwqkts" :class="{ cat }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id">
+<MkA class="eiwwqkts _noSelect" :class="{ cat }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id">
 	<img class="inner" :src="url" decoding="async"/>
 </MkA>
 </template>
diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue
index 997380919..bd6d5bb4f 100644
--- a/src/client/components/notes.vue
+++ b/src/client/components/notes.vue
@@ -8,7 +8,7 @@
 	<MkError v-if="error" @retry="init()"/>
 
 	<div v-show="more && reversed" style="margin-bottom: var(--margin);">
-		<button class="_loadMore" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+		<button class="_buttonPrimary" @click="fetchMoreFeature" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
 			<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
 			<template v-if="moreFetching"><MkLoading inline/></template>
 		</button>
@@ -19,7 +19,7 @@
 	</XList>
 
 	<div v-show="more && !reversed" style="margin-top: var(--margin);">
-		<button class="_loadMore" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+		<button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
 			<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
 			<template v-if="moreFetching"><MkLoading inline/></template>
 		</button>
diff --git a/src/client/components/notifications.vue b/src/client/components/notifications.vue
index 9759cc239..552b22dd3 100644
--- a/src/client/components/notifications.vue
+++ b/src/client/components/notifications.vue
@@ -5,7 +5,7 @@
 		<XNotification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/>
 	</XList>
 
-	<button class="_loadMore" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+	<button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
 		<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
 		<template v-if="moreFetching"><MkLoading inline/></template>
 	</button>
diff --git a/src/client/components/sidebar.vue b/src/client/components/sidebar.vue
index 251f68527..d07dd294a 100644
--- a/src/client/components/sidebar.vue
+++ b/src/client/components/sidebar.vue
@@ -55,6 +55,14 @@ import { sidebarDef } from '@/sidebar';
 import { getAccounts, addAccount, login } from '@/account';
 
 export default defineComponent({
+	props: {
+		defaultHidden: {
+			type: Boolean,
+			required: false,
+			default: false,
+		}
+	},
+
 	data() {
 		return {
 			host: host,
@@ -63,7 +71,7 @@ export default defineComponent({
 			connection: null,
 			menuDef: sidebarDef,
 			iconOnly: false,
-			hidden: false,
+			hidden: this.defaultHidden,
 			faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram
 		};
 	},
@@ -112,7 +120,9 @@ export default defineComponent({
 	methods: {
 		calcViewState() {
 			this.iconOnly = (window.innerWidth <= 1279) || (this.$store.state.sidebarDisplay === 'icon');
-			this.hidden = (window.innerWidth <= 650);
+			if (!this.defaultHidden) {
+				this.hidden = (window.innerWidth <= 650);
+			}
 		},
 
 		show() {
diff --git a/src/client/directives/follow-append.ts b/src/client/directives/follow-append.ts
index 26f9e9f82..9490dcf78 100644
--- a/src/client/directives/follow-append.ts
+++ b/src/client/directives/follow-append.ts
@@ -3,12 +3,24 @@ import { getScrollContainer, getScrollPosition } from '@/scripts/scroll';
 
 export default {
 	mounted(src, binding, vn) {
-		const ro = new ResizeObserver((entries, observer) => {
-			const pos = getScrollPosition(src);
-			const container = getScrollContainer(src);
+		if (binding.value === false) return;
+
+		let isBottom = true;
+
+		const container = getScrollContainer(src)!;
+		container.addEventListener('scroll', () => {
+			const pos = getScrollPosition(container);
 			const viewHeight = container.clientHeight;
 			const height = container.scrollHeight;
-			if (pos + viewHeight > height - 32) {
+			isBottom = (pos + viewHeight > height - 32);
+			console.log(isBottom);
+		}, { passive: true });
+		container.scrollTop = container.scrollHeight;
+
+		const ro = new ResizeObserver((entries, observer) => {
+			console.log(isBottom);
+			if (isBottom) {
+				const height = container.scrollHeight;
 				container.scrollTop = height;
 			}
 		});
@@ -20,6 +32,6 @@ export default {
 	},
 
 	unmounted(src, binding, vn) {
-		src._ro_.unobserve(src);
+		if (src._ro_) src._ro_.unobserve(src);
 	}
 } as Directive;
diff --git a/src/client/init.ts b/src/client/init.ts
index 17feca4c8..c3be85a85 100644
--- a/src/client/init.ts
+++ b/src/client/init.ts
@@ -182,6 +182,7 @@ const app = createApp(await (
 	!$i                               ? import('@/ui/visitor.vue') :
 	ui === 'deck'                     ? import('@/ui/deck.vue') :
 	ui === 'desktop'                  ? import('@/ui/desktop.vue') :
+	ui === 'chat'                     ? import('@/ui/chat/index.vue') :
 	import('@/ui/default.vue')
 ).then(x => x.default));
 
diff --git a/src/client/scripts/paging.ts b/src/client/scripts/paging.ts
index 3d9668f10..a8f122412 100644
--- a/src/client/scripts/paging.ts
+++ b/src/client/scripts/paging.ts
@@ -1,9 +1,11 @@
 import { markRaw } from 'vue';
 import * as os from '@/os';
-import { onScrollTop, isTopVisible } from './scroll';
+import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from './scroll';
 
 const SECOND_FETCH_LIMIT = 30;
 
+// reversed: items 配列の中身を逆順にする(新しい方が最後)
+
 export default (opts) => ({
 	emits: ['queue'],
 
@@ -122,10 +124,41 @@ export default (opts) => ({
 				limit: SECOND_FETCH_LIMIT + 1,
 				...(this.pagination.offsetMode ? {
 					offset: this.offset,
-				} : this.pagination.reversed ? {
-					sinceId: this.items[0].id,
 				} : {
-					untilId: this.items[this.items.length - 1].id,
+					untilId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
+				}),
+			}).then(items => {
+				for (const item of items) {
+					markRaw(item);
+				}
+				if (items.length > SECOND_FETCH_LIMIT) {
+					items.pop();
+					this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
+					this.more = true;
+				} else {
+					this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
+					this.more = false;
+				}
+				this.offset += items.length;
+				this.moreFetching = false;
+			}, e => {
+				this.moreFetching = false;
+			});
+		},
+
+		async fetchMoreFeature() {
+			if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return;
+			this.moreFetching = true;
+			let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
+			if (params && params.then) params = await params;
+			const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
+			await os.api(endpoint, {
+				...params,
+				limit: SECOND_FETCH_LIMIT + 1,
+				...(this.pagination.offsetMode ? {
+					offset: this.offset,
+				} : {
+					sinceId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
 				}),
 			}).then(items => {
 				for (const item of items) {
@@ -147,25 +180,44 @@ export default (opts) => ({
 		},
 
 		prepend(item) {
-			const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el));
-
-			if (isTop) {
-				// Prepend the item
-				this.items.unshift(item);
-
-				// オーバーフローしたら古いアイテムは捨てる
-				if (this.items.length >= opts.displayLimit) {
-					this.items = this.items.slice(0, opts.displayLimit);
-					this.more = true;
-				}
-			} else {
-				this.queue.push(item);
-				onScrollTop(this.$el, () => {
-					for (const item of this.queue) {
-						this.prepend(item);
+			if (this.pagination.reversed) {
+				const container = getScrollContainer(this.$el);
+				const pos = getScrollPosition(this.$el);
+				const viewHeight = container.clientHeight;
+				const height = container.scrollHeight;
+				const isBottom = (pos + viewHeight > height - 32);
+				if (isBottom) {
+					// オーバーフローしたら古いアイテムは捨てる
+					if (this.items.length >= opts.displayLimit) {
+						this.items = this.items.slice(-opts.displayLimit);
+						this.more = true;
 					}
-					this.queue = [];
-				});
+				} else {
+					
+				}
+				this.items.push(item);
+				// TODO
+			} else {
+				const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el));
+
+				if (isTop) {
+					// Prepend the item
+					this.items.unshift(item);
+
+					// オーバーフローしたら古いアイテムは捨てる
+					if (this.items.length >= opts.displayLimit) {
+						this.items = this.items.slice(0, opts.displayLimit);
+						this.more = true;
+					}
+				} else {
+					this.queue.push(item);
+					onScrollTop(this.$el, () => {
+						for (const item of this.queue) {
+							this.prepend(item);
+						}
+						this.queue = [];
+					});
+				}
 			}
 		},
 
diff --git a/src/client/scripts/scroll.ts b/src/client/scripts/scroll.ts
index 18c336689..bc6d1530c 100644
--- a/src/client/scripts/scroll.ts
+++ b/src/client/scripts/scroll.ts
@@ -54,6 +54,14 @@ export function scroll(el: Element, top: number) {
 	}
 }
 
+export function scrollToTop(el: Element) {
+	scroll(el, 0);
+}
+
+export function scrollToBottom(el: Element) {
+	scroll(el, 99999); // TODO: ちゃんと計算する
+}
+
 export function isBottom(el: Element, asobi = 0) {
 	const container = getScrollContainer(el);
 	const current = container
diff --git a/src/client/sidebar.ts b/src/client/sidebar.ts
index 98a70d2d1..d7822e9e0 100644
--- a/src/client/sidebar.ts
+++ b/src/client/sidebar.ts
@@ -141,6 +141,12 @@ export const sidebarDef = {
 					localStorage.setItem('ui', 'deck');
 					location.reload();
 				}
+			}, {
+				text: 'Chat (β)',
+				action: () => {
+					localStorage.setItem('ui', 'chat');
+					location.reload();
+				}
 			}, {
 				text: i18n.locale.desktop + ' (β)',
 				action: () => {
diff --git a/src/client/style.scss b/src/client/style.scss
index 1ac9b4e0b..14e8c8731 100644
--- a/src/client/style.scss
+++ b/src/client/style.scss
@@ -308,13 +308,6 @@ hr {
 	box-shadow: none;
 }
 
-._loadMore {
-	@extend ._panel;
-	@extend ._button;
-	width: 100%;
-	padding: 12px 0;
-}
-
 ._borderButton {
 	@extend ._button;
 	display: block;
diff --git a/src/client/ui/_common_/header.vue b/src/client/ui/_common_/header.vue
index f662f6144..f150653a8 100644
--- a/src/client/ui/_common_/header.vue
+++ b/src/client/ui/_common_/header.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="fdidabkb" :style="`--height:${height};`">
+<div class="fdidabkb" :class="{ center }" :style="`--height:${height};`">
 	<transition :name="$store.state.animation ? 'header' : ''" mode="out-in" appear>
 		<button class="_button back" v-if="withBack && canBack" @click.stop="back()"><Fa :icon="faChevronLeft"/></button>
 	</transition>
@@ -31,6 +31,11 @@ export default defineComponent({
 			required: false,
 			default: true,
 		},
+		center: {
+			type: Boolean,
+			required: false,
+			default: true,
+		},
 	},
 
 	data() {
@@ -67,7 +72,9 @@ export default defineComponent({
 
 <style lang="scss" scoped>
 .fdidabkb {
-	text-align: center;
+	&.center {
+		text-align: center;
+	}
 
 	> .back {
 		height: var(--height);
@@ -111,8 +118,13 @@ export default defineComponent({
 		right: 0;
 	}
 
+	&.center {
+		> .titleContainer {
+			margin: 0 auto;
+		}
+	}
+
 	> .titleContainer {
-		margin: 0 auto;
 		overflow: auto;
 		white-space: nowrap;
 
diff --git a/src/client/ui/chat/date-separated-list.vue b/src/client/ui/chat/date-separated-list.vue
new file mode 100644
index 000000000..eb671510a
--- /dev/null
+++ b/src/client/ui/chat/date-separated-list.vue
@@ -0,0 +1,154 @@
+<script lang="ts">
+import { defineComponent, h, TransitionGroup } from 'vue';
+import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
+
+export default defineComponent({
+	props: {
+		items: {
+			type: Array,
+			required: true,
+		},
+		direction: {
+			type: String,
+			required: false,
+			default: 'down'
+		},
+		reversed: {
+			type: Boolean,
+			required: false,
+			default: false
+		}
+	},
+
+	methods: {
+		focus() {
+			this.$slots.default[0].elm.focus();
+		}
+	},
+
+	render() {
+		const getDateText = (time: string) => {
+			const date = new Date(time).getDate();
+			const month = new Date(time).getMonth() + 1;
+			return this.$t('monthAndDay', {
+				month: month.toString(),
+				day: date.toString()
+			});
+		}
+
+		return h(!this.reversed ? TransitionGroup : 'div', !this.reversed ? {
+			class: 'hmjzthxl',
+			name: 'list',
+			tag: 'div',
+			'data-direction': this.direction,
+			'data-reversed': this.reversed ? 'true' : 'false',
+		} : {
+			class: 'hmjzthxl',
+		}, this.items.map((item, i) => {
+			const el = this.$slots.default({
+				item: item
+			})[0];
+			if (el.key == null && item.id) el.key = item.id;
+
+			if (
+				i != this.items.length - 1 &&
+				new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate() &&
+				!item._prId_ &&
+				!this.items[i + 1]._prId_ &&
+				!item._featuredId_ &&
+				!this.items[i + 1]._featuredId_
+			) {
+				const separator = h('div', {
+					class: 'separator',
+					key: item.id + ':separator',
+				}, h('p', {
+					class: 'date'
+				}, [
+					h('span', [
+						h(FontAwesomeIcon, {
+							class: 'icon',
+							icon: faAngleUp,
+						}),
+						getDateText(item.createdAt)
+					]),
+					h('span', [
+						getDateText(this.items[i + 1].createdAt),
+						h(FontAwesomeIcon, {
+							class: 'icon',
+							icon: faAngleDown,
+						})
+					])
+				]));
+
+				return [el, separator];
+			} else {
+				return el;
+			}
+		}));
+	},
+});
+</script>
+
+<style lang="scss">
+.hmjzthxl {
+	> .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);
+	}
+
+	&[data-direction="up"] {
+		> .list-enter-from {
+			opacity: 0;
+			transform: translateY(64px);
+		}
+	}
+
+	&[data-direction="down"] {
+		> .list-enter-from {
+			opacity: 0;
+			transform: translateY(-64px);
+		}
+	}
+}
+</style>
+
+<style lang="scss">
+.hmjzthxl {
+	> .separator {
+		text-align: center;
+
+		> .date {
+			display: inline-block;
+			position: relative;
+			margin: 0;
+			padding: 0 16px;
+			line-height: 32px;
+			text-align: center;
+			font-size: 12px;
+			color: var(--dateLabelFg);
+
+			> span {
+				&:first-child {
+					margin-right: 8px;
+
+					> .icon {
+						margin-right: 8px;
+					}
+				}
+
+				&:last-child {
+					margin-left: 8px;
+
+					> .icon {
+						margin-left: 8px;
+					}
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/src/client/ui/chat/index.vue b/src/client/ui/chat/index.vue
new file mode 100644
index 000000000..a2df8ab13
--- /dev/null
+++ b/src/client/ui/chat/index.vue
@@ -0,0 +1,389 @@
+<template>
+<div class="mk-app" @contextmenu.self.prevent="onContextmenu">
+	<XSidebar ref="menu" class="menu" :default-hidden="true"/>
+
+	<div class="nav">
+		<header class="header">
+			<div class="left">
+				<button class="_button account" @click="openAccountMenu">
+					<MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/>
+				</button>
+			</div>
+			<div class="right">
+				<MkA class="item" to="/my/notifications"><Fa :icon="faBell"/></MkA>
+			</div>
+		</header>
+		<div class="body">
+			<div class="container">
+				<div class="header">{{ $ts.timeline }}</div>
+				<div class="body">
+					<MkA to="/timeline/home" class="item" :class="{ active: tl === 'home' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.home }}</MkA>
+					<MkA to="/timeline/local" class="item" :class="{ active: tl === 'local' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.local }}</MkA>
+					<MkA to="/timeline/social" class="item" :class="{ active: tl === 'social' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.social }}</MkA>
+					<MkA to="/timeline/global" class="item" :class="{ active: tl === 'global' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.global }}</MkA>
+				</div>
+			</div>
+			<div class="container" v-if="lists">
+				<div class="header">{{ $ts.lists }}</div>
+				<div class="body">
+					<MkA v-for="list in lists" :key="list.id" :to="`/my/list/${ list.id }`" class="item" :class="{ active: tl === `list:${ list.id }` }"><Fa :icon="faListUl" class="icon"/>{{ list.name }}</MkA>
+				</div>
+			</div>
+			<div class="container" v-if="antennas">
+				<div class="header">{{ $ts.antennas }}</div>
+				<div class="body">
+					<MkA v-for="antenna in antennas" :key="antenna.id" :to="`/my/antenna/${ antenna.id }`" class="item" :class="{ active: tl === `antenna:${ antenna.id }` }"><Fa :icon="faSatellite" class="icon"/>{{ antenna.name }}</MkA>
+				</div>
+			</div>
+			<div class="container" v-if="followedChannels">
+				<div class="header">{{ $ts.channel }}</div>
+				<div class="body">
+					<MkA v-for="channel in followedChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }`, read: !channel.hasUnreadNote }"><Fa :icon="faSatelliteDish" class="icon"/>{{ channel.name }}</MkA>
+				</div>
+			</div>
+			<div class="container" v-if="featuredChannels">
+				<div class="header">{{ $ts.channel }}</div>
+				<div class="body">
+					<MkA v-for="channel in featuredChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }` }"><Fa :icon="faSatelliteDish" class="icon"/>{{ channel.name }}</MkA>
+				</div>
+			</div>
+		</div>
+		<footer class="footer">
+			<div class="left">
+				<button class="_button menu" @click="showMenu">
+					<Fa :icon="faBars"/>
+				</button>
+			</div>
+			<div class="right">
+				<MkA class="item" to="/settings"><Fa :icon="faCog"/></MkA>
+			</div>
+		</footer>
+	</div>
+
+	<main class="main" @contextmenu.stop="onContextmenu">
+		<header class="header" ref="header" @click="onHeaderClick">
+			<div v-if="tl === 'home'">
+				<Fa :icon="faHome" class="icon"/>
+				<div class="title">{{ $ts._timelines.home }}</div>
+			</div>
+			<div v-else-if="tl === 'local'">
+				<Fa :icon="faShareAlt" class="icon"/>
+				<div class="title">{{ $ts._timelines.local }}</div>
+			</div>
+			<div v-else-if="tl === 'social'">
+				<Fa :icon="faShareAlt" class="icon"/>
+				<div class="title">{{ $ts._timelines.social }}</div>
+			</div>
+			<div v-else-if="tl === 'global'">
+				<Fa :icon="faShareAlt" class="icon"/>
+				<div class="title">{{ $ts._timelines.global }}</div>
+			</div>
+		</header>
+		<div class="body">
+			<XTimeline v-if="tl.startsWith('channel:')" src="channel" :key="tl" :channel="tl.replace('channel:', '')"/>
+			<XTimeline v-else :src="tl" :key="tl"/>
+		</div>
+		<footer class="footer">
+			<XPostForm v-if="tl.startsWith('channel:')" :key="tl" :channel="tl.replace('channel:', '')"/>
+			<XPostForm v-else/>
+		</footer>
+	</main>
+
+	<XSide class="side" ref="side"/>
+
+	<XCommon/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { faLayerGroup, faBars, faHome, faCircle, faWindowMaximize, faColumns, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog } from '@fortawesome/free-solid-svg-icons';
+import { faBell } from '@fortawesome/free-regular-svg-icons';
+import { instanceName } from '@/config';
+import XSidebar from '@/components/sidebar.vue';
+import XCommon from '../_common_/common.vue';
+import XSide from './side.vue';
+import XTimeline from './timeline.vue';
+import XPostForm from './post-form.vue';
+import * as os from '@/os';
+import { sidebarDef } from '@/sidebar';
+
+export default defineComponent({
+	components: {
+		XCommon,
+		XSidebar,
+		XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる
+		XTimeline,
+		XPostForm,
+	},
+
+	provide() {
+		return {
+			navHook: (path) => {
+				switch (path) {
+					case '/timeline/home': this.tl = 'home'; return;
+					case '/timeline/local': this.tl = 'local'; return;
+					case '/timeline/social': this.tl = 'social'; return;
+					case '/timeline/global': this.tl = 'global'; return;
+
+					default:
+						if (path.startsWith('/channels/')) {
+							this.tl = `channel:${ path.replace('/channels/', '') }`;
+							return;
+						}
+						//os.pageWindow(path);
+						this.$refs.side.navigate(path);
+						break;
+				}
+			},
+			sideViewHook: (path) => {
+				this.$refs.side.navigate(path);
+			}
+		};
+	},
+
+	data() {
+		return {
+			tl: 'home',
+			lists: null,
+			antennas: null,
+			followedChannels: null,
+			featuredChannels: null,
+			menuDef: sidebarDef,
+			faLayerGroup, faBars, faBell, faHome, faCircle, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog,
+		};
+	},
+
+	created() {
+		os.api('users/lists/list').then(lists => {
+			this.lists = lists;
+		});
+
+		os.api('antennas/list').then(antennas => {
+			this.antennas = antennas;
+		});
+
+		os.api('channels/followed').then(channels => {
+			this.followedChannels = channels;
+		});
+
+		os.api('channels/featured').then(channels => {
+			this.featuredChannels = channels;
+		});
+	},
+
+	methods: {
+		showMenu() {
+			this.$refs.menu.show();
+		},
+
+		post() {
+			os.post();
+		},
+
+		top() {
+			window.scroll({ top: 0, behavior: 'smooth' });
+		},
+
+		onTransition() {
+			if (window._scroll) window._scroll();
+		},
+
+		onHeaderClick() {
+			window.scroll({ top: 0, behavior: 'smooth' });
+		},
+
+		onContextmenu(e) {
+			const isLink = (el: HTMLElement) => {
+				if (el.tagName === 'A') return true;
+				if (el.parentElement) {
+					return isLink(el.parentElement);
+				}
+			};
+			if (isLink(e.target)) return;
+			if (['INPUT', 'TEXTAREA'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return;
+			if (window.getSelection().toString() !== '') return;
+			const path = this.$route.path;
+			os.contextMenu([{
+				type: 'label',
+				text: path,
+			}, {
+				icon: faColumns,
+				text: this.$ts.openInSideView,
+				action: () => {
+					this.$refs.side.navigate(path);
+				}
+			}, {
+				icon: faWindowMaximize,
+				text: this.$ts.openInWindow,
+				action: () => {
+					os.pageWindow(path);
+				}
+			}], e);
+		},
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-app {
+	$header-height: 54px; // TODO: どこかに集約したい
+	$ui-font-size: 1em; // TODO: どこかに集約したい
+
+	// ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
+	min-height: calc(var(--vh, 1vh) * 100);
+	box-sizing: border-box;
+	display: flex;
+
+	> .nav {
+		display: flex;
+		flex-direction: column;
+		width: 250px;
+		height: 100vh;
+		border-right: solid 1px var(--divider);
+
+		> .header, > .footer {
+			$padding: 8px;
+			display: flex;
+			z-index: 1000;
+			height: $header-height;
+			padding: $padding;
+			box-sizing: border-box;
+			line-height: ($header-height - ($padding * 2));
+			user-select: none;
+
+			&.header {
+				border-bottom: solid 1px var(--divider);
+			}
+
+			&.footer {
+				border-top: solid 1px var(--divider);
+			}
+
+			> .left {
+				> .account {
+					display: flex;
+					align-items: center;
+					padding: 0 8px;
+
+					> .avatar {
+						width: 26px;
+						height: 26px;
+						margin-right: 8px;
+					}
+				}
+			}
+
+			> .right {
+				margin-left: auto;
+
+				> .item {
+					height: ($header-height - ($padding * 2));
+					width: ($header-height - ($padding * 2));
+					padding: 10px;
+					box-sizing: border-box;
+					margin-right: 4px;
+					opacity: 0.6;
+				}
+			}
+		}
+
+		> .body {
+			flex: 1;
+			min-width: 0;
+			overflow: auto;
+
+			> .container {
+				& + .container {
+					margin-top: 16px;
+				}
+
+				> .header {
+					font-size: 0.9em;
+					padding: 8px 16px;
+					opacity: 0.7;
+				}
+
+				> .body {
+					padding: 0 8px;
+
+					> .item {
+						display: block;
+						padding: 6px 8px;
+						border-radius: 4px;
+
+						&:hover {
+							text-decoration: none;
+							background: rgba(0, 0, 0, 0.05);
+						}
+
+						&.active, &.active:hover {
+							background: var(--accent);
+							color: #fff;
+						}
+
+						&.read {
+							opacity: 0.5;
+						}
+
+						> .icon {
+							margin-right: 6px;
+							opacity: 0.6;
+						}
+					}
+				}
+			}
+		}
+	}
+
+	> .main {
+		display: flex;
+		flex: 1;
+		flex-direction: column;
+		min-width: 0;
+		height: 100vh;
+		position: relative;
+		background: var(--panel);
+
+		> .header {
+			$padding: 8px;
+			z-index: 1000;
+			height: $header-height;
+			padding: $padding;
+			box-sizing: border-box;
+			line-height: ($header-height - ($padding * 2));
+			font-weight: bold;
+			background-color: var(--header);
+			border-bottom: solid 1px var(--divider);
+			user-select: none;
+
+			> div {
+				display: flex;
+
+				> .icon {
+					height: ($header-height - ($padding * 2));
+					width: ($header-height - ($padding * 2));
+					padding: 10px;
+					box-sizing: border-box;
+					margin-right: 4px;
+					opacity: 0.6;
+				}
+			}
+		}
+
+		> .footer {
+			padding: 16px;
+		}
+
+		> .body {
+			flex: 1;
+			min-width: 0;
+			overflow: auto;
+		}
+	}
+
+	> .side {
+		border-left: solid 1px var(--divider);
+	}
+}
+</style>
diff --git a/src/client/ui/chat/note-header.vue b/src/client/ui/chat/note-header.vue
new file mode 100644
index 000000000..cda8ae00e
--- /dev/null
+++ b/src/client/ui/chat/note-header.vue
@@ -0,0 +1,115 @@
+<template>
+<header class="dehvdgxo">
+	<MkA class="name" :to="userPage(note.user)" v-user-preview="note.user.id">
+		<MkUserName :user="note.user"/>
+	</MkA>
+	<span class="is-bot" v-if="note.user.isBot">bot</span>
+	<span class="username"><MkAcct :user="note.user"/></span>
+	<span class="admin" v-if="note.user.isAdmin"><Fa :icon="faBookmark"/></span>
+	<span class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><Fa :icon="farBookmark"/></span>
+	<div class="info">
+		<span class="mobile" v-if="note.viaMobile"><Fa :icon="faMobileAlt"/></span>
+		<MkA class="created-at" :to="notePage(note)">
+			<MkTime :time="note.createdAt"/>
+		</MkA>
+		<span class="visibility" v-if="note.visibility !== 'public'">
+			<Fa v-if="note.visibility === 'home'" :icon="faHome"/>
+			<Fa v-if="note.visibility === 'followers'" :icon="faUnlock"/>
+			<Fa v-if="note.visibility === 'specified'" :icon="faEnvelope"/>
+		</span>
+		<span class="localOnly" v-if="note.localOnly"><Fa :icon="faBiohazard"/></span>
+	</div>
+</header>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faHome, faUnlock, faEnvelope, faMobileAlt, faBookmark, faBiohazard } from '@fortawesome/free-solid-svg-icons';
+import { faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons';
+import notePage from '@/filters/note';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+
+export default defineComponent({
+	props: {
+		note: {
+			type: Object,
+			required: true
+		},
+	},
+
+	data() {
+		return {
+			faHome, faUnlock, faEnvelope, faMobileAlt, faBookmark, farBookmark, faBiohazard
+		};
+	},
+
+	methods: {
+		notePage,
+		userPage
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.dehvdgxo {
+	display: flex;
+	align-items: baseline;
+	white-space: nowrap;
+	font-size: 0.9em;
+
+	> .name {
+		display: block;
+		margin: 0 .5em 0 0;
+		padding: 0;
+		overflow: hidden;
+		font-size: 1em;
+		font-weight: bold;
+		text-decoration: none;
+		text-overflow: ellipsis;
+
+		&:hover {
+			text-decoration: underline;
+		}
+	}
+
+	> .is-bot {
+		flex-shrink: 0;
+		align-self: center;
+		margin: 0 .5em 0 0;
+		padding: 1px 6px;
+		font-size: 80%;
+		border: solid 1px var(--divider);
+		border-radius: 3px;
+	}
+
+	> .admin,
+	> .moderator {
+		margin-right: 0.5em;
+		color: var(--badge);
+	}
+
+	> .username {
+		margin: 0 .5em 0 0;
+		overflow: hidden;
+		text-overflow: ellipsis;
+	}
+
+	> .info {
+		font-size: 0.9em;
+		opacity: 0.7;
+
+		> .mobile {
+			margin-right: 8px;
+		}
+
+		> .visibility {
+			margin-left: 8px;
+		}
+
+		> .localOnly {
+			margin-left: 8px;
+		}
+	}
+}
+</style>
diff --git a/src/client/ui/chat/note-preview.vue b/src/client/ui/chat/note-preview.vue
new file mode 100644
index 000000000..486147370
--- /dev/null
+++ b/src/client/ui/chat/note-preview.vue
@@ -0,0 +1,112 @@
+<template>
+<div class="hduudsxk">
+	<MkAvatar class="avatar" :user="note.user"/>
+	<div class="main">
+		<XNoteHeader class="header" :note="note" :mini="true"/>
+		<div class="body">
+			<p v-if="note.cw != null" class="cw">
+				<span class="text" v-if="note.cw != ''">{{ note.cw }}</span>
+				<XCwButton v-model:value="showContent" :note="note"/>
+			</p>
+			<div class="content" v-show="note.cw == null || showContent">
+				<XSubNote-content class="text" :note="note"/>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XNoteHeader from './note-header.vue';
+import XSubNoteContent from './sub-note-content.vue';
+import XCwButton from '@/components/cw-button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		XNoteHeader,
+		XSubNoteContent,
+		XCwButton,
+	},
+
+	props: {
+		note: {
+			type: Object,
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			showContent: false
+		};
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.hduudsxk {
+	display: flex;
+	margin: 0;
+	padding: 0;
+	overflow: hidden;
+	font-size: 0.95em;
+
+	> .avatar {
+
+		@media (min-width: 350px) {
+			margin: 0 10px 0 0;
+			width: 44px;
+			height: 44px;
+		}
+
+		@media (min-width: 500px) {
+			margin: 0 12px 0 0;
+			width: 48px;
+			height: 48px;
+		}
+	}
+
+	> .avatar {
+		flex-shrink: 0;
+		display: block;
+		margin: 0 10px 0 0;
+		width: 40px;
+		height: 40px;
+		border-radius: 8px;
+	}
+
+	> .main {
+		flex: 1;
+		min-width: 0;
+
+		> .header {
+			margin-bottom: 2px;
+		}
+
+		> .body {
+
+			> .cw {
+				cursor: default;
+				display: block;
+				margin: 0;
+				padding: 0;
+				overflow-wrap: break-word;
+
+				> .text {
+					margin-right: 8px;
+				}
+			}
+
+			> .content {
+				> .text {
+					cursor: default;
+					margin: 0;
+					padding: 0;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/src/client/ui/chat/note.sub.vue b/src/client/ui/chat/note.sub.vue
new file mode 100644
index 000000000..6f365c29e
--- /dev/null
+++ b/src/client/ui/chat/note.sub.vue
@@ -0,0 +1,137 @@
+<template>
+<div class="wrpstxzv" :class="{ children }">
+	<div class="main">
+		<MkAvatar class="avatar" :user="note.user"/>
+		<div class="body">
+			<XNoteHeader class="header" :note="note" :mini="true"/>
+			<div class="body">
+				<p v-if="note.cw != null" class="cw">
+					<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis" />
+					<XCwButton v-model:value="showContent" :note="note"/>
+				</p>
+				<div class="content" v-show="note.cw == null || showContent">
+					<XSubNote-content class="text" :note="note"/>
+				</div>
+			</div>
+		</div>
+	</div>
+	<XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XNoteHeader from './note-header.vue';
+import XSubNoteContent from './sub-note-content.vue';
+import XCwButton from '@/components/cw-button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+	name: 'XSub',
+
+	components: {
+		XNoteHeader,
+		XSubNoteContent,
+		XCwButton,
+	},
+
+	props: {
+		note: {
+			type: Object,
+			required: true
+		},
+		detail: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+		children: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+		// TODO
+		truncate: {
+			type: Boolean,
+			default: true
+		}
+	},
+
+	data() {
+		return {
+			showContent: false,
+			replies: [],
+		};
+	},
+
+	created() {
+		if (this.detail) {
+			os.api('notes/children', {
+				noteId: this.note.id,
+				limit: 5
+			}).then(replies => {
+				this.replies = replies;
+			});
+		}
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.wrpstxzv {
+	padding: 16px 16px;
+	font-size: 0.8em;
+
+	&.children {
+		padding: 10px 0 0 16px;
+		font-size: 1em;
+	}
+
+	> .main {
+		display: flex;
+
+		> .avatar {
+			flex-shrink: 0;
+			display: block;
+			margin: 0 8px 0 0;
+			width: 36px;
+			height: 36px;
+		}
+
+		> .body {
+			flex: 1;
+			min-width: 0;
+
+			> .header {
+				margin-bottom: 2px;
+			}
+
+			> .body {
+				> .cw {
+					cursor: default;
+					display: block;
+					margin: 0;
+					padding: 0;
+					overflow-wrap: break-word;
+
+					> .text {
+						margin-right: 8px;
+					}
+				}
+
+				> .content {
+					> .text {
+						margin: 0;
+						padding: 0;
+					}
+				}
+			}
+		}
+	}
+
+	> .reply {
+		border-left: solid 1px var(--divider);
+		margin-top: 10px;
+	}
+}
+</style>
diff --git a/src/client/ui/chat/note.vue b/src/client/ui/chat/note.vue
new file mode 100644
index 000000000..f17f45962
--- /dev/null
+++ b/src/client/ui/chat/note.vue
@@ -0,0 +1,1126 @@
+<template>
+<div
+	class="note"
+	v-if="!muted"
+	v-show="!isDeleted"
+	:tabindex="!isDeleted ? '-1' : null"
+	:class="{ renote: isRenote }"
+	v-hotkey="keymap"
+>
+	<XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/>
+	<div class="info" v-if="pinned"><Fa :icon="faThumbtack"/> {{ $ts.pinnedNote }}</div>
+	<div class="info" v-if="appearNote._prId_"><Fa :icon="faBullhorn"/> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <Fa :icon="faTimes"/></button></div>
+	<div class="info" v-if="appearNote._featuredId_"><Fa :icon="faBolt"/> {{ $ts.featured }}</div>
+	<div class="renote" v-if="isRenote">
+		<MkAvatar class="avatar" :user="note.user"/>
+		<Fa :icon="faRetweet"/>
+		<I18n :src="$ts.renotedBy" tag="span">
+			<template #user>
+				<MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId">
+					<MkUserName :user="note.user"/>
+				</MkA>
+			</template>
+		</I18n>
+		<div class="info">
+			<button class="_button time" @click="showRenoteMenu()" ref="renoteTime">
+				<Fa class="dropdownIcon" v-if="isMyRenote" :icon="faEllipsisH"/>
+				<MkTime :time="note.createdAt"/>
+			</button>
+			<span class="visibility" v-if="note.visibility !== 'public'">
+				<Fa v-if="note.visibility === 'home'" :icon="faHome"/>
+				<Fa v-if="note.visibility === 'followers'" :icon="faUnlock"/>
+				<Fa v-if="note.visibility === 'specified'" :icon="faEnvelope"/>
+			</span>
+			<span class="localOnly" v-if="note.localOnly"><Fa :icon="faBiohazard"/></span>
+		</div>
+	</div>
+	<article class="article" @contextmenu.stop="onContextmenu">
+		<MkAvatar class="avatar" :user="appearNote.user"/>
+		<div class="main">
+			<XNoteHeader class="header" :note="appearNote" :mini="true"/>
+			<MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/>
+			<div class="body">
+				<p v-if="appearNote.cw != null" class="cw">
+					<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+					<XCwButton v-model:value="showContent" :note="appearNote"/>
+				</p>
+				<div class="content" :class="{ collapsed }" v-show="appearNote.cw == null || showContent">
+					<div class="text">
+						<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span>
+						<MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><Fa :icon="faReply"/></MkA>
+						<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+						<a class="rp" v-if="appearNote.renote != null">RN:</a>
+					</div>
+					<div class="files" v-if="appearNote.files.length > 0">
+						<XMediaList :media-list="appearNote.files"/>
+					</div>
+					<XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/>
+					<MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="false" class="url-preview"/>
+					<div class="renote" v-if="appearNote.renote"><XNotePreview :note="appearNote.renote"/></div>
+					<button v-if="collapsed" class="fade _button" @click="collapsed = false">
+						<span>{{ $ts.showMore }}</span>
+					</button>
+				</div>
+				<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><Fa :icon="faSatelliteDish"/> {{ appearNote.channel.name }}</MkA>
+			</div>
+			<XReactionsViewer :note="appearNote" ref="reactionsViewer"/>
+			<footer class="footer _panel">
+				<button @click="reply()" class="button _button">
+					<template v-if="appearNote.reply"><Fa :icon="faReplyAll"/></template>
+					<template v-else><Fa :icon="faReply"/></template>
+					<p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p>
+				</button>
+				<button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton">
+					<Fa :icon="faRetweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p>
+				</button>
+				<button v-else class="button _button">
+					<Fa :icon="faBan"/>
+				</button>
+				<button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton">
+					<Fa :icon="faPlus"/>
+				</button>
+				<button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton">
+					<Fa :icon="faMinus"/>
+				</button>
+				<button class="button _button" @click="menu()" ref="menuButton">
+					<Fa :icon="faEllipsisH"/>
+				</button>
+			</footer>
+		</div>
+	</article>
+</div>
+<div v-else class="muted" @click="muted = false">
+	<I18n :src="$ts.userSaysSomething" tag="small">
+		<template #name>
+			<MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId">
+				<MkUserName :user="appearNote.user"/>
+			</MkA>
+		</template>
+	</I18n>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineAsyncComponent, defineComponent, markRaw, ref } from 'vue';
+import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug, faExclamationCircle, faPaperclip } from '@fortawesome/free-solid-svg-icons';
+import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
+import { parse } from '../../../mfm/parse';
+import { sum, unique } from '../../../prelude/array';
+import XSub from './note.sub.vue';
+import XNoteHeader from './note-header.vue';
+import XNotePreview from './note-preview.vue';
+import XReactionsViewer from '@/components/reactions-viewer.vue';
+import XMediaList from '@/components/media-list.vue';
+import XCwButton from '@/components/cw-button.vue';
+import XPoll from '@/components/poll.vue';
+import { pleaseLogin } from '@/scripts/please-login';
+import { focusPrev, focusNext } from '@/scripts/focus';
+import { url } from '@/config';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { checkWordMute } from '@/scripts/check-word-mute';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import { noteActions, noteViewInterruptors } from '@/store';
+
+function markRawAll(...xs) {
+	for (const x of xs) {
+		markRaw(x);
+	}
+}
+
+markRawAll(faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug, faSatelliteDish);
+
+export default defineComponent({
+	components: {
+		XSub,
+		XNoteHeader,
+		XNotePreview,
+		XReactionsViewer,
+		XMediaList,
+		XCwButton,
+		XPoll,
+		MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
+		MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
+	},
+
+	inject: {
+		inChannel: {
+			default: null
+		},
+	},
+
+	props: {
+		note: {
+			type: Object,
+			required: true
+		},
+		pinned: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+	},
+
+	emits: ['update:note'],
+
+	data() {
+		return {
+			connection: null,
+			replies: [],
+			showContent: false,
+			collapsed: false,
+			isDeleted: false,
+			muted: false,
+			faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug, faSatelliteDish
+		};
+	},
+
+	computed: {
+		rs() {
+			return this.$store.state.reactions;
+		},
+		keymap(): any {
+			return {
+				'r': () => this.reply(true),
+				'e|a|plus': () => this.react(true),
+				'q': () => this.renote(true),
+				'f|b': this.favorite,
+				'delete|ctrl+d': this.del,
+				'ctrl+q': this.renoteDirectly,
+				'up|k|shift+tab': this.focusBefore,
+				'down|j|tab': this.focusAfter,
+				'esc': this.blur,
+				'm|o': () => this.menu(true),
+				's': this.toggleShowContent,
+				'1': () => this.reactDirectly(this.rs[0]),
+				'2': () => this.reactDirectly(this.rs[1]),
+				'3': () => this.reactDirectly(this.rs[2]),
+				'4': () => this.reactDirectly(this.rs[3]),
+				'5': () => this.reactDirectly(this.rs[4]),
+				'6': () => this.reactDirectly(this.rs[5]),
+				'7': () => this.reactDirectly(this.rs[6]),
+				'8': () => this.reactDirectly(this.rs[7]),
+				'9': () => this.reactDirectly(this.rs[8]),
+				'0': () => this.reactDirectly(this.rs[9]),
+			};
+		},
+
+		isRenote(): boolean {
+			return (this.note.renote &&
+				this.note.text == null &&
+				this.note.fileIds.length == 0 &&
+				this.note.poll == null);
+		},
+
+		appearNote(): any {
+			return this.isRenote ? this.note.renote : this.note;
+		},
+
+		isMyNote(): boolean {
+			return this.$i && (this.$i.id === this.appearNote.userId);
+		},
+
+		isMyRenote(): boolean {
+			return this.$i && (this.$i.id === this.note.userId);
+		},
+
+		canRenote(): boolean {
+			return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote;
+		},
+
+		reactionsCount(): number {
+			return this.appearNote.reactions
+				? sum(Object.values(this.appearNote.reactions))
+				: 0;
+		},
+
+		urls(): string[] {
+			if (this.appearNote.text) {
+				const ast = parse(this.appearNote.text);
+				// TODO: 再帰的にURL要素がないか調べる
+				const urls = unique(ast
+					.filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent))
+					.map(t => t.node.props.url));
+
+				// unique without hash
+				// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
+				const removeHash = x => x.replace(/#[^#]*$/, '');
+
+				return urls.reduce((array, url) => {
+					const removed = removeHash(url);
+					if (!array.map(x => removeHash(x)).includes(removed)) array.push(url);
+					return array;
+				}, []);
+			} else {
+				return null;
+			}
+		},
+
+		showTicker() {
+			if (this.$store.state.instanceTicker === 'always') return true;
+			if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true;
+			return false;
+		}
+	},
+
+	async created() {
+		if (this.$i) {
+			this.connection = os.stream;
+		}
+
+		this.collapsed = this.appearNote.cw == null && this.appearNote.text && (
+			(this.appearNote.text.split('\n').length > 9) ||
+			(this.appearNote.text.length > 500)
+		);
+		this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords);
+
+		// plugin
+		if (noteViewInterruptors.length > 0) {
+			let result = this.note;
+			for (const interruptor of noteViewInterruptors) {
+				result = await interruptor.handler(JSON.parse(JSON.stringify(result)));
+			}
+			this.$emit('update:note', Object.freeze(result));
+		}
+	},
+
+	mounted() {
+		this.capture(true);
+
+		if (this.$i) {
+			this.connection.on('_connected_', this.onStreamConnected);
+		}
+	},
+
+	beforeUnmount() {
+		this.decapture(true);
+
+		if (this.$i) {
+			this.connection.off('_connected_', this.onStreamConnected);
+		}
+	},
+
+	methods: {
+		updateAppearNote(v) {
+			this.$emit('update:note', Object.freeze(this.isRenote ? {
+				...this.note,
+				renote: {
+					...this.note.renote,
+					...v
+				}
+			} : {
+				...this.note,
+				...v
+			}));
+		},
+
+		readPromo() {
+			os.api('promo/read', {
+				noteId: this.appearNote.id
+			});
+			this.isDeleted = true;
+		},
+
+		capture(withHandler = false) {
+			if (this.$i) {
+				this.connection.send(document.body.contains(this.$el) ? 'sn' : 's', { id: this.appearNote.id });
+				if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
+			}
+		},
+
+		decapture(withHandler = false) {
+			if (this.$i) {
+				this.connection.send('un', {
+					id: this.appearNote.id
+				});
+				if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated);
+			}
+		},
+
+		onStreamConnected() {
+			this.capture();
+		},
+
+		onStreamNoteUpdated(data) {
+			const { type, id, body } = data;
+
+			if (id !== this.appearNote.id) return;
+
+			switch (type) {
+				case 'reacted': {
+					const reaction = body.reaction;
+
+					// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+					let n = {
+						...this.appearNote,
+					};
+
+					if (body.emoji) {
+						const emojis = this.appearNote.emojis || [];
+						if (!emojis.includes(body.emoji)) {
+							n.emojis = [...emojis, body.emoji];
+						}
+					}
+
+					// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
+					const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
+
+					// Increment the count
+					n.reactions = {
+						...this.appearNote.reactions,
+						[reaction]: currentCount + 1
+					};
+
+					if (body.userId === this.$i.id) {
+						n.myReaction = reaction;
+					}
+
+					this.updateAppearNote(n);
+					break;
+				}
+
+				case 'unreacted': {
+					const reaction = body.reaction;
+
+					// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+					let n = {
+						...this.appearNote,
+					};
+
+					// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
+					const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
+
+					// Decrement the count
+					n.reactions = {
+						...this.appearNote.reactions,
+						[reaction]: Math.max(0, currentCount - 1)
+					};
+
+					if (body.userId === this.$i.id) {
+						n.myReaction = null;
+					}
+
+					this.updateAppearNote(n);
+					break;
+				}
+
+				case 'pollVoted': {
+					const choice = body.choice;
+
+					// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+					let n = {
+						...this.appearNote,
+					};
+
+					const choices = [...this.appearNote.poll.choices];
+					choices[choice] = {
+						...choices[choice],
+						votes: choices[choice].votes + 1,
+						...(body.userId === this.$i.id ? {
+							isVoted: true
+						} : {})
+					};
+
+					n.poll = {
+						...this.appearNote.poll,
+						choices: choices
+					};
+
+					this.updateAppearNote(n);
+					break;
+				}
+
+				case 'deleted': {
+					this.isDeleted = true;
+					break;
+				}
+			}
+		},
+
+		reply(viaKeyboard = false) {
+			pleaseLogin();
+			os.post({
+				reply: this.appearNote,
+				animation: !viaKeyboard,
+			}, () => {
+				this.focus();
+			});
+		},
+
+		renote(viaKeyboard = false) {
+			pleaseLogin();
+			this.blur();
+			os.modalMenu([{
+				text: this.$ts.renote,
+				icon: faRetweet,
+				action: () => {
+					os.api('notes/create', {
+						renoteId: this.appearNote.id
+					});
+				}
+			}, {
+				text: this.$ts.quote,
+				icon: faQuoteRight,
+				action: () => {
+					os.post({
+						renote: this.appearNote,
+					});
+				}
+			}], this.$refs.renoteButton, {
+				viaKeyboard
+			});
+		},
+
+		renoteDirectly() {
+			os.apiWithDialog('notes/create', {
+				renoteId: this.appearNote.id
+			}, undefined, (res: any) => {
+				os.dialog({
+					type: 'success',
+					text: this.$ts.renoted,
+				});
+			}, (e: Error) => {
+				if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') {
+					os.dialog({
+						type: 'error',
+						text: this.$ts.cantRenote,
+					});
+				} else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') {
+					os.dialog({
+						type: 'error',
+						text: this.$ts.cantReRenote,
+					});
+				}
+			});
+		},
+
+		react(viaKeyboard = false) {
+			pleaseLogin();
+			this.blur();
+			os.popup(import('@/components/emoji-picker.vue'), {
+				src: this.$refs.reactButton,
+				asReactionPicker: true
+			}, {
+				done: reaction => {
+					if (reaction) {
+						os.api('notes/reactions/create', {
+							noteId: this.appearNote.id,
+							reaction: reaction
+						});
+					}
+					this.focus();
+				},
+			}, 'closed');
+		},
+
+		reactDirectly(reaction) {
+			os.api('notes/reactions/create', {
+				noteId: this.appearNote.id,
+				reaction: reaction
+			});
+		},
+
+		undoReact(note) {
+			const oldReaction = note.myReaction;
+			if (!oldReaction) return;
+			os.api('notes/reactions/delete', {
+				noteId: note.id
+			});
+		},
+
+		favorite() {
+			pleaseLogin();
+			os.apiWithDialog('notes/favorites/create', {
+				noteId: this.appearNote.id
+			}, undefined, (res: any) => {
+				os.dialog({
+					type: 'success',
+					text: this.$ts.favorited,
+				});
+			}, (e: Error) => {
+				if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') {
+					os.dialog({
+						type: 'error',
+						text: this.$ts.alreadyFavorited,
+					});
+				} else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') {
+					os.dialog({
+						type: 'error',
+						text: this.$ts.cantFavorite,
+					});
+				}
+			});
+		},
+
+		del() {
+			os.dialog({
+				type: 'warning',
+				text: this.$ts.noteDeleteConfirm,
+				showCancelButton: true
+			}).then(({ canceled }) => {
+				if (canceled) return;
+
+				os.api('notes/delete', {
+					noteId: this.appearNote.id
+				});
+			});
+		},
+
+		delEdit() {
+			os.dialog({
+				type: 'warning',
+				text: this.$ts.deleteAndEditConfirm,
+				showCancelButton: true
+			}).then(({ canceled }) => {
+				if (canceled) return;
+
+				os.api('notes/delete', {
+					noteId: this.appearNote.id
+				});
+
+				os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel });
+			});
+		},
+
+		toggleFavorite(favorite: boolean) {
+			os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
+				noteId: this.appearNote.id
+			});
+		},
+
+		toggleWatch(watch: boolean) {
+			os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
+				noteId: this.appearNote.id
+			});
+		},
+
+		getMenu() {
+			let menu;
+			if (this.$i) {
+				const statePromise = os.api('notes/state', {
+					noteId: this.appearNote.id
+				});
+
+				menu = [{
+					icon: faCopy,
+					text: this.$ts.copyContent,
+					action: this.copyContent
+				}, {
+					icon: faLink,
+					text: this.$ts.copyLink,
+					action: this.copyLink
+				}, (this.appearNote.url || this.appearNote.uri) ? {
+					icon: faExternalLinkSquareAlt,
+					text: this.$ts.showOnRemote,
+					action: () => {
+						window.open(this.appearNote.url || this.appearNote.uri, '_blank');
+					}
+				} : undefined,
+				null,
+				statePromise.then(state => state.isFavorited ? {
+					icon: faStar,
+					text: this.$ts.unfavorite,
+					action: () => this.toggleFavorite(false)
+				} : {
+					icon: faStar,
+					text: this.$ts.favorite,
+					action: () => this.toggleFavorite(true)
+				}),
+				{
+					icon: faPaperclip,
+					text: this.$ts.clip,
+					action: () => this.clip()
+				},
+				(this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? {
+					icon: faEyeSlash,
+					text: this.$ts.unwatch,
+					action: () => this.toggleWatch(false)
+				} : {
+					icon: faEye,
+					text: this.$ts.watch,
+					action: () => this.toggleWatch(true)
+				}) : undefined,
+				this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? {
+					icon: faThumbtack,
+					text: this.$ts.unpin,
+					action: () => this.togglePin(false)
+				} : {
+					icon: faThumbtack,
+					text: this.$ts.pin,
+					action: () => this.togglePin(true)
+				} : undefined,
+				...(this.$i.isModerator || this.$i.isAdmin ? [
+					null,
+					{
+						icon: faBullhorn,
+						text: this.$ts.promote,
+						action: this.promote
+					}]
+					: []
+				),
+				...(this.appearNote.userId != this.$i.id ? [
+					null,
+					{
+						icon: faExclamationCircle,
+						text: this.$ts.reportAbuse,
+						action: () => {
+							const u = `${url}/notes/${this.appearNote.id}`;
+							os.popup(import('@/components/abuse-report-window.vue'), {
+								user: this.appearNote.user,
+								initialComment: `Note: ${u}\n-----\n`
+							}, {}, 'closed');
+						}
+					}]
+					: []
+				),
+				...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [
+					null,
+					this.appearNote.userId == this.$i.id ? {
+						icon: faEdit,
+						text: this.$ts.deleteAndEdit,
+						action: this.delEdit
+					} : undefined,
+					{
+						icon: faTrashAlt,
+						text: this.$ts.delete,
+						danger: true,
+						action: this.del
+					}]
+					: []
+				)]
+				.filter(x => x !== undefined);
+			} else {
+				menu = [{
+					icon: faCopy,
+					text: this.$ts.copyContent,
+					action: this.copyContent
+				}, {
+					icon: faLink,
+					text: this.$ts.copyLink,
+					action: this.copyLink
+				}, (this.appearNote.url || this.appearNote.uri) ? {
+					icon: faExternalLinkSquareAlt,
+					text: this.$ts.showOnRemote,
+					action: () => {
+						window.open(this.appearNote.url || this.appearNote.uri, '_blank');
+					}
+				} : undefined]
+				.filter(x => x !== undefined);
+			}
+
+			if (noteActions.length > 0) {
+				menu = menu.concat([null, ...noteActions.map(action => ({
+					icon: faPlug,
+					text: action.title,
+					action: () => {
+						action.handler(this.appearNote);
+					}
+				}))]);
+			}
+
+			return menu;
+		},
+
+		onContextmenu(e) {
+			const isLink = (el: HTMLElement) => {
+				if (el.tagName === 'A') return true;
+				if (el.parentElement) {
+					return isLink(el.parentElement);
+				}
+			};
+			if (isLink(e.target)) return;
+			if (window.getSelection().toString() !== '') return;
+			os.contextMenu(this.getMenu(), e).then(this.focus);
+		},
+
+		menu(viaKeyboard = false) {
+			os.modalMenu(this.getMenu(), this.$refs.menuButton, {
+				viaKeyboard
+			}).then(this.focus);
+		},
+
+		showRenoteMenu(viaKeyboard = false) {
+			if (!this.isMyRenote) return;
+			os.modalMenu([{
+				text: this.$ts.unrenote,
+				icon: faTrashAlt,
+				danger: true,
+				action: () => {
+					os.api('notes/delete', {
+						noteId: this.note.id
+					});
+					this.isDeleted = true;
+				}
+			}], this.$refs.renoteTime, {
+				viaKeyboard: viaKeyboard
+			});
+		},
+
+		toggleShowContent() {
+			this.showContent = !this.showContent;
+		},
+
+		copyContent() {
+			copyToClipboard(this.appearNote.text);
+			os.success();
+		},
+
+		copyLink() {
+			copyToClipboard(`${url}/notes/${this.appearNote.id}`);
+			os.success();
+		},
+
+		togglePin(pin: boolean) {
+			os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
+				noteId: this.appearNote.id
+			}, undefined, null, e => {
+				if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
+					os.dialog({
+						type: 'error',
+						text: this.$ts.pinLimitExceeded
+					});
+				}
+			});
+		},
+
+		async clip() {
+			const clips = await os.api('clips/list');
+			os.modalMenu([{
+				icon: faPlus,
+				text: this.$ts.createNew,
+				action: async () => {
+					const { canceled, result } = await os.form(this.$ts.createNewClip, {
+						name: {
+							type: 'string',
+							label: this.$ts.name
+						},
+						description: {
+							type: 'string',
+							required: false,
+							multiline: true,
+							label: this.$ts.description
+						},
+						isPublic: {
+							type: 'boolean',
+							label: this.$ts.public,
+							default: false
+						}
+					});
+					if (canceled) return;
+
+					const clip = await os.apiWithDialog('clips/create', result);
+
+					os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
+				}
+			}, null, ...clips.map(clip => ({
+				text: clip.name,
+				action: () => {
+					os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
+				}
+			}))], this.$refs.menuButton, {
+			}).then(this.focus);
+		},
+
+		async promote() {
+			const { canceled, result: days } = await os.dialog({
+				title: this.$ts.numberOfDays,
+				input: { type: 'number' }
+			});
+
+			if (canceled) return;
+
+			os.apiWithDialog('admin/promo/create', {
+				noteId: this.appearNote.id,
+				expiresAt: Date.now() + (86400000 * days)
+			});
+		},
+
+		focus() {
+			this.$el.focus();
+		},
+
+		blur() {
+			this.$el.blur();
+		},
+
+		focusBefore() {
+			focusPrev(this.$el);
+		},
+
+		focusAfter() {
+			focusNext(this.$el);
+		},
+
+		userPage
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.note {
+	position: relative;
+	transition: box-shadow 0.1s ease;
+	overflow: hidden;
+	contain: content;
+
+	// これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、
+	// 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう
+	// ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、
+	// 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる
+	// 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?)
+	//content-visibility: auto;
+  //contain-intrinsic-size: 0 128px;
+
+	&:focus {
+		outline: none;
+	}
+
+	&:hover {
+		background: rgba(0, 0, 0, 0.05);
+	}
+
+	&:hover > .article > .main > .footer {
+		display: block;
+	}
+
+	&.renote {
+		background: rgba(128, 255, 0, 0.05);
+	}
+
+	> .info {
+		display: flex;
+		align-items: center;
+		padding: 16px 32px 8px 32px;
+		line-height: 24px;
+		font-size: 90%;
+		white-space: pre;
+		color: #d28a3f;
+
+		> [data-icon] {
+			margin-right: 4px;
+		}
+
+		> .hide {
+			margin-left: auto;
+			color: inherit;
+		}
+	}
+
+	> .info + .article {
+		padding-top: 8px;
+	}
+
+	> .reply-to {
+		opacity: 0.7;
+		padding-bottom: 0;
+	}
+
+	> .renote {
+		display: flex;
+		align-items: center;
+		padding: 12px 16px 8px 16px;
+		line-height: 28px;
+		white-space: pre;
+		color: var(--renote);
+		font-size: 0.9em;
+
+		> .avatar {
+			flex-shrink: 0;
+			display: inline-block;
+			width: 28px;
+			height: 28px;
+			margin: 0 8px 0 0;
+			border-radius: 6px;
+		}
+
+		> [data-icon] {
+			margin-right: 4px;
+		}
+
+		> span {
+			overflow: hidden;
+			flex-shrink: 1;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+
+			> .name {
+				font-weight: bold;
+			}
+		}
+
+		> .info {
+			margin-left: 8px;
+			font-size: 0.9em;
+			opacity: 0.7;
+
+			> .time {
+				flex-shrink: 0;
+				color: inherit;
+
+				> .dropdownIcon {
+					margin-right: 4px;
+				}
+			}
+
+			> .visibility {
+				margin-left: 8px;
+			}
+
+			> .localOnly {
+				margin-left: 8px;
+			}
+		}
+	}
+
+	> .renote + .article {
+		padding-top: 8px;
+	}
+
+	> .article {
+		display: flex;
+		padding: 12px 16px;
+
+		> .avatar {
+			flex-shrink: 0;
+			display: block;
+			//position: sticky;
+			//top: 72px;
+			margin: 0 14px 0 0;
+			width: 46px;
+			height: 46px;
+		}
+
+		> .main {
+			flex: 1;
+			min-width: 0;
+
+			> .body {
+				> .cw {
+					cursor: default;
+					display: block;
+					margin: 0;
+					padding: 0;
+					overflow-wrap: break-word;
+
+					> .text {
+						margin-right: 8px;
+					}
+				}
+
+				> .content {
+					&.collapsed {
+						position: relative;
+						max-height: 9em;
+						overflow: hidden;
+
+						> .fade {
+							display: block;
+							position: absolute;
+							bottom: 0;
+							left: 0;
+							width: 100%;
+							height: 64px;
+							background: linear-gradient(0deg, var(--panel), var(--X15));
+
+							> span {
+								display: inline-block;
+								background: var(--panel);
+								padding: 6px 10px;
+								font-size: 0.8em;
+								border-radius: 999px;
+								box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+							}
+
+							&:hover {
+								> span {
+									background: var(--panelHighlight);
+								}
+							}
+						}
+					}
+
+					> .text {
+						overflow-wrap: break-word;
+
+						> .reply {
+							color: var(--accent);
+							margin-right: 0.5em;
+						}
+
+						> .rp {
+							margin-left: 4px;
+							font-style: oblique;
+							color: var(--renote);
+						}
+					}
+
+					> .url-preview {
+						margin-top: 8px;
+					}
+
+					> .poll {
+						font-size: 80%;
+					}
+
+					> .renote {
+						padding: 8px 0;
+
+						> * {
+							padding: 16px;
+							border: dashed 1px var(--renote);
+							border-radius: 8px;
+						}
+					}
+				}
+
+				> .channel {
+					opacity: 0.7;
+					font-size: 80%;
+				}
+			}
+
+			> .footer {
+				display: none;
+				position: absolute;
+				top: 8px;
+				right: 8px;
+				padding: 0 6px;
+				opacity: 0.7;
+
+				&:hover {
+					opacity: 1;
+				}
+
+				> .button {
+					margin: 0;
+					padding: 8px;
+					opacity: 0.7;
+
+					&:hover {
+						color: var(--accent);
+					}
+
+					> .count {
+						display: inline;
+						margin: 0 0 0 8px;
+						opacity: 0.7;
+					}
+
+					&.reacted {
+						color: var(--accent);
+					}
+				}
+			}
+		}
+	}
+
+	> .reply {
+		border-top: solid 1px var(--divider);
+	}
+}
+
+.muted {
+	padding: 8px 16px;
+	opacity: 0.7;
+}
+</style>
diff --git a/src/client/ui/chat/notes.vue b/src/client/ui/chat/notes.vue
new file mode 100644
index 000000000..1fa2870ce
--- /dev/null
+++ b/src/client/ui/chat/notes.vue
@@ -0,0 +1,91 @@
+<template>
+<div class="" :ref="mounted">
+	<div class="_fullinfo" v-if="empty">
+		<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+		<div>{{ $ts.noNotes }}</div>
+	</div>
+
+	<MkError v-if="error" @retry="init()"/>
+
+	<div v-show="more && reversed" style="margin-bottom: var(--margin);">
+		<button class="_buttonPrimary" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+			<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+			<template v-if="moreFetching"><MkLoading inline/></template>
+		</button>
+	</div>
+
+	<XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed">
+		<XNote :note="note" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/>
+	</XList>
+
+	<div v-show="more && !reversed" style="margin-top: var(--margin);">
+		<button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+			<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+			<template v-if="moreFetching"><MkLoading inline/></template>
+		</button>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import paging from '@/scripts/paging';
+import XNote from './note.vue';
+import XList from './date-separated-list.vue';
+
+export default defineComponent({
+	components: {
+		XNote, XList,
+	},
+
+	mixins: [
+		paging({
+			before: (self) => {
+				self.$emit('before');
+			},
+
+			after: (self, e) => {
+				self.$emit('after', e);
+			}
+		}),
+	],
+
+	props: {
+		pagination: {
+			required: true
+		},
+
+		prop: {
+			type: String,
+			required: false
+		}
+	},
+
+	emits: ['before', 'after'],
+
+	computed: {
+		notes(): any[] {
+			return this.prop ? this.items.map(item => item[this.prop]) : this.items;
+		},
+
+		reversed(): boolean {
+			return this.pagination.reversed;
+		}
+	},
+
+	methods: {
+		updated(oldValue, newValue) {
+			const i = this.notes.findIndex(n => n === oldValue);
+			if (this.prop) {
+				this.items[i][this.prop] = newValue;
+			} else {
+				this.items[i] = newValue;
+			}
+		},
+
+		focus() {
+			this.$refs.notes.focus();
+		}
+	}
+});
+</script>
diff --git a/src/client/ui/chat/post-form.vue b/src/client/ui/chat/post-form.vue
new file mode 100644
index 000000000..f03e7ebb9
--- /dev/null
+++ b/src/client/ui/chat/post-form.vue
@@ -0,0 +1,771 @@
+<template>
+<div class="pxiwixjf"
+	@dragover.stop="onDragover"
+	@dragenter="onDragenter"
+	@dragleave="onDragleave"
+	@drop.stop="onDrop"
+>
+	<div class="form">
+		<div class="with-quote" v-if="quoteId"><Fa icon="quote-left"/> {{ $ts.quoteAttached }}<button @click="quoteId = null"><Fa icon="times"/></button></div>
+		<div v-if="visibility === 'specified'" class="to-specified">
+			<span style="margin-right: 8px;">{{ $ts.recipient }}</span>
+			<div class="visibleUsers">
+				<span v-for="u in visibleUsers" :key="u.id">
+					<MkAcct :user="u"/>
+					<button class="_button" @click="removeVisibleUser(u)"><Fa :icon="faTimes"/></button>
+				</span>
+				<button @click="addVisibleUser" class="_buttonPrimary"><Fa :icon="faPlus" fixed-width/></button>
+			</div>
+		</div>
+		<input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$ts.annotation" @keydown="onKeydown">
+		<textarea v-model="text" class="text" :class="{ withCw: useCw }" ref="text" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd" />
+		<XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
+		<XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/>
+		<footer>
+			<div class="left">
+				<button class="_button" @click="chooseFileFrom" v-tooltip="$ts.attachFile"><Fa :icon="faPhotoVideo"/></button>
+				<button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><Fa :icon="faPollH"/></button>
+				<button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$ts.useCw"><Fa :icon="faEyeSlash"/></button>
+				<button class="_button" @click="insertMention" v-tooltip="$ts.mention"><Fa :icon="faAt"/></button>
+				<button class="_button" @click="insertEmoji" v-tooltip="$ts.emoji"><Fa :icon="faLaughSquint"/></button>
+				<button class="_button" @click="showActions" v-tooltip="$ts.plugin" v-if="postFormActions.length > 0"><Fa :icon="faPlug"/></button>
+			</div>
+			<div class="right">
+				<span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span>
+				<span class="local-only" v-if="localOnly"><Fa :icon="faBiohazard"/></span>
+				<button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$ts.visibility" :disabled="channel != null">
+					<span v-if="visibility === 'public'"><Fa :icon="faGlobe"/></span>
+					<span v-if="visibility === 'home'"><Fa :icon="faHome"/></span>
+					<span v-if="visibility === 'followers'"><Fa :icon="faUnlock"/></span>
+					<span v-if="visibility === 'specified'"><Fa :icon="faEnvelope"/></span>
+				</button>
+				<button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<Fa :icon="reply ? faReply : renote ? faQuoteRight : faPaperPlane"/></button>
+			</div>
+		</footer>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
+import { faEyeSlash, faLaughSquint } from '@fortawesome/free-regular-svg-icons';
+import insertTextAtCursor from 'insert-text-at-cursor';
+import { length } from 'stringz';
+import { toASCII } from 'punycode';
+import { parse } from '../../../mfm/parse';
+import { host, url } from '@/config';
+import { erase, unique } from '../../../prelude/array';
+import extractMentions from '../../../misc/extract-mentions';
+import getAcct from '../../../misc/acct/render';
+import { formatTimeString } from '../../../misc/format-time-string';
+import { Autocomplete } from '@/scripts/autocomplete';
+import { noteVisibilities } from '../../../types';
+import * as os from '@/os';
+import { selectFile } from '@/scripts/select-file';
+import { notePostInterruptors, postFormActions } from '@/store';
+
+export default defineComponent({
+	components: {
+		XPostFormAttaches: defineAsyncComponent(() => import('@/components/post-form-attaches.vue')),
+		XPollEditor: defineAsyncComponent(() => import('@/components/poll-editor.vue'))
+	},
+
+	props: {
+		reply: {
+			type: Object,
+			required: false
+		},
+		renote: {
+			type: Object,
+			required: false
+		},
+		channel: {
+			type: String,
+			required: false
+		},
+		mention: {
+			type: Object,
+			required: false
+		},
+		specified: {
+			type: Object,
+			required: false
+		},
+		initialText: {
+			type: String,
+			required: false
+		},
+		initialNote: {
+			type: Object,
+			required: false
+		},
+		instant: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+		autofocus: {
+			type: Boolean,
+			required: false,
+			default: true
+		},
+	},
+
+	emits: ['posted', 'cancel', 'esc'],
+
+	data() {
+		return {
+			posting: false,
+			text: '',
+			files: [],
+			poll: null,
+			useCw: false,
+			cw: null,
+			localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly,
+			visibility: this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility,
+			visibleUsers: [],
+			autocomplete: null,
+			draghover: false,
+			quoteId: null,
+			recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
+			imeText: '',
+			postFormActions,
+			faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug
+		};
+	},
+
+	computed: {
+		draftKey(): string {
+			let key = this.channel ? `channel:${this.channel}` : '';
+
+			if (this.renote) {
+				key += `renote:${this.renote.id}`;
+			} else if (this.reply) {
+				key += `reply:${this.reply.id}`;
+			} else {
+				key += 'note';
+			}
+
+			return key;
+		},
+
+		placeholder(): string {
+			if (this.renote) {
+				return this.$ts._postForm.quotePlaceholder;
+			} else if (this.reply) {
+				return this.$ts._postForm.replyPlaceholder;
+			} else if (this.channel) {
+				return this.$ts._postForm.channelPlaceholder;
+			} else {
+				const xs = [
+					this.$ts._postForm._placeholders.a,
+					this.$ts._postForm._placeholders.b,
+					this.$ts._postForm._placeholders.c,
+					this.$ts._postForm._placeholders.d,
+					this.$ts._postForm._placeholders.e,
+					this.$ts._postForm._placeholders.f
+				];
+				return xs[Math.floor(Math.random() * xs.length)];
+			}
+		},
+
+		submitText(): string {
+			return this.renote
+				? this.$ts.quote
+				: this.reply
+					? this.$ts.reply
+					: this.$ts.note;
+		},
+
+		textLength(): number {
+			return length((this.text + this.imeText).trim());
+		},
+
+		canPost(): boolean {
+			return !this.posting &&
+				(1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) &&
+				(this.textLength <= this.max) &&
+				(!this.poll || this.poll.choices.length >= 2);
+		},
+
+		max(): number {
+			return this.$instance ? this.$instance.maxNoteTextLength : 1000;
+		}
+	},
+
+	mounted() {
+		if (this.initialText) {
+			this.text = this.initialText;
+		}
+
+		if (this.mention) {
+			this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`;
+			this.text += ' ';
+		}
+
+		if (this.reply && this.reply.user.host != null) {
+			this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `;
+		}
+
+		if (this.reply && this.reply.text != null) {
+			const ast = parse(this.reply.text);
+
+			for (const x of extractMentions(ast)) {
+				const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`;
+
+				// 自分は除外
+				if (this.$i.username == x.username && x.host == null) continue;
+				if (this.$i.username == x.username && x.host == host) continue;
+
+				// 重複は除外
+				if (this.text.indexOf(`${mention} `) != -1) continue;
+
+				this.text += `${mention} `;
+			}
+		}
+
+		if (this.channel) {
+			this.visibility = 'public';
+			this.localOnly = true; // TODO: チャンネルが連合するようになった折には消す
+		}
+
+		// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
+		if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) {
+			this.visibility = this.reply.visibility;
+			if (this.reply.visibility === 'specified') {
+				os.api('users/show', {
+					userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId)
+				}).then(users => {
+					this.visibleUsers.push(...users);
+				});
+
+				if (this.reply.userId !== this.$i.id) {
+					os.api('users/show', { userId: this.reply.userId }).then(user => {
+						this.visibleUsers.push(user);
+					});
+				}
+			}
+		}
+
+		if (this.specified) {
+			this.visibility = 'specified';
+			this.visibleUsers.push(this.specified);
+		}
+
+		// keep cw when reply
+		if (this.$store.state.keepCw && this.reply && this.reply.cw) {
+			this.useCw = true;
+			this.cw = this.reply.cw;
+		}
+
+		if (this.autofocus) {
+			this.focus();
+
+			this.$nextTick(() => {
+				this.focus();
+			});
+		}
+
+		// TODO: detach when unmount
+		new Autocomplete(this.$refs.text, this, { model: 'text' });
+		new Autocomplete(this.$refs.cw, this, { model: 'cw' });
+
+		this.$nextTick(() => {
+			// 書きかけの投稿を復元
+			if (!this.instant && !this.mention && !this.specified) {
+				const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey];
+				if (draft) {
+					this.text = draft.data.text;
+					this.useCw = draft.data.useCw;
+					this.cw = draft.data.cw;
+					this.visibility = draft.data.visibility;
+					this.localOnly = draft.data.localOnly;
+					this.files = (draft.data.files || []).filter(e => e);
+					if (draft.data.poll) {
+						this.poll = draft.data.poll;
+					}
+				}
+			}
+
+			// 削除して編集
+			if (this.initialNote) {
+				const init = this.initialNote;
+				this.text = init.text ? init.text : '';
+				this.files = init.files;
+				this.cw = init.cw;
+				this.useCw = init.cw != null;
+				if (init.poll) {
+					this.poll = init.poll;
+				}
+				this.visibility = init.visibility;
+				this.localOnly = init.localOnly;
+				this.quoteId = init.renote ? init.renote.id : null;
+			}
+
+			this.$nextTick(() => this.watch());
+		});
+	},
+
+	methods: {
+		watch() {
+			this.$watch('text', () => this.saveDraft());
+			this.$watch('useCw', () => this.saveDraft());
+			this.$watch('cw', () => this.saveDraft());
+			this.$watch('poll', () => this.saveDraft());
+			this.$watch('files', () => this.saveDraft(), { deep: true });
+			this.$watch('visibility', () => this.saveDraft());
+			this.$watch('localOnly', () => this.saveDraft());
+		},
+
+		togglePoll() {
+			if (this.poll) {
+				this.poll = null;
+			} else {
+				this.poll = {
+					choices: ['', ''],
+					multiple: false,
+					expiresAt: null,
+					expiredAfter: null,
+				};
+			}
+		},
+
+		addTag(tag: string) {
+			insertTextAtCursor(this.$refs.text, ` #${tag} `);
+		},
+
+		focus() {
+			(this.$refs.text as any).focus();
+		},
+
+		chooseFileFrom(ev) {
+			selectFile(ev.currentTarget || ev.target, this.$ts.attachFile, true).then(files => {
+				for (const file of files) {
+					this.files.push(file);
+				}
+			});
+		},
+
+		detachFile(id) {
+			this.files = this.files.filter(x => x.id != id);
+		},
+
+		updateFiles(files) {
+			this.files = files;
+		},
+
+		updateFileSensitive(file, sensitive) {
+			this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
+		},
+
+		updateFileName(file, name) {
+			this.files[this.files.findIndex(x => x.id === file.id)].name = name;
+		},
+
+		upload(file: File, name?: string) {
+			os.upload(file, this.$store.state.uploadFolder, name).then(res => {
+				this.files.push(res);
+			});
+		},
+
+		onPollUpdate(poll) {
+			this.poll = poll;
+			this.saveDraft();
+		},
+
+		setVisibility() {
+			if (this.channel) {
+				// TODO: information dialog
+				return;
+			}
+
+			os.popup(import('@/components/visibility-picker.vue'), {
+				currentVisibility: this.visibility,
+				currentLocalOnly: this.localOnly,
+				src: this.$refs.visibilityButton
+			}, {
+				changeVisibility: visibility => {
+					this.visibility = visibility;
+					if (this.$store.state.rememberNoteVisibility) {
+						this.$store.set('visibility', visibility);
+					}
+				},
+				changeLocalOnly: localOnly => {
+					this.localOnly = localOnly;
+					if (this.$store.state.rememberNoteVisibility) {
+						this.$store.set('localOnly', localOnly);
+					}
+				}
+			}, 'closed');
+		},
+
+		addVisibleUser() {
+			os.selectUser().then(user => {
+				this.visibleUsers.push(user);
+			});
+		},
+
+		removeVisibleUser(user) {
+			this.visibleUsers = erase(user, this.visibleUsers);
+		},
+
+		clear() {
+			this.text = '';
+			this.files = [];
+			this.poll = null;
+			this.quoteId = null;
+		},
+
+		onKeydown(e: KeyboardEvent) {
+			if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
+			if (e.which === 27) this.$emit('esc');
+		},
+
+		onCompositionUpdate(e: CompositionEvent) {
+			this.imeText = e.data;
+		},
+
+		onCompositionEnd(e: CompositionEvent) {
+			this.imeText = '';
+		},
+
+		async onPaste(e: ClipboardEvent) {
+			for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) {
+				if (item.kind == 'file') {
+					const file = item.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, `${i + 1}`)}${ext}`;
+					this.upload(file, formatted);
+				}
+			}
+
+			const paste = e.clipboardData.getData('text');
+
+			if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) {
+				e.preventDefault();
+
+				os.dialog({
+					type: 'info',
+					text: this.$ts.quoteQuestion,
+					showCancelButton: true
+				}).then(({ canceled }) => {
+					if (canceled) {
+						insertTextAtCursor(this.$refs.text, paste);
+						return;
+					}
+
+					this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
+				});
+			}
+		},
+
+		onDragover(e) {
+			if (!e.dataTransfer.items[0]) return;
+			const isFile = e.dataTransfer.items[0].kind == 'file';
+			const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
+			if (isFile || isDriveFile) {
+				e.preventDefault();
+				this.draghover = true;
+				e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
+			}
+		},
+
+		onDragenter(e) {
+			this.draghover = true;
+		},
+
+		onDragleave(e) {
+			this.draghover = false;
+		},
+
+		onDrop(e): void {
+			this.draghover = false;
+
+			// ファイルだったら
+			if (e.dataTransfer.files.length > 0) {
+				e.preventDefault();
+				for (const x of Array.from(e.dataTransfer.files)) this.upload(x);
+				return;
+			}
+
+			//#region ドライブのファイル
+			const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+			if (driveFile != null && driveFile != '') {
+				const file = JSON.parse(driveFile);
+				this.files.push(file);
+				e.preventDefault();
+			}
+			//#endregion
+		},
+
+		saveDraft() {
+			if (this.instant) return;
+
+			const data = JSON.parse(localStorage.getItem('drafts') || '{}');
+
+			data[this.draftKey] = {
+				updatedAt: new Date(),
+				data: {
+					text: this.text,
+					useCw: this.useCw,
+					cw: this.cw,
+					visibility: this.visibility,
+					localOnly: this.localOnly,
+					files: this.files,
+					poll: this.poll
+				}
+			};
+
+			localStorage.setItem('drafts', JSON.stringify(data));
+		},
+
+		deleteDraft() {
+			const data = JSON.parse(localStorage.getItem('drafts') || '{}');
+
+			delete data[this.draftKey];
+
+			localStorage.setItem('drafts', JSON.stringify(data));
+		},
+
+		async post() {
+			let data = {
+				text: this.text == '' ? undefined : this.text,
+				fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
+				replyId: this.reply ? this.reply.id : undefined,
+				renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined,
+				channelId: this.channel ? this.channel : undefined,
+				poll: this.poll,
+				cw: this.useCw ? this.cw || '' : undefined,
+				localOnly: this.localOnly,
+				visibility: this.visibility,
+				visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined,
+				viaMobile: os.isMobile
+			};
+
+			// plugin
+			if (notePostInterruptors.length > 0) {
+				for (const interruptor of notePostInterruptors) {
+					data = await interruptor.handler(JSON.parse(JSON.stringify(data)));
+				}
+			}
+
+			this.posting = true;
+			os.api('notes/create', data).then(() => {
+				this.clear();
+				this.$nextTick(() => {
+					this.deleteDraft();
+					this.$emit('posted');
+					if (this.text && this.text != '') {
+						const hashtags = parse(this.text).filter(x => x.node.type === 'hashtag').map(x => x.node.props.hashtag);
+						const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
+						localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
+					}
+					this.posting = false;
+				});
+			}).catch(err => {
+				this.posting = false;
+				os.dialog({
+					type: 'error',
+					text: err.message + '\n' + (err as any).id,
+				});
+			});
+		},
+
+		cancel() {
+			this.$emit('cancel');
+		},
+
+		insertMention() {
+			os.selectUser().then(user => {
+				insertTextAtCursor(this.$refs.text, '@' + getAcct(user) + ' ');
+			});
+		},
+
+		async insertEmoji(ev) {
+			os.pickEmoji(ev.currentTarget || ev.target).then(emoji => {
+				insertTextAtCursor(this.$refs.text, emoji);
+			});
+		},
+
+		showActions(ev) {
+			os.modalMenu(postFormActions.map(action => ({
+				text: action.title,
+				action: () => {
+					action.handler({
+						text: this.text
+					}, (key, value) => {
+						if (key === 'text') { this.text = value; }
+					});
+				}
+			})), ev.currentTarget || ev.target);
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.pxiwixjf {
+	position: relative;
+	border: solid 1px var(--divider);
+	border-radius: 8px;
+
+	> .form {
+		> .preview {
+			padding: 16px;
+		}
+
+		> .with-quote {
+			margin: 0 0 8px 0;
+			color: var(--accent);
+
+			> button {
+				padding: 4px 8px;
+				color: var(--accentAlpha04);
+
+				&:hover {
+					color: var(--accentAlpha06);
+				}
+
+				&:active {
+					color: var(--accentDarken30);
+				}
+			}
+		}
+
+		> .to-specified {
+			padding: 6px 24px;
+			margin-bottom: 8px;
+			overflow: auto;
+			white-space: nowrap;
+
+			> .visibleUsers {
+				display: inline;
+				top: -1px;
+				font-size: 14px;
+
+				> button {
+					padding: 4px;
+					border-radius: 8px;
+				}
+
+				> span {
+					margin-right: 14px;
+					padding: 8px 0 8px 8px;
+					border-radius: 8px;
+					background: var(--X4);
+
+					> button {
+						padding: 4px 8px;
+					}
+				}
+			}
+		}
+
+		> .cw,
+		> .text {
+			display: block;
+			box-sizing: border-box;
+			padding: 16px;
+			margin: 0;
+			width: 100%;
+			font-size: 16px;
+			border: none;
+			border-radius: 0;
+			background: transparent;
+			color: var(--fg);
+			font-family: inherit;
+
+			&:focus {
+				outline: none;
+			}
+
+			&:disabled {
+				opacity: 0.5;
+			}
+		}
+
+		> .cw {
+			z-index: 1;
+			padding-bottom: 8px;
+			border-bottom: solid 1px var(--divider);
+		}
+
+		> .text {
+			max-width: 100%;
+			min-width: 100%;
+			min-height: 60px;
+
+			&.withCw {
+				padding-top: 8px;
+			}
+		}
+
+		> footer {
+			$height: 44px;
+			display: flex;
+			padding: 0 8px 8px 8px;
+			line-height: $height;
+
+			> .left {
+				> button {
+					display: inline-block;
+					padding: 0;
+					margin: 0;
+					font-size: 16px;
+					width: $height;
+					height: $height;
+					border-radius: 6px;
+
+					&:hover {
+						background: var(--X5);
+					}
+
+					&.active {
+						color: var(--accent);
+					}
+				}
+			}
+
+			> .right {
+				margin-left: auto;
+
+				> .text-count {
+					opacity: 0.7;
+				}
+
+				> .visibility {
+					width: $height;
+					margin: 0 8px;
+
+					& + .localOnly {
+						margin-left: 0 !important;
+					}
+				}
+				
+				> .local-only {
+					margin: 0 0 0 12px;
+					opacity: 0.7;
+				}
+
+				> .submit {
+					margin: 0;
+					padding: 0 12px;
+					line-height: 34px;
+					font-weight: bold;
+					border-radius: 4px;
+
+					&:disabled {
+						opacity: 0.7;
+					}
+
+					> [data-icon] {
+						margin-left: 6px;
+					}
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/src/client/ui/chat/side.vue b/src/client/ui/chat/side.vue
new file mode 100644
index 000000000..188123deb
--- /dev/null
+++ b/src/client/ui/chat/side.vue
@@ -0,0 +1,165 @@
+<template>
+<div class="qvzfzxam _narrow_" v-if="component">
+	<div class="container">
+		<header class="header" @contextmenu.prevent.stop="onContextmenu">
+			<button class="_button" @click="back()" v-if="history.length > 0"><Fa :icon="faChevronLeft"/></button>
+			<XHeader class="title" :info="pageInfo" :with-back="false" :center="false"/>
+			<button class="_button" @click="close()"><Fa :icon="faTimes"/></button>
+		</header>
+		<component :is="component" v-bind="props" :ref="changePage"/>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faTimes, faChevronLeft, faExpandAlt, faWindowMaximize, faExternalLinkAlt, faLink } from '@fortawesome/free-solid-svg-icons';
+import XHeader from '../_common_/header.vue';
+import * as os from '@/os';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { resolve } from '@/router';
+import { url } from '@/config';
+
+export default defineComponent({
+	components: {
+		XHeader
+	},
+
+	provide() {
+		return {
+			navHook: (path) => {
+				this.navigate(path);
+			}
+		};
+	},
+
+	data() {
+		return {
+			path: null,
+			component: null,
+			props: {},
+			pageInfo: null,
+			history: [],
+			faTimes, faChevronLeft,
+		};
+	},
+
+	computed: {
+		url(): string {
+			return url + this.path;
+		}
+	},
+
+	methods: {
+		changePage(page) {
+			if (page == null) return;
+			if (page.INFO) {
+				this.pageInfo = page.INFO;
+			}
+		},
+
+		navigate(path, record = true) {
+			if (record && this.path) this.history.push(this.path);
+			this.path = path;
+			const { component, props } = resolve(path);
+			this.component = component;
+			this.props = props;
+		},
+
+		back() {
+			this.navigate(this.history.pop(), false);
+		},
+
+		close() {
+			this.path = null;
+			this.component = null;
+			this.props = {};
+		},
+
+		onContextmenu(e) {
+			os.contextMenu([{
+				type: 'label',
+				text: this.path,
+			}, {
+				icon: faExpandAlt,
+				text: this.$ts.showInPage,
+				action: () => {
+					this.$router.push(this.path);
+					this.close();
+				}
+			}, {
+				icon: faWindowMaximize,
+				text: this.$ts.openInWindow,
+				action: () => {
+					os.pageWindow(this.path);
+					this.close();
+				}
+			}, null, {
+				icon: faExternalLinkAlt,
+				text: this.$ts.openInNewTab,
+				action: () => {
+					window.open(this.url, '_blank');
+					this.close();
+				}
+			}, {
+				icon: faLink,
+				text: this.$ts.copyLink,
+				action: () => {
+					copyToClipboard(this.url);
+				}
+			}], e);
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.qvzfzxam {
+	$header-height: 54px; // TODO: どこかに集約したい
+
+	--section-padding: 16px;
+	--margin: var(--marginHalf);
+
+	width: 390px;
+
+	> .container {
+		position: fixed;
+		width: 390px;
+		height: 100vh;
+		overflow: auto;
+		box-sizing: border-box;
+
+		> .header {
+			display: flex;
+			position: sticky;
+			z-index: 1000;
+			top: 0;
+			height: $header-height;
+			width: 100%;
+			line-height: $header-height;
+			font-weight: bold;
+			//background-color: var(--panel);
+			-webkit-backdrop-filter: blur(32px);
+			backdrop-filter: blur(32px);
+			background-color: var(--header);
+			border-bottom: solid 1px var(--divider);
+			box-sizing: border-box;
+
+			> ._button {
+				height: $header-height;
+				width: $header-height;
+
+				&:hover {
+					color: var(--fgHighlighted);
+				}
+			}
+
+			> .title {
+				flex: 1;
+				position: relative;
+			}
+		}
+	}
+}
+</style>
+
diff --git a/src/client/ui/chat/sub-note-content.vue b/src/client/ui/chat/sub-note-content.vue
new file mode 100644
index 000000000..7e742b8e5
--- /dev/null
+++ b/src/client/ui/chat/sub-note-content.vue
@@ -0,0 +1,64 @@
+<template>
+<div class="wrmlmaau">
+	<div class="body">
+		<span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span>
+		<span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span>
+		<MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><Fa :icon="faReply"/></MkA>
+		<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
+		<MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
+	</div>
+	<details v-if="note.files.length > 0">
+		<summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary>
+		<XMediaList :media-list="note.files"/>
+	</details>
+	<details v-if="note.poll">
+		<summary>{{ $ts.poll }}</summary>
+		<XPoll :note="note"/>
+	</details>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faReply } from '@fortawesome/free-solid-svg-icons';
+import XPoll from '@/components/poll.vue';
+import XMediaList from '@/components/media-list.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		XPoll,
+		XMediaList,
+	},
+	props: {
+		note: {
+			type: Object,
+			required: true
+		}
+	},
+	data() {
+		return {
+			faReply
+		};
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.wrmlmaau {
+	overflow-wrap: break-word;
+
+	> .body {
+		> .reply {
+			margin-right: 6px;
+			color: var(--accent);
+		}
+
+		> .rp {
+			margin-left: 4px;
+			font-style: oblique;
+			color: var(--renote);
+		}
+	}
+}
+</style>
diff --git a/src/client/ui/chat/timeline.vue b/src/client/ui/chat/timeline.vue
new file mode 100644
index 000000000..3a32b9fae
--- /dev/null
+++ b/src/client/ui/chat/timeline.vue
@@ -0,0 +1,190 @@
+<template>
+<XNotes ref="tl" :pagination="pagination" @queue="$emit('queue', $event)" v-follow="pagination.reversed"/>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XNotes from './notes.vue';
+import * as os from '@/os';
+import * as sound from '@/scripts/sound';
+import { scrollToBottom } from '@/scripts/scroll';
+import follow from '@/directives/follow-append';
+
+export default defineComponent({
+	components: {
+		XNotes
+	},
+
+	directives: {
+		follow
+	},
+	
+	provide() {
+		return {
+			inChannel: this.src === 'channel'
+		};
+	},
+
+	props: {
+		src: {
+			type: String,
+			required: true
+		},
+		list: {
+			type: String,
+			required: false
+		},
+		antenna: {
+			type: String,
+			required: false
+		},
+		channel: {
+			type: String,
+			required: false
+		},
+		sound: {
+			type: Boolean,
+			required: false,
+			default: false,
+		}
+	},
+
+	emits: ['note', 'queue', 'before', 'after'],
+
+	data() {
+		return {
+			connection: null,
+			connection2: null,
+			pagination: null,
+			baseQuery: {
+				includeMyRenotes: this.$store.state.showMyRenotes,
+				includeRenotedMyNotes: this.$store.state.showRenotedMyNotes,
+				includeLocalRenotes: this.$store.state.showLocalRenotes
+			},
+			query: {},
+		};
+	},
+
+	created() {
+		const prepend = note => {
+			(this.$refs.tl as any).prepend(note);
+
+			this.$emit('note');
+
+			if (this.sound) {
+				sound.play(note.userId === this.$i.id ? 'noteMy' : 'note');
+			}
+		};
+
+		const onUserAdded = () => {
+			(this.$refs.tl as any).reload();
+		};
+
+		const onUserRemoved = () => {
+			(this.$refs.tl as any).reload();
+		};
+
+		const onChangeFollowing = () => {
+			if (!this.$refs.tl.backed) {
+				this.$refs.tl.reload();
+			}
+		};
+
+		let endpoint;
+		let reversed = false;
+
+		if (this.src == 'antenna') {
+			endpoint = 'antennas/notes';
+			this.query = {
+				antennaId: this.antenna
+			};
+			this.connection = os.stream.connectToChannel('antenna', {
+				antennaId: this.antenna
+			});
+			this.connection.on('note', prepend);
+		} else if (this.src == 'home') {
+			endpoint = 'notes/timeline';
+			this.connection = os.stream.useSharedConnection('homeTimeline');
+			this.connection.on('note', prepend);
+
+			this.connection2 = os.stream.useSharedConnection('main');
+			this.connection2.on('follow', onChangeFollowing);
+			this.connection2.on('unfollow', onChangeFollowing);
+		} else if (this.src == 'local') {
+			endpoint = 'notes/local-timeline';
+			this.connection = os.stream.useSharedConnection('localTimeline');
+			this.connection.on('note', prepend);
+		} else if (this.src == 'social') {
+			endpoint = 'notes/hybrid-timeline';
+			this.connection = os.stream.useSharedConnection('hybridTimeline');
+			this.connection.on('note', prepend);
+		} else if (this.src == 'global') {
+			endpoint = 'notes/global-timeline';
+			this.connection = os.stream.useSharedConnection('globalTimeline');
+			this.connection.on('note', prepend);
+		} else if (this.src == 'mentions') {
+			endpoint = 'notes/mentions';
+			this.connection = os.stream.useSharedConnection('main');
+			this.connection.on('mention', prepend);
+		} else if (this.src == 'directs') {
+			endpoint = 'notes/mentions';
+			this.query = {
+				visibility: 'specified'
+			};
+			const onNote = note => {
+				if (note.visibility == 'specified') {
+					prepend(note);
+				}
+			};
+			this.connection = os.stream.useSharedConnection('main');
+			this.connection.on('mention', onNote);
+		} else if (this.src == 'list') {
+			endpoint = 'notes/user-list-timeline';
+			this.query = {
+				listId: this.list
+			};
+			this.connection = os.stream.connectToChannel('userList', {
+				listId: this.list
+			});
+			this.connection.on('note', prepend);
+			this.connection.on('userAdded', onUserAdded);
+			this.connection.on('userRemoved', onUserRemoved);
+		} else if (this.src == 'channel') {
+			endpoint = 'channels/timeline';
+			reversed = true;
+			this.query = {
+				channelId: this.channel
+			};
+			this.connection = os.stream.connectToChannel('channel', {
+				channelId: this.channel
+			});
+			this.connection.on('note', prepend);
+		}
+
+		this.pagination = {
+			endpoint: endpoint,
+			reversed,
+			limit: 10,
+			params: init => ({
+				untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
+				...this.baseQuery, ...this.query
+			})
+		};
+	},
+
+	mounted() {
+
+	},
+
+	beforeUnmount() {
+		this.connection.dispose();
+		if (this.connection2) this.connection2.dispose();
+	},
+
+	methods: {
+		focus() {
+			this.$refs.tl.focus();
+		},
+	}
+});
+</script>