From 9ce0f96de3ba32e25893f6d248f35badaa522479 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Tue, 5 Jun 2018 21:36:21 +0900
Subject: [PATCH 1/4] wip

---
 locales/ja.yml                                |   1 +
 src/client/app/desktop/script.ts              |   2 +
 .../views/components/ui.header.nav.vue        |   6 +
 .../app/desktop/views/components/ui.vue       |   9 +
 .../desktop/views/pages/deck/deck.column.vue  |  59 ++
 .../views/pages/deck/deck.note.sub.vue        | 153 +++++
 .../desktop/views/pages/deck/deck.note.vue    | 539 ++++++++++++++++++
 .../desktop/views/pages/deck/deck.notes.vue   | 248 ++++++++
 .../app/desktop/views/pages/deck/deck.tl.vue  | 143 +++++
 .../app/desktop/views/pages/deck/deck.vue     |  42 ++
 10 files changed, 1202 insertions(+)
 create mode 100644 src/client/app/desktop/views/pages/deck/deck.column.vue
 create mode 100644 src/client/app/desktop/views/pages/deck/deck.note.sub.vue
 create mode 100644 src/client/app/desktop/views/pages/deck/deck.note.vue
 create mode 100644 src/client/app/desktop/views/pages/deck/deck.notes.vue
 create mode 100644 src/client/app/desktop/views/pages/deck/deck.tl.vue
 create mode 100644 src/client/app/desktop/views/pages/deck/deck.vue

diff --git a/locales/ja.yml b/locales/ja.yml
index a62b341f69..026c2308c3 100644
--- a/locales/ja.yml
+++ b/locales/ja.yml
@@ -606,6 +606,7 @@ desktop/views/components/ui.header.account.vue:
 
 desktop/views/components/ui.header.nav.vue:
   home: "ホーム"
+  deck: "デッキ"
   messaging: "メッセージ"
   game: "ゲーム"
 
diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts
index 8fb6096afa..61f1f5b870 100644
--- a/src/client/app/desktop/script.ts
+++ b/src/client/app/desktop/script.ts
@@ -23,6 +23,7 @@ import updateAvatar from './api/update-avatar';
 import updateBanner from './api/update-banner';
 
 import MkIndex from './views/pages/index.vue';
+import MkDeck from './views/pages/deck/deck.vue';
 import MkUser from './views/pages/user/user.vue';
 import MkFavorites from './views/pages/favorites.vue';
 import MkSelectDrive from './views/pages/selectdrive.vue';
@@ -50,6 +51,7 @@ init(async (launch) => {
 		mode: 'history',
 		routes: [
 			{ path: '/', name: 'index', component: MkIndex },
+			{ path: '/deck', name: 'deck', component: MkDeck },
 			{ path: '/i/customize-home', component: MkHomeCustomize },
 			{ path: '/i/favorites', component: MkFavorites },
 			{ path: '/i/messaging/:user', component: MkMessagingRoom },
diff --git a/src/client/app/desktop/views/components/ui.header.nav.vue b/src/client/app/desktop/views/components/ui.header.nav.vue
index 4780c57cb4..8e792b3df5 100644
--- a/src/client/app/desktop/views/components/ui.header.nav.vue
+++ b/src/client/app/desktop/views/components/ui.header.nav.vue
@@ -8,6 +8,12 @@
 					<p>%i18n:@home%</p>
 				</router-link>
 			</li>
+			<li class="deck" :class="{ active: $route.name == 'deck' }">
+				<router-link to="/deck">
+					%fa:columns%
+					<p>%i18n:@deck%</p>
+				</router-link>
+			</li>
 			<li class="messaging">
 				<a @click="messaging">
 					%fa:comments%
diff --git a/src/client/app/desktop/views/components/ui.vue b/src/client/app/desktop/views/components/ui.vue
index 32cc71e4b0..ad6fc69dfa 100644
--- a/src/client/app/desktop/views/components/ui.vue
+++ b/src/client/app/desktop/views/components/ui.vue
@@ -37,7 +37,16 @@ export default Vue.extend({
 
 <style lang="stylus" scoped>
 .mk-ui
+	display flex
+	flex-direction column
+	flex 1
+
 	> .header
 		@media (max-width 1000px)
 			display none
+
+	> .content
+		display flex
+		flex-direction column
+		flex 1
 </style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.column.vue b/src/client/app/desktop/views/pages/deck/deck.column.vue
new file mode 100644
index 0000000000..4e06798293
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.column.vue
@@ -0,0 +1,59 @@
+<template>
+<div class="dnpfarvgbnfmyzbdquhhzyxcmstpdqzs">
+	<header>
+		<slot name="header">Timeline</slot>
+	</header>
+	<div ref="body">
+		<x-tl ref="tl"/>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XTl from './deck.tl.vue';
+
+export default Vue.extend({
+	components: {
+		XTl
+	},
+	mounted() {
+		this.$nextTick(() => {
+			this.$refs.tl.mount(this.$refs.body);
+		});
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+root(isDark)
+	flex 1
+	max-width 330px
+	height 100%
+	margin-right 16px
+	background isDark ? #282C37 : #fff
+	border-radius 6px
+	box-shadow 0 2px 16px rgba(#000, 0.1)
+	overflow hidden
+
+	> header
+		z-index 1
+		line-height 48px
+		padding 0 16px
+		color isDark ? #e3e5e8 : #888
+		background isDark ? #313543 : #fff
+		box-shadow 0 1px rgba(#000, 0.15)
+
+	> div
+		height calc(100% - 48px)
+		overflow auto
+
+.dnpfarvgbnfmyzbdquhhzyxcmstpdqzs[data-darkmode]
+	root(true)
+
+.dnpfarvgbnfmyzbdquhhzyxcmstpdqzs:not([data-darkmode])
+	root(false)
+
+</style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.note.sub.vue b/src/client/app/desktop/views/pages/deck/deck.note.sub.vue
new file mode 100644
index 0000000000..b458b74186
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.note.sub.vue
@@ -0,0 +1,153 @@
+<template>
+<div class="fnlfosztlhtptnongximhlbykxblytcq">
+	<mk-avatar class="avatar" :user="note.user"/>
+	<div class="main">
+		<header>
+			<router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link>
+			<span class="is-admin" v-if="note.user.isAdmin">%i18n:@admin%</span>
+			<span class="is-bot" v-if="note.user.isBot">%i18n:@bot%</span>
+			<span class="is-cat" v-if="note.user.isCat">%i18n:@cat%</span>
+			<span class="username"><mk-acct :user="note.user"/></span>
+			<div class="info">
+				<span class="mobile" v-if="note.viaMobile">%fa:mobile-alt%</span>
+				<router-link class="created-at" :to="note | notePage">
+					<mk-time :time="note.createdAt"/>
+				</router-link>
+				<span class="visibility" v-if="note.visibility != 'public'">
+					<template v-if="note.visibility == 'home'">%fa:home%</template>
+					<template v-if="note.visibility == 'followers'">%fa:unlock%</template>
+					<template v-if="note.visibility == 'specified'">%fa:envelope%</template>
+					<template v-if="note.visibility == 'private'">%fa:lock%</template>
+				</span>
+			</div>
+		</header>
+		<div class="body">
+			<mk-sub-note-content class="text" :note="note"/>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+export default Vue.extend({
+	props: {
+		note: {
+			type: Object,
+			required: true
+		},
+		// TODO
+		truncate: {
+			type: Boolean,
+			default: true
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+root(isDark)
+	display flex
+	padding 16px
+	font-size 10px
+	background isDark ? #21242d : #fcfcfc
+
+	&.smart
+		> .main
+			width 100%
+
+			> header
+				align-items center
+
+	> .avatar
+		flex-shrink 0
+		display block
+		margin 0 8px 0 0
+		width 38px
+		height 38px
+		border-radius 8px
+
+	> .main
+		flex 1
+		min-width 0
+
+		> header
+			display flex
+			align-items baseline
+			margin-bottom 2px
+			white-space nowrap
+
+			> .avatar
+				flex-shrink 0
+				margin-right 8px
+				width 18px
+				height 18px
+				border-radius 100%
+
+			> .name
+				display block
+				margin 0 0.5em 0 0
+				padding 0
+				overflow hidden
+				color isDark ? #fff : #607073
+				font-size 1em
+				font-weight 700
+				text-align left
+				text-decoration none
+				text-overflow ellipsis
+
+				&:hover
+					text-decoration underline
+
+			> .is-admin
+			> .is-bot
+			> .is-cat
+				align-self center
+				margin 0 0.5em 0 0
+				padding 1px 5px
+				font-size 0.8em
+				color isDark ? #758188 : #aaa
+				border solid 1px isDark ? #57616f : #ddd
+				border-radius 3px
+
+				&.is-admin
+					border-color isDark ? #d42c41 : #f56a7b
+					color isDark ? #d42c41 : #f56a7b
+
+			> .username
+				text-align left
+				margin 0
+				color isDark ? #606984 : #d1d8da
+
+			> .info
+				margin-left auto
+				font-size 0.9em
+
+				> *
+					color isDark ? #606984 : #b2b8bb
+
+				> .mobile
+					margin-right 6px
+
+				> .visibility
+					margin-left 6px
+
+		> .body
+
+			> .text
+				margin 0
+				padding 0
+				color isDark ? #959ba7 : #717171
+
+				pre
+					max-height 120px
+					font-size 80%
+
+.fnlfosztlhtptnongximhlbykxblytcq[data-darkmode]
+	root(true)
+
+.fnlfosztlhtptnongximhlbykxblytcq:not([data-darkmode])
+	root(false)
+
+</style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.note.vue b/src/client/app/desktop/views/pages/deck/deck.note.vue
new file mode 100644
index 0000000000..8582a37b91
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.note.vue
@@ -0,0 +1,539 @@
+<template>
+<div class="zyjjkidcqjnlegkqebitfviomuqmseqk" :class="{ renote: isRenote }">
+	<div class="reply-to" v-if="p.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)">
+		<x-sub :note="p.reply"/>
+	</div>
+	<div class="renote" v-if="isRenote">
+		<mk-avatar class="avatar" :user="note.user"/>
+		%fa:retweet%
+		<span>{{ '%i18n:@reposted-by%'.substr(0, '%i18n:@reposted-by%'.indexOf('{')) }}</span>
+		<router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link>
+		<span>{{ '%i18n:@reposted-by%'.substr('%i18n:@reposted-by%'.indexOf('}') + 1) }}</span>
+		<mk-time :time="note.createdAt"/>
+	</div>
+	<article>
+		<mk-avatar class="avatar" :user="p.user"/>
+		<div class="main">
+			<header>
+				<router-link class="name" :to="p.user | userPage">{{ p.user | userName }}</router-link>
+				<span class="is-admin" v-if="p.user.isAdmin">admin</span>
+				<span class="is-bot" v-if="p.user.isBot">bot</span>
+				<span class="is-cat" v-if="p.user.isCat">cat</span>
+				<span class="username"><mk-acct :user="p.user"/></span>
+				<div class="info">
+					<span class="mobile" v-if="p.viaMobile">%fa:mobile-alt%</span>
+					<router-link class="created-at" :to="p | notePage">
+						<mk-time :time="p.createdAt"/>
+					</router-link>
+					<span class="visibility" v-if="p.visibility != 'public'">
+						<template v-if="p.visibility == 'home'">%fa:home%</template>
+						<template v-if="p.visibility == 'followers'">%fa:unlock%</template>
+						<template v-if="p.visibility == 'specified'">%fa:envelope%</template>
+						<template v-if="p.visibility == 'private'">%fa:lock%</template>
+					</span>
+				</div>
+			</header>
+			<div class="body">
+				<p v-if="p.cw != null" class="cw">
+					<span class="text" v-if="p.cw != ''">{{ p.cw }}</span>
+					<span class="toggle" @click="showContent = !showContent">{{ showContent ? '%i18n:@less%' : '%i18n:@more%' }}</span>
+				</p>
+				<div class="content" v-show="p.cw == null || showContent">
+					<div class="text">
+						<span v-if="p.isHidden" style="opacity: 0.5">(%i18n:@private%)</span>
+						<span v-if="p.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</span>
+						<a class="reply" v-if="p.reply">%fa:reply%</a>
+						<mk-note-html v-if="p.text && !canHideText(p)" :text="p.text" :i="$store.state.i"/>
+						<a class="rp" v-if="p.renote != null">RP:</a>
+					</div>
+					<div class="media" v-if="p.media.length > 0">
+						<mk-media-list :media-list="p.media"/>
+					</div>
+					<mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
+					<div class="tags" v-if="p.tags && p.tags.length > 0">
+						<router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link>
+					</div>
+					<a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
+					<div class="renote" v-if="p.renote">
+						<mk-note-preview :note="p.renote"/>
+					</div>
+				</div>
+				<span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span>
+			</div>
+			<footer>
+				<mk-reactions-viewer :note="p" ref="reactionsViewer"/>
+				<button @click="reply">
+					<template v-if="p.reply">%fa:reply-all%</template>
+					<template v-else>%fa:reply%</template>
+				</button>
+				<button @click="renote" title="Renote">%fa:retweet%</button>
+				<button :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton">%fa:plus%</button>
+				<button class="menu" @click="menu" ref="menuButton">%fa:ellipsis-h%</button>
+			</footer>
+		</div>
+	</article>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import parse from '../../../../../../text/parse';
+import canHideText from '../../../../common/scripts/can-hide-text';
+
+import MkNoteMenu from '../../../../common/views/components/note-menu.vue';
+import MkReactionPicker from '../../../../common/views/components/reaction-picker.vue';
+import XSub from './deck.note.sub.vue';
+
+export default Vue.extend({
+	components: {
+		XSub
+	},
+
+	props: ['note'],
+
+	data() {
+		return {
+			showContent: false,
+			connection: null,
+			connectionId: null
+		};
+	},
+
+	computed: {
+		isRenote(): boolean {
+			return (this.note.renote &&
+				this.note.text == null &&
+				this.note.mediaIds.length == 0 &&
+				this.note.poll == null);
+		},
+
+		p(): any {
+			return this.isRenote ? this.note.renote : this.note;
+		},
+
+		reactionsCount(): number {
+			return this.p.reactionCounts
+				? Object.keys(this.p.reactionCounts)
+					.map(key => this.p.reactionCounts[key])
+					.reduce((a, b) => a + b)
+				: 0;
+		},
+
+		urls(): string[] {
+			if (this.p.text) {
+				const ast = parse(this.p.text);
+				return ast
+					.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
+					.map(t => t.url);
+			} else {
+				return null;
+			}
+		}
+	},
+
+	created() {
+		if (this.$store.getters.isSignedIn) {
+			this.connection = (this as any).os.stream.getConnection();
+			this.connectionId = (this as any).os.stream.use();
+		}
+	},
+
+	mounted() {
+		this.capture(true);
+
+		if (this.$store.getters.isSignedIn) {
+			this.connection.on('_connected_', this.onStreamConnected);
+		}
+
+		// Draw map
+		if (this.p.geo) {
+			const shouldShowMap = this.$store.getters.isSignedIn ? this.$store.state.settings.showMaps : true;
+			if (shouldShowMap) {
+				(this as any).os.getGoogleMaps().then(maps => {
+					const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]);
+					const map = new maps.Map(this.$refs.map, {
+						center: uluru,
+						zoom: 15
+					});
+					new maps.Marker({
+						position: uluru,
+						map: map
+					});
+				});
+			}
+		}
+	},
+
+	beforeDestroy() {
+		this.decapture(true);
+
+		if (this.$store.getters.isSignedIn) {
+			this.connection.off('_connected_', this.onStreamConnected);
+			(this as any).os.stream.dispose(this.connectionId);
+		}
+	},
+
+	methods: {
+		canHideText,
+
+		capture(withHandler = false) {
+			if (this.$store.getters.isSignedIn) {
+				this.connection.send({
+					type: 'capture',
+					id: this.p.id
+				});
+				if (withHandler) this.connection.on('note-updated', this.onStreamNoteUpdated);
+			}
+		},
+
+		decapture(withHandler = false) {
+			if (this.$store.getters.isSignedIn) {
+				this.connection.send({
+					type: 'decapture',
+					id: this.p.id
+				});
+				if (withHandler) this.connection.off('note-updated', this.onStreamNoteUpdated);
+			}
+		},
+
+		onStreamConnected() {
+			this.capture();
+		},
+
+		onStreamNoteUpdated(data) {
+			const note = data.note;
+			if (note.id == this.note.id) {
+				this.$emit('update:note', note);
+			} else if (note.id == this.note.renoteId) {
+				this.note.renote = note;
+			}
+		},
+
+		reply() {
+			(this as any).apis.post({
+				reply: this.p
+			});
+		},
+
+		renote() {
+			(this as any).apis.post({
+				renote: this.p
+			});
+		},
+
+		react() {
+			(this as any).os.new(MkReactionPicker, {
+				source: this.$refs.reactButton,
+				note: this.p,
+				compact: true
+			});
+		},
+
+		menu() {
+			(this as any).os.new(MkNoteMenu, {
+				source: this.$refs.menuButton,
+				note: this.p,
+				compact: true
+			});
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+root(isDark)
+	font-size 12px
+	border-bottom solid 1px isDark ? #1c2023 : #eaeaea
+
+	&:last-of-type
+		border-bottom none
+
+	&.smart
+		> article
+			> .main
+				> header
+					align-items center
+					margin-bottom 4px
+
+	> .renote
+		display flex
+		align-items center
+		padding 8px 16px
+		line-height 28px
+		white-space pre
+		color #9dbb00
+		background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%)
+
+		.avatar
+			flex-shrink 0
+			display inline-block
+			width 20px
+			height 20px
+			margin 0 8px 0 0
+			border-radius 6px
+
+		[data-fa]
+			margin-right 4px
+
+		> span
+			flex-shrink 0
+
+			&:last-of-type
+				margin-right 8px
+
+		.name
+			overflow hidden
+			flex-shrink 1
+			text-overflow ellipsis
+			white-space nowrap
+			font-weight bold
+
+		> .mk-time
+			display block
+			margin-left auto
+			flex-shrink 0
+			font-size 0.9em
+
+		& + article
+			padding-top 8px
+
+	> article
+		display flex
+		padding 16px 16px 9px
+
+		> .avatar
+			flex-shrink 0
+			display block
+			margin 0 10px 8px 0
+			width 42px
+			height 42px
+			border-radius 6px
+			//position -webkit-sticky
+			//position sticky
+			//top 62px
+
+		> .main
+			flex 1
+			min-width 0
+
+			> header
+				display flex
+				align-items baseline
+				white-space nowrap
+
+				> .avatar
+					flex-shrink 0
+					margin-right 8px
+					width 20px
+					height 20px
+					border-radius 100%
+
+				> .name
+					display block
+					margin 0 0.5em 0 0
+					padding 0
+					overflow hidden
+					color isDark ? #fff : #627079
+					font-weight bold
+					text-decoration none
+					text-overflow ellipsis
+
+				> .is-admin
+				> .is-bot
+				> .is-cat
+					align-self center
+					margin 0 0.5em 0 0
+					padding 1px 6px
+					font-size 0.8em
+					color isDark ? #758188 : #aaa
+					border solid 1px isDark ? #57616f : #ddd
+					border-radius 3px
+
+					&.is-admin
+						border-color isDark ? #d42c41 : #f56a7b
+						color isDark ? #d42c41 : #f56a7b
+
+				> .username
+					margin 0 0.5em 0 0
+					overflow hidden
+					text-overflow ellipsis
+					color isDark ? #606984 : #ccc
+
+				> .info
+					margin-left auto
+					font-size 0.9em
+
+					> *
+						color isDark ? #606984 : #c0c0c0
+
+					> .mobile
+						margin-right 6px
+
+					> .visibility
+						margin-left 6px
+
+			> .body
+
+				> .cw
+					cursor default
+					display block
+					margin 0
+					padding 0
+					overflow-wrap break-word
+					color isDark ? #fff : #717171
+
+					> .text
+						margin-right 8px
+
+					> .toggle
+						display inline-block
+						padding 4px 8px
+						font-size 0.7em
+						color isDark ? #393f4f : #fff
+						background isDark ? #687390 : #b1b9c1
+						border-radius 2px
+						cursor pointer
+						user-select none
+
+						&:hover
+							background isDark ? #707b97 : #bbc4ce
+
+				> .content
+
+					> .text
+						display block
+						margin 0
+						padding 0
+						overflow-wrap break-word
+						color isDark ? #fff : #717171
+
+						>>> .title
+							display block
+							margin-bottom 4px
+							padding 4px
+							font-size 90%
+							text-align center
+							background isDark ? #2f3944 : #eef1f3
+							border-radius 4px
+
+						>>> .code
+							margin 8px 0
+
+						>>> .quote
+							margin 8px
+							padding 6px 12px
+							color isDark ? #6f808e : #aaa
+							border-left solid 3px isDark ? #637182 : #eee
+
+						> .reply
+							margin-right 8px
+							color isDark ? #99abbf : #717171
+
+						> .rp
+							margin-left 4px
+							font-style oblique
+							color #a0bf46
+
+						[data-is-me]:after
+							content "you"
+							padding 0 4px
+							margin-left 4px
+							font-size 80%
+							color $theme-color-foreground
+							background $theme-color
+							border-radius 4px
+
+					.mk-url-preview
+						margin-top 8px
+
+					> .tags
+						margin 4px 0 0 0
+
+						> *
+							display inline-block
+							margin 0 8px 0 0
+							padding 2px 8px 2px 16px
+							font-size 90%
+							color #8d969e
+							background isDark ? #313543 : #edf0f3
+							border-radius 4px
+
+							&:before
+								content ""
+								display block
+								position absolute
+								top 0
+								bottom 0
+								left 4px
+								width 8px
+								height 8px
+								margin auto 0
+								background isDark ? #282c37 : #fff
+								border-radius 100%
+
+					> .media
+						> img
+							display block
+							max-width 100%
+
+					> .location
+						margin 4px 0
+						font-size 12px
+						color #ccc
+
+					> .map
+						width 100%
+						height 200px
+
+						&:empty
+							display none
+
+					> .mk-poll
+						font-size 80%
+
+					> .renote
+						margin 8px 0
+
+						> .mk-note-preview
+							padding 16px
+							border dashed 1px isDark ? #4e945e : #c0dac6
+							border-radius 8px
+
+				> .app
+					font-size 12px
+					color #ccc
+
+			> footer
+				> button
+					margin 0
+					padding 8px
+					background transparent
+					border none
+					box-shadow none
+					font-size 1em
+					color isDark ? #606984 : #ddd
+					cursor pointer
+
+					&:not(:last-child)
+						margin-right 28px
+
+					&:hover
+						color isDark ? #9198af : #666
+
+					> .count
+						display inline
+						margin 0 0 0 8px
+						color #999
+
+					&.reacted
+						color $theme-color
+
+.zyjjkidcqjnlegkqebitfviomuqmseqk[data-darkmode]
+	root(true)
+
+.zyjjkidcqjnlegkqebitfviomuqmseqk:not([data-darkmode])
+	root(false)
+
+</style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.notes.vue b/src/client/app/desktop/views/pages/deck/deck.notes.vue
new file mode 100644
index 0000000000..ff871b049d
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.notes.vue
@@ -0,0 +1,248 @@
+<template>
+<div class="eamppglmnmimdhrlzhplwpvyeaqmmhxu">
+	<div class="newer-indicator" v-show="queue.length > 0"></div>
+
+	<slot name="empty" v-if="notes.length == 0 && !fetching && requestInitPromise == null"></slot>
+
+	<div v-if="!fetching && requestInitPromise != null">
+		<p>%i18n:@error%</p>
+		<button @click="resolveInitPromise">%i18n:@retry%</button>
+	</div>
+
+	<transition-group name="mk-notes" class="transition">
+		<template v-for="(note, i) in _notes">
+			<x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/>
+			<p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date">
+				<span>%fa:angle-up%{{ note._datetext }}</span>
+				<span>%fa:angle-down%{{ _notes[i + 1]._datetext }}</span>
+			</p>
+		</template>
+	</transition-group>
+
+	<footer v-if="more">
+		<button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+			<template v-if="!moreFetching">%i18n:@load-more%</template>
+			<template v-if="moreFetching">%fa:spinner .pulse .fw%</template>
+		</button>
+	</footer>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { url } from '../../../config';
+import getNoteSummary from '../../../../../renderers/get-note-summary';
+
+import XNote from './deck.note.vue';
+
+const displayLimit = 30;
+
+export default Vue.extend({
+	components: {
+		XNote
+	},
+
+	props: {
+		more: {
+			type: Function,
+			required: false
+		}
+	},
+
+	data() {
+		return {
+			rootEl: null,
+			requestInitPromise: null as () => Promise<any[]>,
+			notes: [],
+			queue: [],
+			unreadCount: 0,
+			fetching: true,
+			moreFetching: false
+		};
+	},
+
+	computed: {
+		_notes(): any[] {
+			return (this.notes as any).map(note => {
+				const date = new Date(note.createdAt).getDate();
+				const month = new Date(note.createdAt).getMonth() + 1;
+				note._date = date;
+				note._datetext = `${month}月 ${date}日`;
+				return note;
+			});
+		}
+	},
+
+	beforeDestroy() {
+		this.root.removeEventListener('scroll', this.onScroll);
+	},
+
+	methods: {
+		mount(root) {
+			this.rootEl = root;
+			this.rootEl.addEventListener('scroll', this.onScroll);
+		},
+
+		isScrollTop() {
+			if (this.rootEl == null) return true;
+			return this.rootEl.scrollTop <= 8;
+		},
+
+		focus() {
+			(this.$el as any).children[0].focus();
+		},
+
+		onNoteUpdated(i, note) {
+			Vue.set((this as any).notes, i, note);
+		},
+
+		init(promiseGenerator: () => Promise<any[]>) {
+			this.requestInitPromise = promiseGenerator;
+			this.resolveInitPromise();
+		},
+
+		resolveInitPromise() {
+			this.queue = [];
+			this.notes = [];
+			this.fetching = true;
+
+			const promise = this.requestInitPromise();
+
+			promise.then(notes => {
+				this.notes = notes;
+				this.requestInitPromise = null;
+				this.fetching = false;
+			}, e => {
+				this.fetching = false;
+			});
+		},
+
+		prepend(note, silent = false) {
+			//#region 弾く
+			const isMyNote = note.userId == this.$store.state.i.id;
+			const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null;
+
+			if (this.$store.state.settings.showMyRenotes === false) {
+				if (isMyNote && isPureRenote) {
+					return;
+				}
+			}
+
+			if (this.$store.state.settings.showRenotedMyNotes === false) {
+				if (isPureRenote && (note.renote.userId == this.$store.state.i.id)) {
+					return;
+				}
+			}
+			//#endregion
+
+			if (this.isScrollTop()) {
+				// Prepend the note
+				this.notes.unshift(note);
+
+				// オーバーフローしたら古い投稿は捨てる
+				if (this.notes.length >= displayLimit) {
+					this.notes = this.notes.slice(0, displayLimit);
+				}
+			} else {
+				this.queue.push(note);
+			}
+		},
+
+		append(note) {
+			this.notes.push(note);
+		},
+
+		tail() {
+			return this.notes[this.notes.length - 1];
+		},
+
+		releaseQueue() {
+			this.queue.forEach(n => this.prepend(n, true));
+			this.queue = [];
+		},
+
+		async loadMore() {
+			if (this.more == null) return;
+			if (this.moreFetching) return;
+
+			this.moreFetching = true;
+			await this.more();
+			this.moreFetching = false;
+		},
+
+		onScroll() {
+			if (this.isScrollTop()) {
+				this.releaseQueue();
+			}
+
+			if (this.rootEl && this.$store.state.settings.fetchOnScroll !== false) {
+				const current = this.rootEl.scrollTop + this.rootEl.clientHeight;
+				if (current > this.rootEl.scrollHeight - 8) this.loadMore();
+			}
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+root(isDark)
+	.transition
+		.mk-notes-enter
+		.mk-notes-leave-to
+			opacity 0
+			transform translateY(-30px)
+
+		> *
+			transition transform .3s ease, opacity .3s ease
+
+		> .date
+			display block
+			margin 0
+			line-height 32px
+			font-size 14px
+			text-align center
+			color isDark ? #666b79 : #aaa
+			background isDark ? #242731 : #fdfdfd
+			border-bottom solid 1px isDark ? #1c2023 : #eaeaea
+
+			span
+				margin 0 16px
+
+			[data-fa]
+				margin-right 8px
+
+	> .newer-indicator
+		position -webkit-sticky
+		position sticky
+		z-index 100
+		height 3px
+		background $theme-color
+
+	> footer
+		> button
+			display block
+			margin 0
+			padding 16px
+			width 100%
+			text-align center
+			color #ccc
+			background isDark ? #282C37 : #fff
+			border-top solid 1px isDark ? #1c2023 : #eaeaea
+			border-bottom-left-radius 6px
+			border-bottom-right-radius 6px
+
+			&:hover
+				background isDark ? #2e3440 : #f5f5f5
+
+			&:active
+				background isDark ? #21242b : #eee
+
+.eamppglmnmimdhrlzhplwpvyeaqmmhxu[data-darkmode]
+	root(true)
+
+.eamppglmnmimdhrlzhplwpvyeaqmmhxu:not([data-darkmode])
+	root(false)
+
+</style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.tl.vue b/src/client/app/desktop/views/pages/deck/deck.tl.vue
new file mode 100644
index 0000000000..ce9a77703f
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.tl.vue
@@ -0,0 +1,143 @@
+<template>
+	<x-notes ref="timeline" :more="existMore ? more : null"/>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XNotes from './deck.notes.vue';
+
+const fetchLimit = 10;
+
+export default Vue.extend({
+	components: {
+		XNotes
+	},
+
+	props: {
+		root: {
+			type: Object,
+			required: false
+		},
+		src: {
+			type: String,
+			required: false,
+			default: 'home'
+		}
+	},
+
+	data() {
+		return {
+			fetching: true,
+			moreFetching: false,
+			existMore: false,
+			connection: null,
+			connectionId: null,
+			unreadCount: 0,
+			date: null
+		};
+	},
+
+	computed: {
+		stream(): any {
+			return this.src == 'home'
+				? (this as any).os.stream
+				: this.src == 'local'
+					? (this as any).os.streams.localTimelineStream
+					: (this as any).os.streams.globalTimelineStream;
+		},
+
+		endpoint(): string {
+			return this.src == 'home'
+				? 'notes/timeline'
+				: this.src == 'local'
+					? 'notes/local-timeline'
+					: 'notes/global-timeline';
+		}
+	},
+
+	mounted() {
+		this.connection = this.stream.getConnection();
+		this.connectionId = this.stream.use();
+
+		this.connection.on('note', this.onNote);
+		if (this.src == 'home') {
+			this.connection.on('follow', this.onChangeFollowing);
+			this.connection.on('unfollow', this.onChangeFollowing);
+		}
+
+		this.fetch();
+	},
+
+	beforeDestroy() {
+		this.connection.off('note', this.onNote);
+		if (this.src == 'home') {
+			this.connection.off('follow', this.onChangeFollowing);
+			this.connection.off('unfollow', this.onChangeFollowing);
+		}
+		this.stream.dispose(this.connectionId);
+	},
+
+	methods: {
+		mount(root) {
+			this.$refs.timeline.mount(root);
+		},
+
+		fetch() {
+			this.fetching = true;
+
+			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
+				(this as any).api(this.endpoint, {
+					limit: fetchLimit + 1,
+					untilDate: this.date ? this.date.getTime() : undefined,
+					includeMyRenotes: this.$store.state.settings.showMyRenotes,
+					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes
+				}).then(notes => {
+					if (notes.length == fetchLimit + 1) {
+						notes.pop();
+						this.existMore = true;
+					}
+					res(notes);
+					this.fetching = false;
+					this.$emit('loaded');
+				}, rej);
+			}));
+		},
+
+		more() {
+			this.moreFetching = true;
+
+			const promise = (this as any).api(this.endpoint, {
+				limit: fetchLimit + 1,
+				untilId: (this.$refs.timeline as any).tail().id,
+				includeMyRenotes: this.$store.state.settings.showMyRenotes,
+				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes
+			});
+
+			promise.then(notes => {
+				if (notes.length == fetchLimit + 1) {
+					notes.pop();
+				} else {
+					this.existMore = false;
+				}
+				notes.forEach(n => (this.$refs.timeline as any).append(n));
+				this.moreFetching = false;
+			});
+
+			return promise;
+		},
+
+		onNote(note) {
+			// Prepend a note
+			(this.$refs.timeline as any).prepend(note);
+		},
+
+		onChangeFollowing() {
+			this.fetch();
+		},
+
+		focus() {
+			(this.$refs.timeline as any).focus();
+		}
+	}
+});
+</script>
diff --git a/src/client/app/desktop/views/pages/deck/deck.vue b/src/client/app/desktop/views/pages/deck/deck.vue
new file mode 100644
index 0000000000..afb65d2335
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.vue
@@ -0,0 +1,42 @@
+<template>
+<mk-ui :class="$style.root">
+	<div class="qlvquzbjribqcaozciifydkngcwtyzje">
+		<x-column src="home"/>
+		<x-column src="home"/>
+		<x-column src="home"/>
+		<x-column src="home"/>
+	</div>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XColumn from './deck.column.vue';
+
+export default Vue.extend({
+	components: {
+		XColumn
+	}
+});
+</script>
+
+<style lang="stylus" module>
+.root
+	height 100vh
+</style>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+root(isDark)
+	display flex
+	flex 1
+	padding 16px
+
+.qlvquzbjribqcaozciifydkngcwtyzje[data-darkmode]
+	root(true)
+
+.qlvquzbjribqcaozciifydkngcwtyzje:not([data-darkmode])
+	root(false)
+
+</style>

From e28d1c756971cdfda5364c80f6af7a69dbf5ffc1 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Tue, 5 Jun 2018 21:44:02 +0900
Subject: [PATCH 2/4] wip

---
 src/client/app/desktop/views/pages/deck/deck.column.vue | 3 ++-
 src/client/app/desktop/views/pages/deck/deck.vue        | 3 ++-
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/src/client/app/desktop/views/pages/deck/deck.column.vue b/src/client/app/desktop/views/pages/deck/deck.column.vue
index 4e06798293..e0fc394f33 100644
--- a/src/client/app/desktop/views/pages/deck/deck.column.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.column.vue
@@ -30,7 +30,7 @@ export default Vue.extend({
 
 root(isDark)
 	flex 1
-	max-width 330px
+	min-width 330px
 	height 100%
 	margin-right 16px
 	background isDark ? #282C37 : #fff
@@ -49,6 +49,7 @@ root(isDark)
 	> div
 		height calc(100% - 48px)
 		overflow auto
+		overflow-x hidden
 
 .dnpfarvgbnfmyzbdquhhzyxcmstpdqzs[data-darkmode]
 	root(true)
diff --git a/src/client/app/desktop/views/pages/deck/deck.vue b/src/client/app/desktop/views/pages/deck/deck.vue
index afb65d2335..0c32b7d665 100644
--- a/src/client/app/desktop/views/pages/deck/deck.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.vue
@@ -31,7 +31,8 @@ export default Vue.extend({
 root(isDark)
 	display flex
 	flex 1
-	padding 16px
+	padding 16px 0 16px 16px
+	overflow auto
 
 .qlvquzbjribqcaozciifydkngcwtyzje[data-darkmode]
 	root(true)

From dfa2c951d67a64f4e72a0ebca7ff81e40fc25976 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Tue, 5 Jun 2018 22:54:03 +0900
Subject: [PATCH 3/4] wip

---
 .../views/components/ui.header.nav.vue        |  2 +-
 .../desktop/views/pages/deck/deck.column.vue  | 25 +++++++++++---
 .../desktop/views/pages/deck/deck.notes.vue   | 16 +++++----
 .../views/pages/deck/deck.tl-column.vue       | 33 +++++++++++++++++++
 .../app/desktop/views/pages/deck/deck.tl.vue  |  4 ---
 .../app/desktop/views/pages/deck/deck.vue     | 11 +++----
 src/services/note/create.ts                   |  4 ++-
 7 files changed, 72 insertions(+), 23 deletions(-)
 create mode 100644 src/client/app/desktop/views/pages/deck/deck.tl-column.vue

diff --git a/src/client/app/desktop/views/components/ui.header.nav.vue b/src/client/app/desktop/views/components/ui.header.nav.vue
index 8e792b3df5..fe2637cec3 100644
--- a/src/client/app/desktop/views/components/ui.header.nav.vue
+++ b/src/client/app/desktop/views/components/ui.header.nav.vue
@@ -11,7 +11,7 @@
 			<li class="deck" :class="{ active: $route.name == 'deck' }">
 				<router-link to="/deck">
 					%fa:columns%
-					<p>%i18n:@deck%</p>
+					<p>%i18n:@deck% <small>(beta)</small></p>
 				</router-link>
 			</li>
 			<li class="messaging">
diff --git a/src/client/app/desktop/views/pages/deck/deck.column.vue b/src/client/app/desktop/views/pages/deck/deck.column.vue
index e0fc394f33..8d0b3c0fdb 100644
--- a/src/client/app/desktop/views/pages/deck/deck.column.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.column.vue
@@ -1,10 +1,10 @@
 <template>
 <div class="dnpfarvgbnfmyzbdquhhzyxcmstpdqzs">
 	<header>
-		<slot name="header">Timeline</slot>
+		<slot name="header"></slot>
 	</header>
 	<div ref="body">
-		<x-tl ref="tl"/>
+		<slot></slot>
 	</div>
 </div>
 </template>
@@ -17,9 +17,23 @@ export default Vue.extend({
 	components: {
 		XTl
 	},
+	provide() {
+		return {
+			getColumn() {
+				return this;
+			},
+			getScrollContainer() {
+				return this.$refs.body;
+			}
+		};
+	},
 	mounted() {
 		this.$nextTick(() => {
-			this.$refs.tl.mount(this.$refs.body);
+			this.$emit('mounted');
+
+			setInterval(() => {
+				this.$emit('mounted');
+			}, 100);
 		});
 	}
 });
@@ -31,6 +45,7 @@ export default Vue.extend({
 root(isDark)
 	flex 1
 	min-width 330px
+	max-width 330px
 	height 100%
 	margin-right 16px
 	background isDark ? #282C37 : #fff
@@ -40,14 +55,14 @@ root(isDark)
 
 	> header
 		z-index 1
-		line-height 48px
+		line-height 42px
 		padding 0 16px
 		color isDark ? #e3e5e8 : #888
 		background isDark ? #313543 : #fff
 		box-shadow 0 1px rgba(#000, 0.15)
 
 	> div
-		height calc(100% - 48px)
+		height calc(100% - 42px)
 		overflow auto
 		overflow-x hidden
 
diff --git a/src/client/app/desktop/views/pages/deck/deck.notes.vue b/src/client/app/desktop/views/pages/deck/deck.notes.vue
index ff871b049d..48be4e585c 100644
--- a/src/client/app/desktop/views/pages/deck/deck.notes.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.notes.vue
@@ -73,16 +73,20 @@ export default Vue.extend({
 		}
 	},
 
+	inject: ['getColumn', 'getScrollContainer'],
+
+	created() {
+		this.getColumn().$once('mounted', () => {
+			this.rootEl = this.getScrollContainer();
+			this.rootEl.addEventListener('scroll', this.onScroll);
+		})
+	},
+
 	beforeDestroy() {
-		this.root.removeEventListener('scroll', this.onScroll);
+		this.rootEl.removeEventListener('scroll', this.onScroll);
 	},
 
 	methods: {
-		mount(root) {
-			this.rootEl = root;
-			this.rootEl.addEventListener('scroll', this.onScroll);
-		},
-
 		isScrollTop() {
 			if (this.rootEl == null) return true;
 			return this.rootEl.scrollTop <= 8;
diff --git a/src/client/app/desktop/views/pages/deck/deck.tl-column.vue b/src/client/app/desktop/views/pages/deck/deck.tl-column.vue
new file mode 100644
index 0000000000..674f04077f
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.tl-column.vue
@@ -0,0 +1,33 @@
+<template>
+<div>
+	<x-column>
+		<span slot="header">
+			<template v-if="src == 'home'">%fa:home% %i18n:@home%</template>
+			<template v-if="src == 'local'">%fa:R comments% %i18n:@local%</template>
+			<template v-if="src == 'global'">%fa:globe% %i18n:@global%</template>
+			<template v-if="src == 'list'">%fa:list% {{ list.title }}</template>
+		</span>
+		<x-tl :src="src"/>
+	</x-column>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XColumn from './deck.column.vue';
+import XTl from './deck.tl.vue';
+
+export default Vue.extend({
+	components: {
+		XColumn,
+		XTl
+	},
+
+	props: {
+		src: {
+			type: String,
+			required: false
+		}
+	},
+});
+</script>
diff --git a/src/client/app/desktop/views/pages/deck/deck.tl.vue b/src/client/app/desktop/views/pages/deck/deck.tl.vue
index ce9a77703f..0a788b32ed 100644
--- a/src/client/app/desktop/views/pages/deck/deck.tl.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.tl.vue
@@ -14,10 +14,6 @@ export default Vue.extend({
 	},
 
 	props: {
-		root: {
-			type: Object,
-			required: false
-		},
 		src: {
 			type: String,
 			required: false,
diff --git a/src/client/app/desktop/views/pages/deck/deck.vue b/src/client/app/desktop/views/pages/deck/deck.vue
index 0c32b7d665..dfd480029c 100644
--- a/src/client/app/desktop/views/pages/deck/deck.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.vue
@@ -1,21 +1,20 @@
 <template>
 <mk-ui :class="$style.root">
 	<div class="qlvquzbjribqcaozciifydkngcwtyzje">
-		<x-column src="home"/>
-		<x-column src="home"/>
-		<x-column src="home"/>
-		<x-column src="home"/>
+		<x-tl-column src="home"/>
+		<x-tl-column src="local"/>
+		<x-tl-column src="global"/>
 	</div>
 </mk-ui>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
-import XColumn from './deck.column.vue';
+import XTlColumn from './deck.tl-column.vue';
 
 export default Vue.extend({
 	components: {
-		XColumn
+		XTlColumn
 	}
 });
 </script>
diff --git a/src/services/note/create.ts b/src/services/note/create.ts
index 37d21fecad..f820182a42 100644
--- a/src/services/note/create.ts
+++ b/src/services/note/create.ts
@@ -221,7 +221,9 @@ export default async (user: IUser, data: {
 		}
 
 		// Publish note to global timeline stream
-		publishGlobalTimelineStream(noteObj);
+		if (note.visibility == 'public' && note.replyId == null) {
+			publishGlobalTimelineStream(noteObj);
+		}
 
 		if (note.visibility == 'specified') {
 			data.visibleUsers.forEach(async u => {

From 2e919b788f449b34872bd3e530b2f4f637365c00 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Tue, 5 Jun 2018 23:19:04 +0900
Subject: [PATCH 4/4] wip

---
 .../pages/deck/deck.notifications-column.vue  |  22 ++
 .../views/pages/deck/deck.notifications.vue   | 335 ++++++++++++++++++
 .../app/desktop/views/pages/deck/deck.vue     |   5 +-
 3 files changed, 361 insertions(+), 1 deletion(-)
 create mode 100644 src/client/app/desktop/views/pages/deck/deck.notifications-column.vue
 create mode 100644 src/client/app/desktop/views/pages/deck/deck.notifications.vue

diff --git a/src/client/app/desktop/views/pages/deck/deck.notifications-column.vue b/src/client/app/desktop/views/pages/deck/deck.notifications-column.vue
new file mode 100644
index 0000000000..0566989642
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.notifications-column.vue
@@ -0,0 +1,22 @@
+<template>
+<div>
+	<x-column>
+		<span slot="header">%fa:bell R% %i18n:@notifications%</span>
+
+		<x-notifications/>
+	</x-column>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XColumn from './deck.column.vue';
+import XNotifications from './deck.notifications.vue';
+
+export default Vue.extend({
+	components: {
+		XColumn,
+		XNotifications
+	}
+});
+</script>
diff --git a/src/client/app/desktop/views/pages/deck/deck.notifications.vue b/src/client/app/desktop/views/pages/deck/deck.notifications.vue
new file mode 100644
index 0000000000..7a9646b587
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.notifications.vue
@@ -0,0 +1,335 @@
+<template>
+<div class="oxynyeqmfvracxnglgulyqfgqxnxmehl">
+	<div class="notifications" v-if="notifications.length != 0">
+		<transition-group name="mk-notifications" class="transition">
+			<template v-for="(notification, i) in _notifications">
+				<div class="notification" :class="notification.type" :key="notification.id">
+					<mk-time :time="notification.createdAt"/>
+
+					<template v-if="notification.type == 'reaction'">
+						<mk-avatar class="avatar" :user="notification.user"/>
+						<div class="text">
+							<p>
+								<mk-reaction-icon :reaction="notification.reaction"/>
+								<router-link :to="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</router-link>
+							</p>
+							<router-link class="note-ref" :to="notification.note | notePage">
+								%fa:quote-left%{{ getNoteSummary(notification.note) }}%fa:quote-right%
+							</router-link>
+						</div>
+					</template>
+
+					<template v-if="notification.type == 'renote'">
+						<mk-avatar class="avatar" :user="notification.note.user"/>
+						<div class="text">
+							<p>%fa:retweet%
+								<router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link>
+							</p>
+							<router-link class="note-ref" :to="notification.note | notePage">
+								%fa:quote-left%{{ getNoteSummary(notification.note.renote) }}%fa:quote-right%
+							</router-link>
+						</div>
+					</template>
+
+					<template v-if="notification.type == 'quote'">
+						<mk-avatar class="avatar" :user="notification.note.user"/>
+						<div class="text">
+							<p>%fa:quote-left%
+								<router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link>
+							</p>
+							<router-link class="note-preview" :to="notification.note | notePage">{{ getNoteSummary(notification.note) }}</router-link>
+						</div>
+					</template>
+
+					<template v-if="notification.type == 'follow'">
+						<mk-avatar class="avatar" :user="notification.user"/>
+						<div class="text">
+							<p>%fa:user-plus%
+								<router-link :to="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</router-link>
+							</p>
+						</div>
+					</template>
+
+					<template v-if="notification.type == 'receiveFollowRequest'">
+						<mk-avatar class="avatar" :user="notification.user"/>
+						<div class="text">
+							<p>%fa:user-clock%
+								<router-link :to="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</router-link>
+							</p>
+						</div>
+					</template>
+
+					<template v-if="notification.type == 'reply'">
+						<mk-avatar class="avatar" :user="notification.note.user"/>
+						<div class="text">
+							<p>%fa:reply%
+								<router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link>
+							</p>
+							<router-link class="note-preview" :to="notification.note | notePage">{{ getNoteSummary(notification.note) }}</router-link>
+						</div>
+					</template>
+
+					<template v-if="notification.type == 'mention'">
+						<mk-avatar class="avatar" :user="notification.note.user"/>
+						<div class="text">
+							<p>%fa:at%
+								<router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link>
+							</p>
+							<a class="note-preview" :href="notification.note | notePage">{{ getNoteSummary(notification.note) }}</a>
+						</div>
+					</template>
+
+					<template v-if="notification.type == 'poll_vote'">
+						<mk-avatar class="avatar" :user="notification.user"/>
+						<div class="text">
+							<p>%fa:chart-pie%<a :href="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</a></p>
+							<router-link class="note-ref" :to="notification.note | notePage">
+								%fa:quote-left%{{ getNoteSummary(notification.note) }}%fa:quote-right%
+							</router-link>
+						</div>
+					</template>
+				</div>
+
+				<p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'">
+					<span>%fa:angle-up%{{ notification._datetext }}</span>
+					<span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span>
+				</p>
+			</template>
+		</transition-group>
+	</div>
+	<button class="more" :class="{ fetching: fetchingMoreNotifications }" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications">
+		<template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>{{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:@more%' }}
+	</button>
+	<p class="empty" v-if="notifications.length == 0 && !fetching">%i18n:@empty%</p>
+	<p class="loading" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import getNoteSummary from '../../../../../../renderers/get-note-summary';
+
+export default Vue.extend({
+	data() {
+		return {
+			fetching: true,
+			fetchingMoreNotifications: false,
+			notifications: [],
+			moreNotifications: false,
+			connection: null,
+			connectionId: null,
+			getNoteSummary
+		};
+	},
+	computed: {
+		_notifications(): any[] {
+			return (this.notifications as any).map(notification => {
+				const date = new Date(notification.createdAt).getDate();
+				const month = new Date(notification.createdAt).getMonth() + 1;
+				notification._date = date;
+				notification._datetext = `${month}月 ${date}日`;
+				return notification;
+			});
+		}
+	},
+	mounted() {
+		this.connection = (this as any).os.stream.getConnection();
+		this.connectionId = (this as any).os.stream.use();
+
+		this.connection.on('notification', this.onNotification);
+
+		const max = 10;
+
+		(this as any).api('i/notifications', {
+			limit: max + 1
+		}).then(notifications => {
+			if (notifications.length == max + 1) {
+				this.moreNotifications = true;
+				notifications.pop();
+			}
+
+			this.notifications = notifications;
+			this.fetching = false;
+		});
+	},
+	beforeDestroy() {
+		this.connection.off('notification', this.onNotification);
+		(this as any).os.stream.dispose(this.connectionId);
+	},
+	methods: {
+		fetchMoreNotifications() {
+			this.fetchingMoreNotifications = true;
+
+			const max = 30;
+
+			(this as any).api('i/notifications', {
+				limit: max + 1,
+				untilId: this.notifications[this.notifications.length - 1].id
+			}).then(notifications => {
+				if (notifications.length == max + 1) {
+					this.moreNotifications = true;
+					notifications.pop();
+				} else {
+					this.moreNotifications = false;
+				}
+				this.notifications = this.notifications.concat(notifications);
+				this.fetchingMoreNotifications = false;
+			});
+		},
+		onNotification(notification) {
+			// TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
+			this.connection.send({
+				type: 'read_notification',
+				id: notification.id
+			});
+
+			this.notifications.unshift(notification);
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+root(isDark)
+	.transition
+		.mk-notifications-enter
+		.mk-notifications-leave-to
+			opacity 0
+			transform translateY(-30px)
+
+		> *
+			transition transform .3s ease, opacity .3s ease
+
+	> .notifications
+		> *
+			> .notification
+				margin 0
+				padding 16px
+				overflow-wrap break-word
+				font-size 0.9em
+				border-bottom solid 1px isDark ? #1c2023 : rgba(#000, 0.05)
+
+				&:last-child
+					border-bottom none
+
+				> .mk-time
+					display inline
+					position absolute
+					top 16px
+					right 12px
+					vertical-align top
+					color isDark ? #606984 : rgba(#000, 0.6)
+					font-size small
+
+				&:after
+					content ""
+					display block
+					clear both
+
+				> .avatar
+					display block
+					float left
+					position -webkit-sticky
+					position sticky
+					top 16px
+					width 36px
+					height 36px
+					border-radius 6px
+
+				> .text
+					float right
+					width calc(100% - 36px)
+					padding-left 8px
+
+					p
+						margin 0
+
+						i, .mk-reaction-icon
+							margin-right 4px
+
+				.note-preview
+					color isDark ? #c2cad4 : rgba(#000, 0.7)
+
+				.note-ref
+					color isDark ? #c2cad4 : rgba(#000, 0.7)
+
+					[data-fa]
+						font-size 1em
+						font-weight normal
+						font-style normal
+						display inline-block
+						margin-right 3px
+
+				&.renote, &.quote
+					.text p i
+						color #77B255
+
+				&.follow
+					.text p i
+						color #53c7ce
+
+				&.receiveFollowRequest
+					.text p i
+						color #888
+
+				&.reply, &.mention
+					.text p i
+						color #555
+
+			> .date
+				display block
+				margin 0
+				line-height 32px
+				text-align center
+				font-size 0.8em
+				color isDark ? #666b79 : #aaa
+				background isDark ? #242731 : #fdfdfd
+				border-bottom solid 1px isDark ? #1c2023 : rgba(#000, 0.05)
+
+				span
+					margin 0 16px
+
+				[data-fa]
+					margin-right 8px
+
+	> .more
+		display block
+		width 100%
+		padding 16px
+		color #555
+		border-top solid 1px rgba(#000, 0.05)
+
+		&:hover
+			background rgba(#000, 0.025)
+
+		&:active
+			background rgba(#000, 0.05)
+
+		&.fetching
+			cursor wait
+
+		> [data-fa]
+			margin-right 4px
+
+	> .empty
+		margin 0
+		padding 16px
+		text-align center
+		color #aaa
+
+	> .loading
+		margin 0
+		padding 16px
+		text-align center
+		color #aaa
+
+		> [data-fa]
+			margin-right 4px
+
+.oxynyeqmfvracxnglgulyqfgqxnxmehl[data-darkmode]
+	root(true)
+
+.oxynyeqmfvracxnglgulyqfgqxnxmehl:not([data-darkmode])
+	root(false)
+
+</style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.vue b/src/client/app/desktop/views/pages/deck/deck.vue
index dfd480029c..fb5e55086c 100644
--- a/src/client/app/desktop/views/pages/deck/deck.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.vue
@@ -2,6 +2,7 @@
 <mk-ui :class="$style.root">
 	<div class="qlvquzbjribqcaozciifydkngcwtyzje">
 		<x-tl-column src="home"/>
+		<x-notifications-column/>
 		<x-tl-column src="local"/>
 		<x-tl-column src="global"/>
 	</div>
@@ -11,10 +12,12 @@
 <script lang="ts">
 import Vue from 'vue';
 import XTlColumn from './deck.tl-column.vue';
+import XNotificationsColumn from './deck.notifications-column.vue';
 
 export default Vue.extend({
 	components: {
-		XTlColumn
+		XTlColumn,
+		XNotificationsColumn
 	}
 });
 </script>