From ba1492f9773abab84277d62e957668ef2950f77a Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Mon, 18 Feb 2019 09:17:55 +0900
Subject: [PATCH] Refactor client (#4307)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Fix bug

* :art:

* :art:

* :art:
---
 .../app/desktop/views/components/notes.vue    |  83 +++++----
 .../views/components/user-list-timeline.vue   |  84 +++------
 .../app/desktop/views/deck/deck.direct.vue    |  82 +++-----
 .../views/deck/deck.favorites-column.vue      |  68 ++-----
 .../views/deck/deck.featured-column.vue       |  30 +--
 .../desktop/views/deck/deck.hashtag-tl.vue    |  86 +++------
 .../app/desktop/views/deck/deck.list-tl.vue   |  90 +++------
 .../app/desktop/views/deck/deck.mentions.vue  |  80 +++-----
 .../app/desktop/views/deck/deck.notes.vue     |  76 ++++----
 .../desktop/views/deck/deck.search-column.vue |  82 +++-----
 src/client/app/desktop/views/deck/deck.tl.vue |  85 +++------
 .../views/deck/deck.user-column.home.vue      |  78 +++-----
 src/client/app/desktop/views/home/search.vue  | 120 ++++--------
 src/client/app/desktop/views/home/tag.vue     | 113 ++++-------
 .../app/desktop/views/home/timeline.core.vue  | 106 +++--------
 .../app/desktop/views/home/timeline.vue       | 166 ++++++++---------
 .../app/desktop/views/home/user/user.home.vue |   5 +-
 .../desktop/views/home/user/user.timeline.vue | 176 ++++++------------
 src/client/app/mobile/style.styl              |  12 ++
 .../app/mobile/views/components/notes.vue     |  82 ++++----
 .../views/components/user-list-timeline.vue   |  91 +++------
 .../mobile/views/components/user-timeline.vue |  74 ++------
 src/client/app/mobile/views/pages/explore.vue |  15 --
 .../app/mobile/views/pages/favorites.vue      |  10 -
 .../app/mobile/views/pages/featured.vue       |  10 -
 .../app/mobile/views/pages/home.timeline.vue  |  80 +++-----
 src/client/app/mobile/views/pages/home.vue    |  11 --
 src/client/app/mobile/views/pages/note.vue    |  12 --
 .../app/mobile/views/pages/notifications.vue  |  15 --
 .../views/pages/received-follow-requests.vue  |  11 --
 src/client/app/mobile/views/pages/search.vue  |  91 +++------
 .../app/mobile/views/pages/settings.vue       |   3 -
 src/client/app/mobile/views/pages/tag.vue     |  80 +++-----
 .../app/mobile/views/pages/user-list.vue      |  15 --
 .../app/mobile/views/pages/user-lists.vue     |  17 --
 .../app/mobile/views/pages/user/home.vue      |   1 -
 .../app/mobile/views/pages/user/index.vue     |  24 +--
 37 files changed, 738 insertions(+), 1526 deletions(-)

diff --git a/src/client/app/desktop/views/components/notes.vue b/src/client/app/desktop/views/components/notes.vue
index 5cf51d9cc4..d1bf6dcc04 100644
--- a/src/client/app/desktop/views/components/notes.vue
+++ b/src/client/app/desktop/views/components/notes.vue
@@ -1,10 +1,12 @@
 <template>
 <div class="mk-notes">
+	<slot name="header"></slot>
+
 	<div class="newer-indicator" :style="{ top: $store.state.uiHeaderHeight + 'px' }" v-show="queue.length > 0"></div>
 
-	<slot name="empty" v-if="notes.length == 0 && !fetching && requestInitPromise == null"></slot>
+	<slot name="empty" v-if="notes.length == 0 && !fetching && inited"></slot>
 
-	<mk-error v-if="!fetching && requestInitPromise != null" @retry="resolveInitPromise"/>
+	<mk-error v-if="!fetching && !inited" @retry="init()"/>
 
 	<div class="placeholder" v-if="fetching">
 		<template v-for="i in 10">
@@ -23,8 +25,8 @@
 		</template>
 	</component>
 
-	<footer v-if="more">
-		<button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+	<footer v-if="cursor != null">
+		<button @click="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
 			<template v-if="!moreFetching">{{ $t('@.load-more') }}</template>
 			<template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template>
 		</button>
@@ -43,24 +45,25 @@ const displayLimit = 30;
 
 export default Vue.extend({
 	i18n: i18n(),
+
 	components: {
 		XNote
 	},
 
 	props: {
-		more: {
-			type: Function,
-			required: false
+		makePromise: {
+			required: true
 		}
 	},
 
 	data() {
 		return {
-			requestInitPromise: null as () => Promise<any[]>,
 			notes: [],
 			queue: [],
 			fetching: true,
-			moreFetching: false
+			moreFetching: false,
+			inited: false,
+			cursor: null
 		};
 	},
 
@@ -76,6 +79,10 @@ export default Vue.extend({
 		}
 	},
 
+	created() {
+		this.init();
+	},
+
 	mounted() {
 		window.addEventListener('scroll', this.onScroll, { passive: true });
 	},
@@ -97,27 +104,41 @@ export default Vue.extend({
 			Vue.set((this as any).notes, i, note);
 		},
 
-		init(promiseGenerator: () => Promise<any[]>) {
-			this.requestInitPromise = promiseGenerator;
-			this.resolveInitPromise();
-		},
-
-		resolveInitPromise() {
+		reload() {
 			this.queue = [];
 			this.notes = [];
+			this.init();
+		},
+
+		init() {
 			this.fetching = true;
-
-			const promise = this.requestInitPromise();
-
-			promise.then(notes => {
-				this.notes = notes;
-				this.requestInitPromise = null;
+			this.makePromise().then(x => {
+				if (Array.isArray(x)) {
+					this.notes = x;
+				} else {
+					this.notes = x.notes;
+					this.cursor = x.cursor;
+				}
+				this.inited = true;
 				this.fetching = false;
+				this.$emit('inited');
 			}, e => {
 				this.fetching = false;
 			});
 		},
 
+		more() {
+			if (this.cursor == null || this.moreFetching) return;
+			this.moreFetching = true;
+			this.makePromise(this.cursor).then(x => {
+				this.notes = this.notes.concat(x.notes);
+				this.cursor = x.cursor;
+				this.moreFetching = false;
+			}, e => {
+				this.moreFetching = false;
+			});
+		},
+
 		prepend(note, silent = false) {
 			// 弾く
 			if (shouldMuteNote(this.$store.state.i, this.$store.state.settings, note)) return;
@@ -151,10 +172,6 @@ export default Vue.extend({
 			this.notes.push(note);
 		},
 
-		tail() {
-			return this.notes[this.notes.length - 1];
-		},
-
 		releaseQueue() {
 			for (const n of this.queue) {
 				this.prepend(n, true);
@@ -162,15 +179,6 @@ export default Vue.extend({
 			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();
@@ -178,7 +186,7 @@ export default Vue.extend({
 
 			if (this.$store.state.settings.fetchOnScroll !== false) {
 				const current = window.scrollY + window.innerHeight;
-				if (current > document.body.offsetHeight - 8) this.loadMore();
+				if (current > document.body.offsetHeight - 8) this.more();
 			}
 		}
 	}
@@ -187,6 +195,11 @@ export default Vue.extend({
 
 <style lang="stylus" scoped>
 .mk-notes
+	background var(--face)
+	box-shadow var(--shadow)
+	border-radius var(--round)
+	overflow hidden
+
 	.transition
 		.mk-notes-enter
 		.mk-notes-leave-to
diff --git a/src/client/app/desktop/views/components/user-list-timeline.vue b/src/client/app/desktop/views/components/user-list-timeline.vue
index 8afd95a68e..d61de06eed 100644
--- a/src/client/app/desktop/views/components/user-list-timeline.vue
+++ b/src/client/app/desktop/views/components/user-list-timeline.vue
@@ -1,6 +1,10 @@
 <template>
 <div>
-	<mk-notes ref="timeline" :more="existMore ? more : null"/>
+	<mk-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')">
+		<template slot="header">
+			<slot></slot>
+		</template>
+	</mk-notes>
 </div>
 </template>
 
@@ -13,10 +17,28 @@ export default Vue.extend({
 	props: ['list'],
 	data() {
 		return {
-			fetching: true,
-			moreFetching: false,
-			existMore: false,
-			connection: null
+			connection: null,
+			makePromise: cursor => this.$root.api('notes/user-list-timeline', {
+				listId: this.list.id,
+				limit: fetchLimit + 1,
+				untilId: cursor ? cursor : undefined,
+				includeMyRenotes: this.$store.state.settings.showMyRenotes,
+				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
+			}).then(notes => {
+				if (notes.length == fetchLimit + 1) {
+					notes.pop();
+					return {
+						notes: notes,
+						cursor: notes[notes.length - 1].id
+					};
+				} else {
+					return {
+						notes: notes,
+						cursor: null
+					};
+				}
+			})
 		};
 	},
 	watch: {
@@ -37,63 +59,15 @@ export default Vue.extend({
 			this.connection.on('note', this.onNote);
 			this.connection.on('userAdded', this.onUserAdded);
 			this.connection.on('userRemoved', this.onUserRemoved);
-
-			this.fetch();
-		},
-		fetch() {
-			this.fetching = true;
-
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api('notes/user-list-timeline', {
-					listId: this.list.id,
-					limit: fetchLimit + 1,
-					includeMyRenotes: this.$store.state.settings.showMyRenotes,
-					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
-				}).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.$root.api('notes/user-list-timeline', {
-				listId: this.list.id,
-				limit: fetchLimit + 1,
-				untilId: (this.$refs.timeline as any).tail().id,
-				includeMyRenotes: this.$store.state.settings.showMyRenotes,
-				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
-			});
-
-			promise.then(notes => {
-				if (notes.length == fetchLimit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) (this.$refs.timeline as any).append(n);
-				this.moreFetching = false;
-			});
-
-			return promise;
 		},
 		onNote(note) {
-			// Prepend a note
 			(this.$refs.timeline as any).prepend(note);
 		},
 		onUserAdded() {
-			this.fetch();
+			(this.$refs.timeline as any).reload();
 		},
 		onUserRemoved() {
-			this.fetch();
+			(this.$refs.timeline as any).reload();
 		}
 	}
 });
diff --git a/src/client/app/desktop/views/deck/deck.direct.vue b/src/client/app/desktop/views/deck/deck.direct.vue
index c6c2b99233..2618363b14 100644
--- a/src/client/app/desktop/views/deck/deck.direct.vue
+++ b/src/client/app/desktop/views/deck/deck.direct.vue
@@ -1,5 +1,5 @@
 <template>
-<x-notes ref="timeline" :more="existMore ? more : null"/>
+<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
 </template>
 
 <script lang="ts">
@@ -13,23 +13,36 @@ export default Vue.extend({
 		XNotes
 	},
 
-	props: {
-	},
-
 	data() {
 		return {
-			fetching: true,
-			moreFetching: false,
-			existMore: false,
-			connection: null
+			connection: null,
+			makePromise: cursor => this.$root.api('notes/mentions', {
+				limit: fetchLimit + 1,
+				untilId: cursor ? cursor : undefined,
+				includeMyRenotes: this.$store.state.settings.showMyRenotes,
+				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+				includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
+				visibility: 'specified'
+			}).then(notes => {
+				if (notes.length == fetchLimit + 1) {
+					notes.pop();
+					return {
+						notes: notes,
+						cursor: notes[notes.length - 1].id
+					};
+				} else {
+					return {
+						notes: notes,
+						cursor: null
+					};
+				}
+			})
 		};
 	},
 
 	mounted() {
 		this.connection = this.$root.stream.useSharedConnection('main');
 		this.connection.on('mention', this.onNote);
-
-		this.fetch();
 	},
 
 	beforeDestroy() {
@@ -37,55 +50,6 @@ export default Vue.extend({
 	},
 
 	methods: {
-		fetch() {
-			this.fetching = true;
-
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api('notes/mentions', {
-					limit: fetchLimit + 1,
-					includeMyRenotes: this.$store.state.settings.showMyRenotes,
-					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-					includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
-					visibility: 'specified'
-				}).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.$root.api('notes/mentions', {
-				limit: fetchLimit + 1,
-				untilId: (this.$refs.timeline as any).tail().id,
-				includeMyRenotes: this.$store.state.settings.showMyRenotes,
-				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-				includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
-				visibility: 'specified'
-			});
-
-			promise.then(notes => {
-				if (notes.length == fetchLimit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) {
-					(this.$refs.timeline as any).append(n);
-				}
-				this.moreFetching = false;
-			});
-
-			return promise;
-		},
-
 		onNote(note) {
 			// Prepend a note
 			if (note.visibility == 'specified') {
diff --git a/src/client/app/desktop/views/deck/deck.favorites-column.vue b/src/client/app/desktop/views/deck/deck.favorites-column.vue
index 3c2b50dee8..e0d5f8a339 100644
--- a/src/client/app/desktop/views/deck/deck.favorites-column.vue
+++ b/src/client/app/desktop/views/deck/deck.favorites-column.vue
@@ -5,7 +5,7 @@
 	</span>
 
 	<div>
-		<x-notes ref="timeline" :more="existMore ? more : null"/>
+		<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
 	</div>
 </x-column>
 </template>
@@ -28,58 +28,28 @@ export default Vue.extend({
 
 	data() {
 		return {
-			fetching: true,
-			moreFetching: false,
-			existMore: false,
+			makePromise: cursor => this.$root.api('i/favorites', {
+				limit: fetchLimit + 1,
+				untilId: cursor ? cursor : undefined,
+			}).then(notes => {
+				notes = notes.map(x => x.note);
+				if (notes.length == fetchLimit + 1) {
+					notes.pop();
+					return {
+						notes: notes,
+						cursor: notes[notes.length - 1].id
+					};
+				} else {
+					return {
+						notes: notes,
+						cursor: null
+					};
+				}
+			})
 		};
 	},
 
-	mounted() {
-		this.fetch();
-	},
-
 	methods: {
-		fetch() {
-			this.fetching = true;
-
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api('i/favorites', {
-					limit: fetchLimit + 1,
-				}).then(notes => {
-					if (notes.length == fetchLimit + 1) {
-						notes.pop();
-						this.existMore = true;
-					}
-					res(notes.map(x => x.note));
-					this.fetching = false;
-					this.$emit('loaded');
-				}, rej);
-			}));
-		},
-
-		more() {
-			this.moreFetching = true;
-
-			const promise = this.$root.api('i/favorites', {
-				limit: fetchLimit + 1,
-				untilId: (this.$refs.timeline as any).tail().id,
-			});
-
-			promise.then(notes => {
-				if (notes.length == fetchLimit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) {
-					(this.$refs.timeline as any).append(n);
-				}
-				this.moreFetching = false;
-			});
-
-			return promise;
-		},
-
 		focus() {
 			this.$refs.timeline.focus();
 		}
diff --git a/src/client/app/desktop/views/deck/deck.featured-column.vue b/src/client/app/desktop/views/deck/deck.featured-column.vue
index 78a5a7e3f5..e654c1eaae 100644
--- a/src/client/app/desktop/views/deck/deck.featured-column.vue
+++ b/src/client/app/desktop/views/deck/deck.featured-column.vue
@@ -5,7 +5,7 @@
 	</span>
 
 	<div>
-		<x-notes ref="timeline" :more="null"/>
+		<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
 	</div>
 </x-column>
 </template>
@@ -27,31 +27,17 @@ export default Vue.extend({
 
 	data() {
 		return {
-			fetching: true,
-			faNewspaper
+			faNewspaper,
+			makePromise: cursor => this.$root.api('notes/featured', {
+				limit: 20,
+			}).then(notes => {
+				notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+				return notes;
+			})
 		};
 	},
 
-	mounted() {
-		this.fetch();
-	},
-
 	methods: {
-		fetch() {
-			this.fetching = true;
-
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api('notes/featured', {
-					limit: 20,
-				}).then(notes => {
-					notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
-					res(notes);
-					this.fetching = false;
-					this.$emit('loaded');
-				}, rej);
-			}));
-		},
-
 		focus() {
 			this.$refs.timeline.focus();
 		}
diff --git a/src/client/app/desktop/views/deck/deck.hashtag-tl.vue b/src/client/app/desktop/views/deck/deck.hashtag-tl.vue
index 9a70733fda..e19c134849 100644
--- a/src/client/app/desktop/views/deck/deck.hashtag-tl.vue
+++ b/src/client/app/desktop/views/deck/deck.hashtag-tl.vue
@@ -1,5 +1,5 @@
 <template>
-<x-notes ref="timeline" :more="existMore ? more : null" :media-view="mediaView"/>
+<x-notes ref="timeline" :make-promise="makePromise" :media-view="mediaView" @inited="() => $emit('loaded')"/>
 </template>
 
 <script lang="ts">
@@ -32,16 +32,35 @@ export default Vue.extend({
 
 	data() {
 		return {
-			fetching: true,
-			moreFetching: false,
-			existMore: false,
-			connection: null
+			connection: null,
+			makePromise: cursor => this.$root.api('notes/search_by_tag', {
+				limit: fetchLimit + 1,
+				untilId: cursor ? cursor : undefined,
+				withFiles: this.mediaOnly,
+				includeMyRenotes: this.$store.state.settings.showMyRenotes,
+				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+				includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
+				query: this.tagTl.query
+			}).then(notes => {
+				if (notes.length == fetchLimit + 1) {
+					notes.pop();
+					return {
+						notes: notes,
+						cursor: notes[notes.length - 1].id
+					};
+				} else {
+					return {
+						notes: notes,
+						cursor: null
+					};
+				}
+			})
 		};
 	},
 
 	watch: {
 		mediaOnly() {
-			this.fetch();
+			this.$refs.timeline.reload();
 		}
 	},
 
@@ -51,8 +70,6 @@ export default Vue.extend({
 			q: this.tagTl.query
 		});
 		this.connection.on('note', this.onNote);
-
-		this.fetch();
 	},
 
 	beforeDestroy() {
@@ -60,61 +77,8 @@ export default Vue.extend({
 	},
 
 	methods: {
-		fetch() {
-			this.fetching = true;
-
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api('notes/search_by_tag', {
-					limit: fetchLimit + 1,
-					withFiles: this.mediaOnly,
-					includeMyRenotes: this.$store.state.settings.showMyRenotes,
-					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-					includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
-					query: this.tagTl.query
-				}).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.$root.api('notes/search_by_tag', {
-				limit: fetchLimit + 1,
-				untilId: (this.$refs.timeline as any).tail().id,
-				withFiles: this.mediaOnly,
-				includeMyRenotes: this.$store.state.settings.showMyRenotes,
-				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-				includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
-				query: this.tagTl.query
-			});
-
-			promise.then(notes => {
-				if (notes.length == fetchLimit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) {
-					(this.$refs.timeline as any).append(n);
-				}
-				this.moreFetching = false;
-			});
-
-			return promise;
-		},
-
 		onNote(note) {
 			if (this.mediaOnly && note.files.length == 0) return;
-
-			// Prepend a note
 			(this.$refs.timeline as any).prepend(note);
 		},
 
diff --git a/src/client/app/desktop/views/deck/deck.list-tl.vue b/src/client/app/desktop/views/deck/deck.list-tl.vue
index 68fbbb3ff9..7166263295 100644
--- a/src/client/app/desktop/views/deck/deck.list-tl.vue
+++ b/src/client/app/desktop/views/deck/deck.list-tl.vue
@@ -1,5 +1,5 @@
 <template>
-<x-notes ref="timeline" :more="existMore ? more : null" :media-view="mediaView"/>
+<x-notes ref="timeline" :make-promise="makePromise" :media-view="mediaView" @inited="() => $emit('loaded')"/>
 </template>
 
 <script lang="ts">
@@ -32,16 +32,35 @@ export default Vue.extend({
 
 	data() {
 		return {
-			fetching: true,
-			moreFetching: false,
-			existMore: false,
-			connection: null
+			connection: null,
+			makePromise: cursor => this.$root.api('notes/user-list-timeline', {
+				listId: this.list.id,
+				limit: fetchLimit + 1,
+				untilId: cursor ? cursor : undefined,
+				withFiles: this.mediaOnly,
+				includeMyRenotes: this.$store.state.settings.showMyRenotes,
+				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
+			}).then(notes => {
+				if (notes.length == fetchLimit + 1) {
+					notes.pop();
+					return {
+						notes: notes,
+						cursor: notes[notes.length - 1].id
+					};
+				} else {
+					return {
+						notes: notes,
+						cursor: null
+					};
+				}
+			})
 		};
 	},
 
 	watch: {
 		mediaOnly() {
-			this.fetch();
+			this.$refs.timeline.reload();
 		}
 	},
 
@@ -53,8 +72,6 @@ export default Vue.extend({
 		this.connection.on('note', this.onNote);
 		this.connection.on('userAdded', this.onUserAdded);
 		this.connection.on('userRemoved', this.onUserRemoved);
-
-		this.fetch();
 	},
 
 	beforeDestroy() {
@@ -62,70 +79,17 @@ export default Vue.extend({
 	},
 
 	methods: {
-		fetch() {
-			this.fetching = true;
-
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api('notes/user-list-timeline', {
-					listId: this.list.id,
-					limit: fetchLimit + 1,
-					withFiles: this.mediaOnly,
-					includeMyRenotes: this.$store.state.settings.showMyRenotes,
-					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
-				}).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.$root.api('notes/user-list-timeline', {
-				listId: this.list.id,
-				limit: fetchLimit + 1,
-				untilId: (this.$refs.timeline as any).tail().id,
-				withFiles: this.mediaOnly,
-				includeMyRenotes: this.$store.state.settings.showMyRenotes,
-				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
-			});
-
-			promise.then(notes => {
-				if (notes.length == fetchLimit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) {
-					(this.$refs.timeline as any).append(n);
-				}
-				this.moreFetching = false;
-			});
-
-			return promise;
-		},
-
 		onNote(note) {
 			if (this.mediaOnly && note.files.length == 0) return;
-
-			// Prepend a note
 			(this.$refs.timeline as any).prepend(note);
 		},
 
 		onUserAdded() {
-			this.fetch();
+			this.$refs.timeline.reload();
 		},
 
 		onUserRemoved() {
-			this.fetch();
+			this.$refs.timeline.reload();
 		},
 
 		focus() {
diff --git a/src/client/app/desktop/views/deck/deck.mentions.vue b/src/client/app/desktop/views/deck/deck.mentions.vue
index 5fcabde5d6..1efd778226 100644
--- a/src/client/app/desktop/views/deck/deck.mentions.vue
+++ b/src/client/app/desktop/views/deck/deck.mentions.vue
@@ -1,5 +1,5 @@
 <template>
-<x-notes ref="timeline" :more="existMore ? more : null"/>
+<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
 </template>
 
 <script lang="ts">
@@ -13,23 +13,35 @@ export default Vue.extend({
 		XNotes
 	},
 
-	props: {
-	},
-
 	data() {
 		return {
-			fetching: true,
-			moreFetching: false,
-			existMore: false,
-			connection: null
+			connection: null,
+			makePromise: cursor => this.$root.api('notes/mentions', {
+				limit: fetchLimit + 1,
+				untilId: cursor ? cursor : undefined,
+				includeMyRenotes: this.$store.state.settings.showMyRenotes,
+				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
+			}).then(notes => {
+				if (notes.length == fetchLimit + 1) {
+					notes.pop();
+					return {
+						notes: notes,
+						cursor: notes[notes.length - 1].id
+					};
+				} else {
+					return {
+						notes: notes,
+						cursor: null
+					};
+				}
+			})
 		};
 	},
 
 	mounted() {
 		this.connection = this.$root.stream.useSharedConnection('main');
 		this.connection.on('mention', this.onNote);
-
-		this.fetch();
 	},
 
 	beforeDestroy() {
@@ -37,55 +49,7 @@ export default Vue.extend({
 	},
 
 	methods: {
-		fetch() {
-			this.fetching = true;
-
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api('notes/mentions', {
-					limit: fetchLimit + 1,
-					includeMyRenotes: this.$store.state.settings.showMyRenotes,
-					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
-				}).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.$root.api('notes/mentions', {
-				limit: fetchLimit + 1,
-				untilId: (this.$refs.timeline as any).tail().id,
-				includeMyRenotes: this.$store.state.settings.showMyRenotes,
-				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
-			});
-
-			promise.then(notes => {
-				if (notes.length == fetchLimit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) {
-					(this.$refs.timeline as any).append(n);
-				}
-				this.moreFetching = false;
-			});
-
-			return promise;
-		},
-
 		onNote(note) {
-			// Prepend a note
 			(this.$refs.timeline as any).prepend(note);
 		},
 
diff --git a/src/client/app/desktop/views/deck/deck.notes.vue b/src/client/app/desktop/views/deck/deck.notes.vue
index 260d75a884..e7fa9fd52a 100644
--- a/src/client/app/desktop/views/deck/deck.notes.vue
+++ b/src/client/app/desktop/views/deck/deck.notes.vue
@@ -1,6 +1,8 @@
 <template>
 <div class="eamppglmnmimdhrlzhplwpvyeaqmmhxu">
-	<slot name="empty" v-if="notes.length == 0 && !fetching && requestInitPromise == null"></slot>
+	<slot name="empty" v-if="notes.length == 0 && !fetching && inited"></slot>
+
+	<mk-error v-if="!fetching && !inited" @retry="init()"/>
 
 	<div class="placeholder" v-if="fetching">
 		<template v-for="i in 10">
@@ -8,8 +10,6 @@
 		</template>
 	</div>
 
-	<mk-error v-if="!fetching && requestInitPromise != null" @retry="resolveInitPromise"/>
-
 	<!-- トランジションを有効にするとなぜかメモリリークする -->
 	<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="transition notes" ref="notes" tag="div">
 		<template v-for="(note, i) in _notes">
@@ -27,8 +27,8 @@
 		</template>
 	</component>
 
-	<footer v-if="more">
-		<button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+	<footer v-if="cursor != null">
+		<button @click="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
 			<template v-if="!moreFetching">{{ $t('@.load-more') }}</template>
 			<template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template>
 		</button>
@@ -40,13 +40,13 @@
 import Vue from 'vue';
 import i18n from '../../../i18n';
 import shouldMuteNote from '../../../common/scripts/should-mute-note';
-
 import XNote from '../components/note.vue';
 
 const displayLimit = 20;
 
 export default Vue.extend({
 	i18n: i18n(),
+
 	components: {
 		XNote
 	},
@@ -54,9 +54,8 @@ export default Vue.extend({
 	inject: ['column', 'isScrollTop', 'count'],
 
 	props: {
-		more: {
-			type: Function,
-			required: false
+		makePromise: {
+			required: true
 		},
 		mediaView: {
 			type: Boolean,
@@ -68,11 +67,12 @@ export default Vue.extend({
 	data() {
 		return {
 			rootEl: null,
-			requestInitPromise: null as () => Promise<any[]>,
 			notes: [],
 			queue: [],
 			fetching: true,
-			moreFetching: false
+			moreFetching: false,
+			inited: false,
+			cursor: null
 		};
 	},
 
@@ -97,6 +97,7 @@ export default Vue.extend({
 	created() {
 		this.column.$on('top', this.onTop);
 		this.column.$on('bottom', this.onBottom);
+		this.init();
 	},
 
 	beforeDestroy() {
@@ -113,27 +114,41 @@ export default Vue.extend({
 			Vue.set((this as any).notes, i, note);
 		},
 
-		init(promiseGenerator: () => Promise<any[]>) {
-			this.requestInitPromise = promiseGenerator;
-			this.resolveInitPromise();
-		},
-
-		resolveInitPromise() {
+		reload() {
 			this.queue = [];
 			this.notes = [];
+			this.init();
+		},
+
+		init() {
 			this.fetching = true;
-
-			const promise = this.requestInitPromise();
-
-			promise.then(notes => {
-				this.notes = notes;
-				this.requestInitPromise = null;
+			this.makePromise().then(x => {
+				if (Array.isArray(x)) {
+					this.notes = x;
+				} else {
+					this.notes = x.notes;
+					this.cursor = x.cursor;
+				}
+				this.inited = true;
 				this.fetching = false;
+				this.$emit('inited');
 			}, e => {
 				this.fetching = false;
 			});
 		},
 
+		more() {
+			if (this.cursor == null || this.moreFetching) return;
+			this.moreFetching = true;
+			this.makePromise(this.cursor).then(x => {
+				this.notes = this.notes.concat(x.notes);
+				this.cursor = x.cursor;
+				this.moreFetching = false;
+			}, e => {
+				this.moreFetching = false;
+			});
+		},
+
 		prepend(note, silent = false) {
 			// 弾く
 			if (shouldMuteNote(this.$store.state.i, this.$store.state.settings, note)) return;
@@ -160,10 +175,6 @@ export default Vue.extend({
 			this.notes.push(note);
 		},
 
-		tail() {
-			return this.notes[this.notes.length - 1];
-		},
-
 		releaseQueue() {
 			for (const n of this.queue) {
 				this.prepend(n, true);
@@ -171,21 +182,12 @@ export default Vue.extend({
 			this.queue = [];
 		},
 
-		async loadMore() {
-			if (this.more == null) return;
-			if (this.moreFetching) return;
-
-			this.moreFetching = true;
-			await this.more();
-			this.moreFetching = false;
-		},
-
 		onTop() {
 			this.releaseQueue();
 		},
 
 		onBottom() {
-			this.loadMore();
+			this.more();
 		}
 	}
 });
diff --git a/src/client/app/desktop/views/deck/deck.search-column.vue b/src/client/app/desktop/views/deck/deck.search-column.vue
index d732f524da..cc719bdf5c 100644
--- a/src/client/app/desktop/views/deck/deck.search-column.vue
+++ b/src/client/app/desktop/views/deck/deck.search-column.vue
@@ -5,7 +5,7 @@
 	</span>
 
 	<div>
-		<x-notes ref="timeline" :more="existMore ? more : null"/>
+		<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
 	</div>
 </x-column>
 </template>
@@ -25,12 +25,24 @@ export default Vue.extend({
 
 	data() {
 		return {
-			fetching: true,
-			moreFetching: false,
-			existMore: false,
-			offset: 0,
-			empty: false,
-			notAvailable: false
+			makePromise: cursor => this.$root.api('notes/search', {
+				limit: limit + 1,
+				offset: cursor ? cursor : undefined,
+				query: this.q
+			}).then(notes => {
+				if (notes.length == limit + 1) {
+					notes.pop();
+					return {
+						notes: notes,
+						cursor: cursor ? cursor + limit : limit
+					};
+				} else {
+					return {
+						notes: notes,
+						cursor: null
+					};
+				}
+			})
 		};
 	},
 
@@ -41,59 +53,9 @@ export default Vue.extend({
 	},
 
 	watch: {
-		$route: 'fetch'
-	},
-
-	created() {
-		this.fetch();
-	},
-
-	methods: {
-		fetch() {
-			this.fetching = true;
-
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api('notes/search', {
-					limit: limit + 1,
-					offset: this.offset,
-					query: this.q
-				}).then(notes => {
-					if (notes.length == 0) this.empty = true;
-					if (notes.length == limit + 1) {
-						notes.pop();
-						this.existMore = true;
-					}
-					res(notes);
-					this.fetching = false;
-				}, (e: string) => {
-					this.fetching = false;
-					if (e === 'searching not available') this.notAvailable = true;
-				});
-			}));
-		},
-		more() {
-			this.offset += limit;
-
-			const promise = this.$root.api('notes/search', {
-				limit: limit + 1,
-				offset: this.offset,
-				query: this.q
-			});
-
-			promise.then(notes => {
-				if (notes.length == limit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) {
-					(this.$refs.timeline as any).append(n);
-				}
-				this.moreFetching = false;
-			});
-
-			return promise;
+		$route() {
+			this.$refs.timeline.reload();
 		}
-	}
+	},
 });
 </script>
diff --git a/src/client/app/desktop/views/deck/deck.tl.vue b/src/client/app/desktop/views/deck/deck.tl.vue
index 16f268f2c1..263c2a0820 100644
--- a/src/client/app/desktop/views/deck/deck.tl.vue
+++ b/src/client/app/desktop/views/deck/deck.tl.vue
@@ -6,7 +6,7 @@
 	</p>
 	<p class="desc">{{ $t('disabled-timeline.description') }}</p>
 </div>
-<x-notes v-else ref="timeline" :more="existMore ? more : null" :media-view="mediaView"/>
+<x-notes v-else ref="timeline" :make-promise="makePromise" :media-view="mediaView" @inited="() => $emit('loaded')"/>
 </template>
 
 <script lang="ts">
@@ -44,12 +44,10 @@ export default Vue.extend({
 
 	data() {
 		return {
-			fetching: true,
-			moreFetching: false,
-			existMore: false,
 			connection: null,
 			disabled: false,
-			faMinusCircle
+			faMinusCircle,
+			makePromise: null
 		};
 	},
 
@@ -79,6 +77,28 @@ export default Vue.extend({
 		}
 	},
 
+	created() {
+		this.makePromise = cursor => this.$root.api(this.endpoint, {
+			limit: fetchLimit + 1,
+			untilDate: cursor ? undefined : (this.date ? this.date.getTime() : undefined),
+			untilId: cursor ? cursor : undefined,
+			...this.baseQuery, ...this.query
+		}).then(notes => {
+			if (notes.length == fetchLimit + 1) {
+				notes.pop();
+				return {
+					notes: notes,
+					cursor: notes[notes.length - 1].id
+				};
+			} else {
+				return {
+					notes: notes,
+					cursor: null
+				};
+			}
+		});
+	},
+
 	mounted() {
 		this.connection = this.stream;
 
@@ -93,8 +113,6 @@ export default Vue.extend({
 				meta.disableLocalTimeline && ['local', 'hybrid'].includes(this.src) ||
 				meta.disableGlobalTimeline && ['global'].includes(this.src));
 		});
-
-		this.fetch();
 	},
 
 	beforeDestroy() {
@@ -102,64 +120,13 @@ export default Vue.extend({
 	},
 
 	methods: {
-		fetch() {
-			this.fetching = true;
-
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api(this.endpoint, {
-					limit: fetchLimit + 1,
-					withFiles: this.mediaOnly,
-					includeMyRenotes: this.$store.state.settings.showMyRenotes,
-					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
-				}).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.$root.api(this.endpoint, {
-				limit: fetchLimit + 1,
-				withFiles: this.mediaOnly,
-				untilId: (this.$refs.timeline as any).tail().id,
-				includeMyRenotes: this.$store.state.settings.showMyRenotes,
-				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
-			});
-
-			promise.then(notes => {
-				if (notes.length == fetchLimit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) {
-					(this.$refs.timeline as any).append(n);
-				}
-				this.moreFetching = false;
-			});
-
-			return promise;
-		},
-
 		onNote(note) {
 			if (this.mediaOnly && note.files.length == 0) return;
-
-			// Prepend a note
 			(this.$refs.timeline as any).prepend(note);
 		},
 
 		onChangeFollowing() {
-			this.fetch();
+			(this.$refs.timeline as any).reload();
 		},
 
 		focus() {
diff --git a/src/client/app/desktop/views/deck/deck.user-column.home.vue b/src/client/app/desktop/views/deck/deck.user-column.home.vue
index 966c5bdb1b..ff13bc3124 100644
--- a/src/client/app/desktop/views/deck/deck.user-column.home.vue
+++ b/src/client/app/desktop/views/deck/deck.user-column.home.vue
@@ -26,7 +26,7 @@
 	<ui-container>
 		<span slot="header"><fa :icon="['far', 'comment-alt']"/> {{ $t('timeline') }}</span>
 		<div>
-			<x-notes ref="timeline" :more="existMore ? fetchMoreNotes : null"/>
+			<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
 		</div>
 	</ui-container>
 </div>
@@ -35,7 +35,6 @@
 <script lang="ts">
 import Vue from 'vue';
 import i18n from '../../../i18n';
-import parseAcct from '../../../../../misc/acct/parse';
 import XNotes from './deck.notes.vue';
 import XNote from '../components/note.vue';
 import { concat } from '../../../../../prelude/array';
@@ -45,6 +44,7 @@ const fetchLimit = 10;
 
 export default Vue.extend({
 	i18n: i18n('deck/deck.user-column.vue'),
+
 	components: {
 		XNotes,
 		XNote
@@ -59,10 +59,30 @@ export default Vue.extend({
 
 	data() {
 		return {
-			existMore: false,
-			moreFetching: false,
 			withFiles: false,
 			images: [],
+			makePromise: cursor => this.$root.api('users/notes', {
+				userId: this.user.id,
+				limit: fetchLimit + 1,
+				untilId: cursor ? cursor : undefined,
+				withFiles: this.withFiles,
+				includeMyRenotes: this.$store.state.settings.showMyRenotes,
+				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
+			}).then(notes => {
+				if (notes.length == fetchLimit + 1) {
+					notes.pop();
+					return {
+						notes: notes,
+						cursor: notes[notes.length - 1].id
+					};
+				} else {
+					return {
+						notes: notes,
+						cursor: null
+					};
+				}
+			})
 		};
 	},
 
@@ -72,10 +92,6 @@ export default Vue.extend({
 
 	methods: {
 		fetch() {
-			this.$nextTick(() => {
-				(this.$refs.timeline as any).init(() => this.initTl());
-			});
-
 			const image = [
 				'image/jpeg',
 				'image/png',
@@ -177,52 +193,6 @@ export default Vue.extend({
 				chart.render();
 			});
 		},
-
-		initTl() {
-			return new Promise((res, rej) => {
-				this.$root.api('users/notes', {
-					userId: this.user.id,
-					limit: fetchLimit + 1,
-					untilDate: new Date().getTime() + 1000 * 86400 * 365,
-					withFiles: this.withFiles,
-					includeMyRenotes: this.$store.state.settings.showMyRenotes,
-					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
-				}).then(notes => {
-					if (notes.length == fetchLimit + 1) {
-						notes.pop();
-						this.existMore = true;
-					}
-					res(notes);
-				}, rej);
-			});
-		},
-
-		fetchMoreNotes() {
-			this.moreFetching = true;
-
-			const promise = this.$root.api('users/notes', {
-				userId: this.user.id,
-				limit: fetchLimit + 1,
-				untilDate: new Date((this.$refs.timeline as any).tail().createdAt).getTime(),
-				withFiles: this.withFiles,
-				includeMyRenotes: this.$store.state.settings.showMyRenotes,
-				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
-			});
-
-			promise.then(notes => {
-				if (notes.length == fetchLimit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) (this.$refs.timeline as any).append(n);
-				this.moreFetching = false;
-			});
-
-			return promise;
-		}
 	}
 });
 </script>
diff --git a/src/client/app/desktop/views/home/search.vue b/src/client/app/desktop/views/home/search.vue
index 993467b4bf..cd21bd5b2a 100644
--- a/src/client/app/desktop/views/home/search.vue
+++ b/src/client/app/desktop/views/home/search.vue
@@ -1,13 +1,10 @@
 <template>
-<div class="oxgbmvii">
-	<div class="notes">
-		<header>
+<div>
+	<mk-notes ref="timeline" :make-promise="makePromise" @inited="inited">
+		<header slot="header" class="oxgbmvii">
 			<span><fa icon="search"/> {{ q }}</span>
 		</header>
-		<p v-if="!fetching && notAvailable">{{ $t('not-available') }}</p>
-		<p v-if="!fetching && empty"><fa icon="search"/> {{ $t('not-found', { q }) }}</p>
-		<mk-notes ref="timeline" :more="existMore ? more : null"/>
-	</div>
+	</mk-notes>
 </div>
 </template>
 
@@ -22,27 +19,40 @@ export default Vue.extend({
 	i18n: i18n('desktop/views/pages/search.vue'),
 	data() {
 		return {
-			fetching: true,
-			moreFetching: false,
-			existMore: false,
-			offset: 0,
-			empty: false,
-			notAvailable: false
+			makePromise: cursor => this.$root.api('notes/search', {
+				limit: limit + 1,
+				offset: cursor ? cursor : undefined,
+				query: this.q
+			}).then(notes => {
+				if (notes.length == limit + 1) {
+					notes.pop();
+					return {
+						notes: notes,
+						cursor: cursor ? cursor + limit : limit
+					};
+				} else {
+					return {
+						notes: notes,
+						cursor: null
+					};
+				}
+			})
 		};
 	},
-	watch: {
-		$route: 'fetch'
-	},
 	computed: {
 		q(): string {
 			return this.$route.query.q;
 		}
 	},
+	watch: {
+		$route() {
+			this.$refs.timeline.reload();
+		}
+	},
 	mounted() {
 		document.addEventListener('keydown', this.onDocumentKeydown);
 		window.addEventListener('scroll', this.onScroll, { passive: true });
-
-		this.fetch();
+		Progress.start();
 	},
 	beforeDestroy() {
 		document.removeEventListener('keydown', this.onDocumentKeydown);
@@ -56,75 +66,23 @@ export default Vue.extend({
 				}
 			}
 		},
-		fetch() {
-			this.fetching = true;
-			Progress.start();
-
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api('notes/search', {
-					limit: limit + 1,
-					offset: this.offset,
-					query: this.q
-				}).then(notes => {
-					if (notes.length == 0) this.empty = true;
-					if (notes.length == limit + 1) {
-						notes.pop();
-						this.existMore = true;
-					}
-					res(notes);
-					this.fetching = false;
-					Progress.done();
-				}, (e: string) => {
-					this.fetching = false;
-					Progress.done();
-					if (e === 'searching not available') this.notAvailable = true;
-				});
-			}));
+		inited() {
+			Progress.done();
 		},
-		more() {
-			this.offset += limit;
-
-			const promise = this.$root.api('notes/search', {
-				limit: limit + 1,
-				offset: this.offset,
-				query: this.q
-			});
-
-			promise.then(notes => {
-				if (notes.length == limit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) {
-					(this.$refs.timeline as any).append(n);
-				}
-				this.moreFetching = false;
-			});
-
-			return promise;
-		}
 	}
 });
 </script>
 
 <style lang="stylus" scoped>
 .oxgbmvii
-	> .notes
-		background var(--face)
-		box-shadow var(--shadow)
-		border-radius var(--round)
-		overflow hidden
+	padding 0 8px
+	z-index 10
+	background var(--faceHeader)
+	box-shadow 0 var(--lineWidth) var(--desktopTimelineHeaderShadow)
 
-		> header
-			padding 0 8px
-			z-index 10
-			background var(--faceHeader)
-			box-shadow 0 var(--lineWidth) var(--desktopTimelineHeaderShadow)
-
-			> span
-				padding 0 8px
-				font-size 0.9em
-				line-height 42px
-				color var(--text)
+	> span
+		padding 0 8px
+		font-size 0.9em
+		line-height 42px
+		color var(--text)
 </style>
diff --git a/src/client/app/desktop/views/home/tag.vue b/src/client/app/desktop/views/home/tag.vue
index 182c8f3512..2f9854c074 100644
--- a/src/client/app/desktop/views/home/tag.vue
+++ b/src/client/app/desktop/views/home/tag.vue
@@ -1,7 +1,10 @@
 <template>
 <div>
-	<p :class="$style.empty" v-if="!fetching && empty"><fa icon="search"/> {{ $t('no-posts-found', { q: $route.params.tag }) }}</p>
-	<mk-notes ref="timeline" :class="$style.notes" :more="existMore ? more : null"/>
+	<mk-notes ref="timeline" :make-promise="makePromise" @inited="inited">
+		<header class="wqraeznr" slot="header">
+			<span><fa icon="hashtag"/> {{ $route.params.tag }}</span>
+		</header>
+	</mk-notes>
 </div>
 </template>
 
@@ -16,21 +19,35 @@ export default Vue.extend({
 	i18n: i18n('desktop/views/pages/tag.vue'),
 	data() {
 		return {
-			fetching: true,
-			moreFetching: false,
-			existMore: false,
-			offset: 0,
-			empty: false
+			makePromise: cursor => this.$root.api('notes/search_by_tag', {
+				limit: limit + 1,
+				offset: cursor ? cursor : undefined,
+				tag: this.$route.params.tag
+			}).then(notes => {
+				if (notes.length == limit + 1) {
+					notes.pop();
+					return {
+						notes: notes,
+						cursor: cursor ? cursor + limit : limit
+					};
+				} else {
+					return {
+						notes: notes,
+						cursor: null
+					};
+				}
+			})
 		};
 	},
 	watch: {
-		$route: 'fetch'
+		$route() {
+			this.$refs.timeline.reload();
+		}
 	},
 	mounted() {
 		document.addEventListener('keydown', this.onDocumentKeydown);
 		window.addEventListener('scroll', this.onScroll, { passive: true });
-
-		this.fetch();
+		Progress.start();
 	},
 	beforeDestroy() {
 		document.removeEventListener('keydown', this.onDocumentKeydown);
@@ -44,73 +61,23 @@ export default Vue.extend({
 				}
 			}
 		},
-		fetch() {
-			this.fetching = true;
-			Progress.start();
-
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api('notes/search_by_tag', {
-					limit: limit + 1,
-					offset: this.offset,
-					tag: this.$route.params.tag
-				}).then(notes => {
-					if (notes.length == 0) this.empty = true;
-					if (notes.length == limit + 1) {
-						notes.pop();
-						this.existMore = true;
-					}
-					res(notes);
-					this.fetching = false;
-					Progress.done();
-				}, rej);
-			}));
+		inited() {
+			Progress.done();
 		},
-		more() {
-			this.offset += limit;
-
-			const promise = this.$root.api('notes/search_by_tag', {
-				limit: limit + 1,
-				offset: this.offset,
-				tag: this.$route.params.tag
-			});
-
-			promise.then(notes => {
-				if (notes.length == limit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) {
-					(this.$refs.timeline as any).append(n);
-				}
-				this.moreFetching = false;
-			});
-
-			return promise;
-		}
 	}
 });
 </script>
 
-<style lang="stylus" module>
-.notes
-	background var(--face)
-	box-shadow var(--shadow)
-	border-radius var(--round)
-	overflow hidden
-
-.empty
-	display block
-	margin 0 auto
-	padding 32px
-	max-width 400px
-	text-align center
-	color #999
-
-	> [data-icon]
-		display block
-		margin-bottom 16px
-		font-size 3em
-		color #ccc
+<style lang="stylus" scoped>
+.wqraeznr
+	padding 0 8px
+	z-index 10
+	background var(--faceHeader)
+	box-shadow 0 var(--lineWidth) var(--desktopTimelineHeaderShadow)
 
+	> span
+		padding 0 8px
+		font-size 0.9em
+		line-height 42px
+		color var(--text)
 </style>
diff --git a/src/client/app/desktop/views/home/timeline.core.vue b/src/client/app/desktop/views/home/timeline.core.vue
index 704ca48ae4..efffc0b4de 100644
--- a/src/client/app/desktop/views/home/timeline.core.vue
+++ b/src/client/app/desktop/views/home/timeline.core.vue
@@ -5,8 +5,11 @@
 		<router-link to="/explore">{{ $t('@.empty-timeline-info.explore') }}</router-link>
 	</div>
 
-	<mk-notes ref="timeline" :more="existMore ? more : null">
-		<p :class="$style.empty" slot="empty">
+	<mk-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')">
+		<template slot="header">
+			<slot></slot>
+		</template>
+		<p slot="empty">
 			<fa :icon="['far', 'comments']"/>{{ $t('empty') }}
 		</p>
 	</mk-notes>
@@ -21,6 +24,7 @@ const fetchLimit = 10;
 
 export default Vue.extend({
 	i18n: i18n('desktop/views/components/timeline.core.vue'),
+
 	props: {
 		src: {
 			type: String,
@@ -33,9 +37,6 @@ export default Vue.extend({
 
 	data() {
 		return {
-			fetching: true,
-			moreFetching: false,
-			existMore: false,
 			connection: null,
 			date: null,
 			baseQuery: {
@@ -44,21 +45,18 @@ export default Vue.extend({
 				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
 			},
 			query: {},
-			endpoint: null
+			endpoint: null,
+			makePromise: null
 		};
 	},
 
 	computed: {
 		alone(): boolean {
 			return this.$store.state.i.followingCount == 0;
-		},
-
-		canFetchMore(): boolean {
-			return !this.moreFetching && !this.fetching && this.existMore;
 		}
 	},
 
-	mounted() {
+	created() {
 		const prepend = note => {
 			(this.$refs.timeline as any).prepend(note);
 		};
@@ -109,7 +107,25 @@ export default Vue.extend({
 			this.connection.on('mention', onNote);
 		}
 
-		this.fetch();
+		this.makePromise = cursor => this.$root.api(this.endpoint, {
+			limit: fetchLimit + 1,
+			untilDate: cursor ? undefined : (this.date ? this.date.getTime() : undefined),
+			untilId: cursor ? cursor : undefined,
+			...this.baseQuery, ...this.query
+		}).then(notes => {
+			if (notes.length == fetchLimit + 1) {
+				notes.pop();
+				return {
+					notes: notes,
+					cursor: notes[notes.length - 1].id
+				};
+			} else {
+				return {
+					notes: notes,
+					cursor: null
+				};
+			}
+		});
 	},
 
 	beforeDestroy() {
@@ -117,57 +133,8 @@ export default Vue.extend({
 	},
 
 	methods: {
-		fetch() {
-			this.fetching = true;
-
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api(this.endpoint, Object.assign({
-					limit: fetchLimit + 1,
-					untilDate: this.date ? this.date.getTime() : undefined
-				}, this.baseQuery, this.query)).then(notes => {
-					if (notes.length == fetchLimit + 1) {
-						notes.pop();
-						this.existMore = true;
-					}
-					res(notes);
-					this.fetching = false;
-					this.$emit('loaded');
-				}, rej);
-			}));
-		},
-
-		more() {
-			if (!this.canFetchMore) return;
-
-			this.moreFetching = true;
-
-			const promise = this.$root.api(this.endpoint, Object.assign({
-				limit: fetchLimit + 1,
-				untilId: (this.$refs.timeline as any).tail().id
-			}, this.baseQuery, this.query));
-
-			promise.then(notes => {
-				if (notes.length == fetchLimit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) {
-					(this.$refs.timeline as any).append(n);
-				}
-				this.moreFetching = false;
-			});
-
-			return promise;
-		},
-
 		focus() {
 			(this.$refs.timeline as any).focus();
-		},
-
-		warp(date) {
-			this.date = date;
-			this.fetch();
 		}
 	}
 });
@@ -186,20 +153,3 @@ export default Vue.extend({
 			margin 0 0 8px 0
 
 </style>
-
-<style lang="stylus" module>
-.empty
-	display block
-	margin 0 auto
-	padding 32px
-	max-width 400px
-	text-align center
-	color #999
-
-	> [data-icon]
-		display block
-		margin-bottom 16px
-		font-size 3em
-		color #ccc
-
-</style>
diff --git a/src/client/app/desktop/views/home/timeline.vue b/src/client/app/desktop/views/home/timeline.vue
index 2f42b9723f..573cc95a9e 100644
--- a/src/client/app/desktop/views/home/timeline.vue
+++ b/src/client/app/desktop/views/home/timeline.vue
@@ -1,29 +1,23 @@
 <template>
-<div class="mk-timeline">
+<div class="pwbzawku">
 	<mk-post-form class="form" v-if="$store.state.settings.showPostFormOnTopOfTl"/>
 	<div class="main">
-		<header>
-			<span :data-active="src == 'home'" @click="src = 'home'"><fa icon="home"/> {{ $t('home') }}</span>
-			<span :data-active="src == 'local'" @click="src = 'local'" v-if="enableLocalTimeline"><fa :icon="['far', 'comments']"/> {{ $t('local') }}</span>
-			<span :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline"><fa icon="share-alt"/> {{ $t('hybrid') }}</span>
-			<span :data-active="src == 'global'" @click="src = 'global'" v-if="enableGlobalTimeline"><fa icon="globe"/> {{ $t('global') }}</span>
-			<span :data-active="src == 'tag'" @click="src = 'tag'" v-if="tagTl"><fa icon="hashtag"/> {{ tagTl.title }}</span>
-			<span :data-active="src == 'list'" @click="src = 'list'" v-if="list"><fa icon="list"/> {{ list.title }}</span>
-			<div class="buttons">
-				<button :data-active="src == 'mentions'" @click="src = 'mentions'" :title="$t('mentions')"><fa icon="at"/><i class="badge" v-if="$store.state.i.hasUnreadMentions"><fa icon="circle"/></i></button>
-				<button :data-active="src == 'messages'" @click="src = 'messages'" :title="$t('messages')"><fa :icon="['far', 'envelope']"/><i class="badge" v-if="$store.state.i.hasUnreadSpecifiedNotes"><fa icon="circle"/></i></button>
-				<button @click="chooseTag" :title="$t('hashtag')" ref="tagButton"><fa icon="hashtag"/></button>
-				<button @click="chooseList" :title="$t('list')" ref="listButton"><fa icon="list"/></button>
-			</div>
-		</header>
-		<x-core v-if="src == 'home'" ref="tl" key="home" src="home"/>
-		<x-core v-if="src == 'local'" ref="tl" key="local" src="local"/>
-		<x-core v-if="src == 'hybrid'" ref="tl" key="hybrid" src="hybrid"/>
-		<x-core v-if="src == 'global'" ref="tl" key="global" src="global"/>
-		<x-core v-if="src == 'mentions'" ref="tl" key="mentions" src="mentions"/>
-		<x-core v-if="src == 'messages'" ref="tl" key="messages" src="messages"/>
-		<x-core v-if="src == 'tag'" ref="tl" key="tag" src="tag" :tag-tl="tagTl"/>
-		<mk-user-list-timeline v-if="src == 'list'" ref="tl" :key="list.id" :list="list"/>
+		<component :is="src == 'list' ? 'mk-user-list-timeline' : 'x-core'" ref="tl" v-bind="options">
+			<header class="zahtxcqi">
+				<span :data-active="src == 'home'" @click="src = 'home'"><fa icon="home"/> {{ $t('home') }}</span>
+				<span :data-active="src == 'local'" @click="src = 'local'" v-if="enableLocalTimeline"><fa :icon="['far', 'comments']"/> {{ $t('local') }}</span>
+				<span :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline"><fa icon="share-alt"/> {{ $t('hybrid') }}</span>
+				<span :data-active="src == 'global'" @click="src = 'global'" v-if="enableGlobalTimeline"><fa icon="globe"/> {{ $t('global') }}</span>
+				<span :data-active="src == 'tag'" @click="src = 'tag'" v-if="tagTl"><fa icon="hashtag"/> {{ tagTl.title }}</span>
+				<span :data-active="src == 'list'" @click="src = 'list'" v-if="list"><fa icon="list"/> {{ list.title }}</span>
+				<div class="buttons">
+					<button :data-active="src == 'mentions'" @click="src = 'mentions'" :title="$t('mentions')"><fa icon="at"/><i class="badge" v-if="$store.state.i.hasUnreadMentions"><fa icon="circle"/></i></button>
+					<button :data-active="src == 'messages'" @click="src = 'messages'" :title="$t('messages')"><fa :icon="['far', 'envelope']"/><i class="badge" v-if="$store.state.i.hasUnreadSpecifiedNotes"><fa icon="circle"/></i></button>
+					<button @click="chooseTag" :title="$t('hashtag')" ref="tagButton"><fa icon="hashtag"/></button>
+					<button @click="chooseList" :title="$t('list')" ref="listButton"><fa icon="list"/></button>
+				</div>
+			</header>
+		</component>
 	</div>
 </div>
 </template>
@@ -51,6 +45,16 @@ export default Vue.extend({
 		};
 	},
 
+	computed: {
+		options(): any {
+			return {
+				...(this.src == 'list' ? { list: this.list } : { src: this.src }),
+				...(this.src == 'tag' ? { tagTl: this.tagTl } : {}),
+				key: this.src == 'list' ? this.list.id : this.src
+			}
+		}
+	},
+
 	watch: {
 		src() {
 			this.saveSrc();
@@ -186,88 +190,82 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-.mk-timeline
+.pwbzawku
 	> .form
 		margin-bottom 16px
 		box-shadow var(--shadow)
 		border-radius var(--round)
 
-	> .main
-		background var(--face)
-		box-shadow var(--shadow)
-		border-radius var(--round)
-		overflow hidden
+	.zahtxcqi
+		padding 0 8px
+		z-index 10
+		background var(--faceHeader)
+		box-shadow 0 var(--lineWidth) var(--desktopTimelineHeaderShadow)
 
-		> header
-			padding 0 8px
-			z-index 10
-			background var(--faceHeader)
-			box-shadow 0 var(--lineWidth) var(--desktopTimelineHeaderShadow)
+		> .buttons
+			position absolute
+			z-index 2
+			top 0
+			right 0
+			padding-right 8px
 
-			> .buttons
-				position absolute
-				z-index 2
-				top 0
-				right 0
-				padding-right 8px
-
-				> button
-					padding 0 8px
-					font-size 0.9em
-					line-height 42px
-					color var(--faceTextButton)
-
-					> .badge
-						position absolute
-						top -4px
-						right 4px
-						font-size 10px
-						color var(--notificationIndicator)
-
-					&:hover
-						color var(--faceTextButtonHover)
-
-					&[data-active]
-						color var(--primary)
-						cursor default
-
-						&:before
-							content ""
-							display block
-							position absolute
-							bottom 0
-							left 0
-							width 100%
-							height 2px
-							background var(--primary)
-
-			> span
-				display inline-block
-				padding 0 10px
+			> button
+				padding 0 8px
+				font-size 0.9em
 				line-height 42px
-				font-size 12px
-				user-select none
+				color var(--faceTextButton)
+
+				> .badge
+					position absolute
+					top -4px
+					right 4px
+					font-size 10px
+					color var(--notificationIndicator)
+
+				&:hover
+					color var(--faceTextButtonHover)
 
 				&[data-active]
 					color var(--primary)
 					cursor default
-					font-weight bold
 
 					&:before
 						content ""
 						display block
 						position absolute
 						bottom 0
-						left -8px
-						width calc(100% + 16px)
+						left 0
+						width 100%
 						height 2px
 						background var(--primary)
 
-				&:not([data-active])
-					color var(--desktopTimelineSrc)
-					cursor pointer
+		> span
+			display inline-block
+			padding 0 10px
+			line-height 42px
+			font-size 12px
+			user-select none
 
-					&:hover
-						color var(--desktopTimelineSrcHover)
+			&[data-active]
+				color var(--primary)
+				cursor default
+				font-weight bold
+
+				&:before
+					content ""
+					display block
+					position absolute
+					bottom 0
+					left -8px
+					width calc(100% + 16px)
+					height 2px
+					background var(--primary)
+
+			&:not([data-active])
+				color var(--desktopTimelineSrc)
+				cursor pointer
+
+				&:hover
+					color var(--desktopTimelineSrcHover)
 
 </style>
diff --git a/src/client/app/desktop/views/home/user/user.home.vue b/src/client/app/desktop/views/home/user/user.home.vue
index 3a999b5739..65aa5e1c8a 100644
--- a/src/client/app/desktop/views/home/user/user.home.vue
+++ b/src/client/app/desktop/views/home/user/user.home.vue
@@ -10,7 +10,7 @@
 		</ui-container>
 	</div>
 	<x-photos :user="user"/>
-	<x-timeline class="timeline" ref="tl" :user="user"/>
+	<x-timeline ref="tl" :user="user"/>
 </div>
 </template>
 
@@ -51,7 +51,4 @@ export default Vue.extend({
 	> *
 		margin-bottom 16px
 
-	> .timeline
-		box-shadow var(--shadow)
-
 </style>
diff --git a/src/client/app/desktop/views/home/user/user.timeline.vue b/src/client/app/desktop/views/home/user/user.timeline.vue
index 0571ce76f1..edbced8170 100644
--- a/src/client/app/desktop/views/home/user/user.timeline.vue
+++ b/src/client/app/desktop/views/home/user/user.timeline.vue
@@ -1,12 +1,12 @@
 <template>
-<div class="oh5y2r7l5lx8j6jj791ykeiwgihheguk">
-	<header>
-		<span :data-active="mode == 'default'" @click="mode = 'default'"><fa :icon="['far', 'comment-alt']"/> {{ $t('default') }}</span>
-		<span :data-active="mode == 'with-replies'" @click="mode = 'with-replies'"><fa icon="comments"/> {{ $t('with-replies') }}</span>
-		<span :data-active="mode == 'with-media'" @click="mode = 'with-media'"><fa :icon="['far', 'images']"/> {{ $t('with-media') }}</span>
-		<span :data-active="mode == 'my-posts'" @click="mode = 'my-posts'"><fa icon="user"/> {{ $t('my-posts') }}</span>
-	</header>
-	<mk-notes ref="timeline" :more="existMore ? more : null">
+<div>
+	<mk-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')">
+		<header slot="header" class="oh5y2r7l5lx8j6jj791ykeiwgihheguk">
+			<span :data-active="mode == 'default'" @click="mode = 'default'"><fa :icon="['far', 'comment-alt']"/> {{ $t('default') }}</span>
+			<span :data-active="mode == 'with-replies'" @click="mode = 'with-replies'"><fa icon="comments"/> {{ $t('with-replies') }}</span>
+			<span :data-active="mode == 'with-media'" @click="mode = 'with-media'"><fa :icon="['far', 'images']"/> {{ $t('with-media') }}</span>
+			<span :data-active="mode == 'my-posts'" @click="mode = 'my-posts'"><fa icon="user"/> {{ $t('my-posts') }}</span>
+		</header>
 		<p class="empty" slot="empty"><fa :icon="['far', 'comments']"/>{{ $t('empty') }}</p>
 	</mk-notes>
 </div>
@@ -20,29 +20,47 @@ const fetchLimit = 10;
 
 export default Vue.extend({
 	i18n: i18n('desktop/views/pages/user/user.timeline.vue'),
+
 	props: ['user'],
 
 	data() {
 		return {
 			fetching: true,
-			moreFetching: false,
-			existMore: false,
 			mode: 'default',
 			unreadCount: 0,
-			date: null
+			date: null,
+			makePromise: cursor => this.$root.api('users/notes', {
+				userId: this.user.id,
+				limit: fetchLimit + 1,
+				includeReplies: this.mode == 'with-replies',
+				includeMyRenotes: this.mode != 'my-posts',
+				withFiles: this.mode == 'with-media',
+				untilId: cursor ? cursor : undefined
+			}).then(notes => {
+				if (notes.length == fetchLimit + 1) {
+					notes.pop();
+					return {
+						notes: notes,
+						cursor: notes[notes.length - 1].id
+					};
+				} else {
+					return {
+						notes: notes,
+						cursor: null
+					};
+				}
+			})
 		};
 	},
 
 	watch: {
 		mode() {
-			this.fetch();
+			(this.$refs.timeline as any).reload();
 		}
 	},
 
 	mounted() {
 		document.addEventListener('keydown', this.onDocumentKeydown);
-
-		this.fetch(() => this.$emit('loaded'));
 	},
 
 	beforeDestroy() {
@@ -58,58 +76,9 @@ export default Vue.extend({
 			}
 		},
 
-		fetch(cb?) {
-			this.fetching = true;
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api('users/notes', {
-					userId: this.user.id,
-					limit: fetchLimit + 1,
-					untilDate: this.date ? this.date.getTime() : new Date().getTime() + 1000 * 86400 * 365,
-					includeReplies: this.mode == 'with-replies',
-					includeMyRenotes: this.mode != 'my-posts',
-					withFiles: this.mode == 'with-media'
-				}).then(notes => {
-					if (notes.length == fetchLimit + 1) {
-						notes.pop();
-						this.existMore = true;
-					}
-					res(notes);
-					this.fetching = false;
-					if (cb) cb();
-				}, rej);
-			}));
-		},
-
-		more() {
-			this.moreFetching = true;
-
-			const promise = this.$root.api('users/notes', {
-				userId: this.user.id,
-				limit: fetchLimit + 1,
-				includeReplies: this.mode == 'with-replies',
-				includeMyRenotes: this.mode != 'my-posts',
-				withFiles: this.mode == 'with-media',
-				untilDate: new Date((this.$refs.timeline as any).tail().createdAt).getTime()
-			});
-
-			promise.then(notes => {
-				if (notes.length == fetchLimit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) {
-					(this.$refs.timeline as any).append(n);
-				}
-				this.moreFetching = false;
-			});
-
-			return promise;
-		},
-
 		warp(date) {
 			this.date = date;
-			this.fetch();
+			(this.$refs.timeline as any).reload();
 		}
 	}
 });
@@ -117,59 +86,38 @@ export default Vue.extend({
 
 <style lang="stylus" scoped>
 .oh5y2r7l5lx8j6jj791ykeiwgihheguk
-	background var(--face)
-	border-radius var(--round)
-	overflow hidden
+	padding 0 8px
+	z-index 10
+	background var(--faceHeader)
+	box-shadow 0 1px var(--desktopTimelineHeaderShadow)
 
-	> header
-		padding 0 8px
-		z-index 10
-		background var(--faceHeader)
-		box-shadow 0 1px var(--desktopTimelineHeaderShadow)
+	> span
+		display inline-block
+		padding 0 10px
+		line-height 42px
+		font-size 12px
+		user-select none
 
-		> span
-			display inline-block
-			padding 0 10px
-			line-height 42px
-			font-size 12px
-			user-select none
+		&[data-active]
+			color var(--primary)
+			cursor default
+			font-weight bold
 
-			&[data-active]
-				color var(--primary)
-				cursor default
-				font-weight bold
-
-				&:before
-					content ""
-					display block
-					position absolute
-					bottom 0
-					left -8px
-					width calc(100% + 16px)
-					height 2px
-					background var(--primary)
-
-			&:not([data-active])
-				color var(--desktopTimelineSrc)
-				cursor pointer
-
-				&:hover
-					color var(--desktopTimelineSrcHover)
-
-	> .mk-notes
-
-		> .empty
-			display block
-			margin 0 auto
-			padding 32px
-			max-width 400px
-			text-align center
-			color var(--text)
-
-			> [data-icon]
+			&:before
+				content ""
 				display block
-				margin-bottom 16px
-				font-size 3em
-				color var(--faceHeaderText);
+				position absolute
+				bottom 0
+				left -8px
+				width calc(100% + 16px)
+				height 2px
+				background var(--primary)
+
+		&:not([data-active])
+			color var(--desktopTimelineSrc)
+			cursor pointer
+
+			&:hover
+				color var(--desktopTimelineSrcHover)
 
 </style>
diff --git a/src/client/app/mobile/style.styl b/src/client/app/mobile/style.styl
index 095e5266fd..3a4fc9c0c6 100644
--- a/src/client/app/mobile/style.styl
+++ b/src/client/app/mobile/style.styl
@@ -9,3 +9,15 @@
 html
 	height 100%
 	background var(--bg)
+
+main
+	width 100%
+	max-width 680px
+	margin 0 auto
+	padding 8px
+
+	@media (min-width 500px)
+		padding 16px
+
+	@media (min-width 600px)
+		padding 32px
diff --git a/src/client/app/mobile/views/components/notes.vue b/src/client/app/mobile/views/components/notes.vue
index 1d0375cfa9..9b4e7a3895 100644
--- a/src/client/app/mobile/views/components/notes.vue
+++ b/src/client/app/mobile/views/components/notes.vue
@@ -1,8 +1,8 @@
 <template>
-<div class="mk-notes">
-	<slot name="head"></slot>
+<div class="ivaojijs">
+	<slot name="empty" v-if="notes.length == 0 && !fetching && inited"></slot>
 
-	<slot name="empty" v-if="notes.length == 0 && !fetching && requestInitPromise == null"></slot>
+	<mk-error v-if="!fetching && !inited" @retry="init()"/>
 
 	<div class="placeholder" v-if="fetching">
 		<template v-for="i in 10">
@@ -10,8 +10,6 @@
 		</template>
 	</div>
 
-	<mk-error v-if="!fetching && requestInitPromise != null" @retry="resolveInitPromise"/>
-
 	<!-- トランジションを有効にするとなぜかメモリリークする -->
 	<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="transition" tag="div">
 		<template v-for="(note, i) in _notes">
@@ -23,8 +21,8 @@
 		</template>
 	</component>
 
-	<footer v-if="more">
-		<button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+	<footer v-if="cursor != null">
+		<button @click="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
 			<template v-if="!moreFetching">{{ $t('@.load-more') }}</template>
 			<template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template>
 		</button>
@@ -41,20 +39,21 @@ const displayLimit = 30;
 
 export default Vue.extend({
 	i18n: i18n(),
+
 	props: {
-		more: {
-			type: Function,
-			required: false
+		makePromise: {
+			required: true
 		}
 	},
 
 	data() {
 		return {
-			requestInitPromise: null as () => Promise<any[]>,
 			notes: [],
 			queue: [],
 			fetching: true,
-			moreFetching: false
+			moreFetching: false,
+			inited: false,
+			cursor: null
 		};
 	},
 
@@ -80,6 +79,10 @@ export default Vue.extend({
 		}
 	},
 
+	created() {
+		this.init();
+	},
+
 	mounted() {
 		window.addEventListener('scroll', this.onScroll, { passive: true });
 	},
@@ -97,27 +100,41 @@ export default Vue.extend({
 			Vue.set((this as any).notes, i, note);
 		},
 
-		init(promiseGenerator: () => Promise<any[]>) {
-			this.requestInitPromise = promiseGenerator;
-			this.resolveInitPromise();
-		},
-
-		resolveInitPromise() {
+		reload() {
 			this.queue = [];
 			this.notes = [];
+			this.init();
+		},
+
+		init() {
 			this.fetching = true;
-
-			const promise = this.requestInitPromise();
-
-			promise.then(notes => {
-				this.notes = notes;
-				this.requestInitPromise = null;
+			this.makePromise().then(x => {
+				if (Array.isArray(x)) {
+					this.notes = x;
+				} else {
+					this.notes = x.notes;
+					this.cursor = x.cursor;
+				}
+				this.inited = true;
 				this.fetching = false;
+				this.$emit('inited');
 			}, e => {
 				this.fetching = false;
 			});
 		},
 
+		more() {
+			if (this.cursor == null || this.moreFetching) return;
+			this.moreFetching = true;
+			this.makePromise(this.cursor).then(x => {
+				this.notes = this.notes.concat(x.notes);
+				this.cursor = x.cursor;
+				this.moreFetching = false;
+			}, e => {
+				this.moreFetching = false;
+			});
+		},
+
 		prepend(note, silent = false) {
 			// 弾く
 			if (shouldMuteNote(this.$store.state.i, this.$store.state.settings, note)) return;
@@ -144,10 +161,6 @@ export default Vue.extend({
 			this.notes.push(note);
 		},
 
-		tail() {
-			return this.notes[this.notes.length - 1];
-		},
-
 		releaseQueue() {
 			for (const n of this.queue) {
 				this.prepend(n, true);
@@ -155,15 +168,6 @@ export default Vue.extend({
 			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();
@@ -176,7 +180,7 @@ export default Vue.extend({
 				if (this.$el.offsetHeight == 0) return;
 
 				const current = window.scrollY + window.innerHeight;
-				if (current > document.body.offsetHeight - 8) this.loadMore();
+				if (current > document.body.offsetHeight - 8) this.more();
 			}
 		}
 	}
@@ -184,7 +188,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-.mk-notes
+.ivaojijs
 	overflow hidden
 	background var(--face)
 	border-radius 8px
diff --git a/src/client/app/mobile/views/components/user-list-timeline.vue b/src/client/app/mobile/views/components/user-list-timeline.vue
index d90051710b..e67d7931f7 100644
--- a/src/client/app/mobile/views/components/user-list-timeline.vue
+++ b/src/client/app/mobile/views/components/user-list-timeline.vue
@@ -1,6 +1,6 @@
 <template>
 <div>
-	<mk-notes ref="timeline" :more="existMore ? more : null"/>
+	<mk-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
 </div>
 </template>
 
@@ -14,19 +14,31 @@ export default Vue.extend({
 
 	data() {
 		return {
-			fetching: true,
-			moreFetching: false,
-			existMore: false,
-			connection: null
+			connection: null,
+			makePromise: cursor => this.$root.api('notes/user-list-timeline', {
+				listId: this.list.id,
+				limit: fetchLimit + 1,
+				untilId: cursor ? cursor : undefined,
+				includeMyRenotes: this.$store.state.settings.showMyRenotes,
+				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
+			}).then(notes => {
+				if (notes.length == fetchLimit + 1) {
+					notes.pop();
+					return {
+						notes: notes,
+						cursor: notes[notes.length - 1].id
+					};
+				} else {
+					return {
+						notes: notes,
+						cursor: null
+					};
+				}
+			})
 		};
 	},
 
-	computed: {
-		canFetchMore(): boolean {
-			return !this.moreFetching && !this.fetching && this.existMore;
-		}
-	},
-
 	watch: {
 		$route: 'init'
 	},
@@ -48,59 +60,6 @@ export default Vue.extend({
 			this.connection.on('note', this.onNote);
 			this.connection.on('userAdded', this.onUserAdded);
 			this.connection.on('userRemoved', this.onUserRemoved);
-
-			this.fetch();
-		},
-
-		fetch() {
-			this.fetching = true;
-
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api('notes/user-list-timeline', {
-					listId: this.list.id,
-					limit: fetchLimit + 1,
-					includeMyRenotes: this.$store.state.settings.showMyRenotes,
-					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
-				}).then(notes => {
-					if (notes.length == fetchLimit + 1) {
-						notes.pop();
-						this.existMore = true;
-					}
-					res(notes);
-					this.fetching = false;
-					this.$emit('loaded');
-				}, rej);
-			}));
-		},
-
-		more() {
-			if (!this.canFetchMore) return;
-
-			this.moreFetching = true;
-
-			const promise = this.$root.api('notes/user-list-timeline', {
-				listId: this.list.id,
-				limit: fetchLimit + 1,
-				untilId: (this.$refs.timeline as any).tail().id,
-				includeMyRenotes: this.$store.state.settings.showMyRenotes,
-				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
-				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
-			});
-
-			promise.then(notes => {
-				if (notes.length == fetchLimit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) {
-					(this.$refs.timeline as any).append(n);
-				}
-				this.moreFetching = false;
-			});
-
-			return promise;
 		},
 
 		onNote(note) {
@@ -109,11 +68,11 @@ export default Vue.extend({
 		},
 
 		onUserAdded() {
-			this.fetch();
+			(this.$refs.timeline as any).reload();
 		},
 
 		onUserRemoved() {
-			this.fetch();
+			(this.$refs.timeline as any).reload();
 		}
 	}
 });
diff --git a/src/client/app/mobile/views/components/user-timeline.vue b/src/client/app/mobile/views/components/user-timeline.vue
index 0d0bbc4073..e85a0d177c 100644
--- a/src/client/app/mobile/views/components/user-timeline.vue
+++ b/src/client/app/mobile/views/components/user-timeline.vue
@@ -1,6 +1,6 @@
 <template>
 <div class="mk-user-timeline">
-	<mk-notes ref="timeline" :more="existMore ? more : null">
+	<mk-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')">
 		<div slot="empty">
 			<fa :icon="['far', 'comments']"/>
 			{{ withMedia ? this.$t('no-notes-with-media') : this.$t('no-notes') }}
@@ -17,73 +17,31 @@ const fetchLimit = 10;
 
 export default Vue.extend({
 	i18n: i18n('mobile/views/components/user-timeline.vue'),
+
 	props: ['user', 'withMedia'],
 
 	data() {
 		return {
-			fetching: true,
-			existMore: false,
-			moreFetching: false
-		};
-	},
-
-	computed: {
-		canFetchMore(): boolean {
-			return !this.moreFetching && !this.fetching && this.existMore;
-		}
-	},
-
-	mounted() {
-		this.fetch();
-	},
-
-	methods: {
-		fetch() {
-			this.fetching = true;
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api('users/notes', {
-					userId: this.user.id,
-					withFiles: this.withMedia,
-					limit: fetchLimit + 1,
-					untilDate: new Date().getTime() + 1000 * 86400 * 365
-				}).then(notes => {
-					if (notes.length == fetchLimit + 1) {
-						notes.pop();
-						this.existMore = true;
-					}
-					res(notes);
-					this.fetching = false;
-					this.$emit('loaded');
-				}, rej);
-			}));
-		},
-
-		more() {
-			if (!this.canFetchMore) return;
-
-			this.moreFetching = true;
-
-			const promise = this.$root.api('users/notes', {
+			makePromise: cursor => this.$root.api('users/notes', {
 				userId: this.user.id,
-				withFiles: this.withMedia,
 				limit: fetchLimit + 1,
-				untilDate: new Date((this.$refs.timeline as any).tail().createdAt).getTime()
-			});
-
-			promise.then(notes => {
+				withFiles: this.withMedia,
+				untilId: cursor ? cursor : undefined
+			}).then(notes => {
 				if (notes.length == fetchLimit + 1) {
 					notes.pop();
+					return {
+						notes: notes,
+						cursor: notes[notes.length - 1].id
+					};
 				} else {
-					this.existMore = false;
+					return {
+						notes: notes,
+						cursor: null
+					};
 				}
-				for (const n of notes) {
-					(this.$refs.timeline as any).append(n);
-				}
-				this.moreFetching = false;
-			});
-
-			return promise;
-		}
+			})
+		};
 	}
 });
 </script>
diff --git a/src/client/app/mobile/views/pages/explore.vue b/src/client/app/mobile/views/pages/explore.vue
index 2955c9a50b..c861f2dfc4 100644
--- a/src/client/app/mobile/views/pages/explore.vue
+++ b/src/client/app/mobile/views/pages/explore.vue
@@ -26,18 +26,3 @@ export default Vue.extend({
 	},
 });
 </script>
-
-<style lang="stylus" scoped>
-main
-	width 100%
-	max-width 680px
-	margin 0 auto
-	padding 8px
-
-	@media (min-width 500px)
-		padding 16px
-
-	@media (min-width 600px)
-		padding 32px
-
-</style>
diff --git a/src/client/app/mobile/views/pages/favorites.vue b/src/client/app/mobile/views/pages/favorites.vue
index 61dd1526ba..9fcaf566e3 100644
--- a/src/client/app/mobile/views/pages/favorites.vue
+++ b/src/client/app/mobile/views/pages/favorites.vue
@@ -76,21 +76,11 @@ export default Vue.extend({
 
 <style lang="stylus" scoped>
 main
-	width 100%
-	max-width 680px
-	margin 0 auto
-	padding 8px
-
 	> * > .post
 		margin-bottom 8px
 
 	@media (min-width 500px)
-		padding 16px
-
 		> * > .post
 			margin-bottom 16px
 
-	@media (min-width 600px)
-		padding 32px
-
 </style>
diff --git a/src/client/app/mobile/views/pages/featured.vue b/src/client/app/mobile/views/pages/featured.vue
index 9122673be1..cab7b7243e 100644
--- a/src/client/app/mobile/views/pages/featured.vue
+++ b/src/client/app/mobile/views/pages/featured.vue
@@ -51,21 +51,11 @@ export default Vue.extend({
 
 <style lang="stylus" scoped>
 main
-	width 100%
-	max-width 680px
-	margin 0 auto
-	padding 8px
-
 	> * > .post
 		margin-bottom 8px
 
 	@media (min-width 500px)
-		padding 16px
-
 		> * > .post
 			margin-bottom 16px
 
-	@media (min-width 600px)
-		padding 32px
-
 </style>
diff --git a/src/client/app/mobile/views/pages/home.timeline.vue b/src/client/app/mobile/views/pages/home.timeline.vue
index b768a9ccc8..2fa155892e 100644
--- a/src/client/app/mobile/views/pages/home.timeline.vue
+++ b/src/client/app/mobile/views/pages/home.timeline.vue
@@ -7,7 +7,7 @@
 		</div>
 	</ui-container>
 
-	<mk-notes ref="timeline" :more="existMore ? more : null">
+	<mk-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')">
 		<div slot="empty">
 			<fa :icon="['far', 'comments']"/>{{ $t('empty') }}
 		</div>
@@ -36,9 +36,6 @@ export default Vue.extend({
 
 	data() {
 		return {
-			fetching: true,
-			moreFetching: false,
-			existMore: false,
 			streamManager: null,
 			connection: null,
 			unreadCount: 0,
@@ -49,21 +46,18 @@ export default Vue.extend({
 				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
 			},
 			query: {},
-			endpoint: null
+			endpoint: null,
+			makePromise: null
 		};
 	},
 
 	computed: {
 		alone(): boolean {
 			return this.$store.state.i.followingCount == 0;
-		},
-
-		canFetchMore(): boolean {
-			return !this.moreFetching && !this.fetching && this.existMore;
 		}
 	},
 
-	mounted() {
+	created() {
 		const prepend = note => {
 			(this.$refs.timeline as any).prepend(note);
 		};
@@ -114,7 +108,25 @@ export default Vue.extend({
 			this.connection.on('mention', onNote);
 		}
 
-		this.fetch();
+		this.makePromise = cursor => this.$root.api(this.endpoint, {
+			limit: fetchLimit + 1,
+			untilDate: cursor ? undefined : (this.date ? this.date.getTime() : undefined),
+			untilId: cursor ? cursor : undefined,
+			...this.baseQuery, ...this.query
+		}).then(notes => {
+			if (notes.length == fetchLimit + 1) {
+				notes.pop();
+				return {
+					notes: notes,
+					cursor: notes[notes.length - 1].id
+				};
+			} else {
+				return {
+					notes: notes,
+					cursor: null
+				};
+			}
+		});
 	},
 
 	beforeDestroy() {
@@ -122,57 +134,13 @@ export default Vue.extend({
 	},
 
 	methods: {
-		fetch() {
-			this.fetching = true;
-
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api(this.endpoint, Object.assign({
-					limit: fetchLimit + 1,
-					untilDate: this.date ? this.date.getTime() : undefined
-				}, this.baseQuery, this.query)).then(notes => {
-					if (notes.length == fetchLimit + 1) {
-						notes.pop();
-						this.existMore = true;
-					}
-					res(notes);
-					this.fetching = false;
-					this.$emit('loaded');
-				}, rej);
-			}));
-		},
-
-		more() {
-			if (!this.canFetchMore) return;
-
-			this.moreFetching = true;
-
-			const promise = this.$root.api(this.endpoint, Object.assign({
-				limit: fetchLimit + 1,
-				untilId: (this.$refs.timeline as any).tail().id
-			}, this.baseQuery, this.query));
-
-			promise.then(notes => {
-				if (notes.length == fetchLimit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) {
-					(this.$refs.timeline as any).append(n);
-				}
-				this.moreFetching = false;
-			});
-
-			return promise;
-		},
-
 		focus() {
 			(this.$refs.timeline as any).focus();
 		},
 
 		warp(date) {
 			this.date = date;
-			this.fetch();
+			(this.$refs.timeline as any).reload();
 		}
 	}
 });
diff --git a/src/client/app/mobile/views/pages/home.vue b/src/client/app/mobile/views/pages/home.vue
index a663c1da99..7f6a1b8238 100644
--- a/src/client/app/mobile/views/pages/home.vue
+++ b/src/client/app/mobile/views/pages/home.vue
@@ -233,17 +233,6 @@ main
 						font-size 10px
 						color var(--notificationIndicator)
 
-	> .tl
-		max-width 680px
-		margin 0 auto
-		padding 8px
-
-		@media (min-width 500px)
-			padding 16px
-
-		@media (min-width 600px)
-			padding 32px
-
 </style>
 
 <style lang="stylus" module>
diff --git a/src/client/app/mobile/views/pages/note.vue b/src/client/app/mobile/views/pages/note.vue
index 79757ea374..f22601a3f7 100644
--- a/src/client/app/mobile/views/pages/note.vue
+++ b/src/client/app/mobile/views/pages/note.vue
@@ -56,18 +56,6 @@ export default Vue.extend({
 <style lang="stylus" scoped>
 main
 	text-align center
-	padding 8px
-
-	@media (min-width 500px)
-		padding 16px
-
-	@media (min-width 600px)
-		padding 32px
-
-	> div
-		margin 0 auto
-		padding 0
-		max-width 600px
 
 	> footer
 		margin-top 16px
diff --git a/src/client/app/mobile/views/pages/notifications.vue b/src/client/app/mobile/views/pages/notifications.vue
index c6e5b646f2..fd84a21c15 100644
--- a/src/client/app/mobile/views/pages/notifications.vue
+++ b/src/client/app/mobile/views/pages/notifications.vue
@@ -39,18 +39,3 @@ export default Vue.extend({
 	}
 });
 </script>
-
-<style lang="stylus" scoped>
-main
-	width 100%
-	max-width 680px
-	margin 0 auto
-	padding 8px
-
-	@media (min-width 500px)
-		padding 16px
-
-	@media (min-width 600px)
-		padding 32px
-
-</style>
diff --git a/src/client/app/mobile/views/pages/received-follow-requests.vue b/src/client/app/mobile/views/pages/received-follow-requests.vue
index 1b8323e834..df0cf109cd 100644
--- a/src/client/app/mobile/views/pages/received-follow-requests.vue
+++ b/src/client/app/mobile/views/pages/received-follow-requests.vue
@@ -57,17 +57,6 @@ export default Vue.extend({
 
 <style lang="stylus" scoped>
 main
-	width 100%
-	max-width 680px
-	margin 0 auto
-	padding 8px
-
-	@media (min-width 500px)
-		padding 16px
-
-	@media (min-width 600px)
-		padding 32px
-
 	> div
 		display flex
 		padding 16px
diff --git a/src/client/app/mobile/views/pages/search.vue b/src/client/app/mobile/views/pages/search.vue
index 669e0b740b..0bfc1c0384 100644
--- a/src/client/app/mobile/views/pages/search.vue
+++ b/src/client/app/mobile/views/pages/search.vue
@@ -3,8 +3,7 @@
 	<span slot="header"><fa icon="search"/> {{ q }}</span>
 
 	<main>
-		<p :class="$style.empty" v-if="!fetching && empty"><fa icon="search"/> {{ $t('not-found', { q }) }}</p>
-		<mk-notes ref="timeline" :more="existMore ? more : null"/>
+		<mk-notes ref="timeline" :make-promise="makePromise" @inited="inited"/>
 	</main>
 </mk-ui>
 </template>
@@ -20,15 +19,30 @@ export default Vue.extend({
 	i18n: i18n('mobile/views/pages/search.vue'),
 	data() {
 		return {
-			fetching: true,
-			moreFetching: false,
-			existMore: false,
-			empty: false,
-			offset: 0
+			makePromise: cursor => this.$root.api('notes/search', {
+				limit: limit + 1,
+				offset: cursor ? cursor : undefined,
+				query: this.q
+			}).then(notes => {
+				if (notes.length == limit + 1) {
+					notes.pop();
+					return {
+						notes: notes,
+						cursor: cursor ? cursor + limit : limit
+					};
+				} else {
+					return {
+						notes: notes,
+						cursor: null
+					};
+				}
+			})
 		};
 	},
 	watch: {
-		$route: 'fetch'
+		$route() {
+			this.$refs.timeline.reload();
+		}
 	},
 	computed: {
 		q(): string {
@@ -37,68 +51,11 @@ export default Vue.extend({
 	},
 	mounted() {
 		document.title = `%i18n:@search%: ${this.q} | ${this.$root.instanceName}`;
-
-		this.fetch();
 	},
 	methods: {
-		fetch() {
-			this.fetching = true;
-			Progress.start();
-
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api('notes/search', {
-					limit: limit + 1,
-					offset: this.offset,
-					query: this.q
-				}).then(notes => {
-					if (notes.length == 0) this.empty = true;
-					if (notes.length == limit + 1) {
-						notes.pop();
-						this.existMore = true;
-					}
-					res(notes);
-					this.fetching = false;
-					Progress.done();
-				}, rej);
-			}));
+		inited() {
+			Progress.done();
 		},
-		more() {
-			this.offset += limit;
-
-			const promise = this.$root.api('notes/search', {
-				limit: limit + 1,
-				offset: this.offset,
-				query: this.q
-			});
-
-			promise.then(notes => {
-				if (notes.length == limit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) {
-					(this.$refs.timeline as any).append(n);
-				}
-				this.moreFetching = false;
-			});
-
-			return promise;
-		}
 	}
 });
 </script>
-
-<style lang="stylus" module>
-.notes
-	margin 8px auto
-	max-width 500px
-	width calc(100% - 16px)
-	background #fff
-	border-radius 8px
-	box-shadow 0 0 0 1px rgba(#000, 0.2)
-
-	@media (min-width 500px)
-		margin 16px auto
-		width calc(100% - 32px)
-</style>
diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue
index f26b9af6f4..17f0c2f146 100644
--- a/src/client/app/mobile/views/pages/settings.vue
+++ b/src/client/app/mobile/views/pages/settings.vue
@@ -383,9 +383,6 @@ export default Vue.extend({
 
 <style lang="stylus" scoped>
 main
-	margin 0 auto
-	max-width 600px
-	width 100%
 
 	> .signed-in-as
 		margin 16px
diff --git a/src/client/app/mobile/views/pages/tag.vue b/src/client/app/mobile/views/pages/tag.vue
index ecd523dab2..53129ed20b 100644
--- a/src/client/app/mobile/views/pages/tag.vue
+++ b/src/client/app/mobile/views/pages/tag.vue
@@ -3,8 +3,7 @@
 	<span slot="header"><span style="margin-right:4px;"><fa icon="hashtag"/></span>{{ $route.params.tag }}</span>
 
 	<main>
-		<p v-if="!fetching && empty"><fa icon="search"/> {{ $t('no-posts-found', { q: $route.params.tag }) }}</p>
-		<mk-notes ref="timeline" :more="existMore ? more : null"/>
+		<mk-notes ref="timeline" :make-promise="makePromise" @inited="inited"/>
 	</main>
 </mk-ui>
 </template>
@@ -20,66 +19,35 @@ export default Vue.extend({
 	i18n: i18n('mobile/views/pages/tag.vue'),
 	data() {
 		return {
-			fetching: true,
-			moreFetching: false,
-			existMore: false,
-			offset: 0,
-			empty: false
+			makePromise: cursor => this.$root.api('notes/search_by_tag', {
+				limit: limit + 1,
+				offset: cursor ? cursor : undefined,
+				tag: this.$route.params.tag
+			}).then(notes => {
+				if (notes.length == limit + 1) {
+					notes.pop();
+					return {
+						notes: notes,
+						cursor: cursor ? cursor + limit : limit
+					};
+				} else {
+					return {
+						notes: notes,
+						cursor: null
+					};
+				}
+			})
 		};
 	},
 	watch: {
-		$route: 'fetch'
-	},
-	mounted() {
-		this.$nextTick(() => {
-			this.fetch();
-		});
+		$route() {
+			this.$refs.timeline.reload();
+		}
 	},
 	methods: {
-		fetch() {
-			this.fetching = true;
-			Progress.start();
-
-			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
-				this.$root.api('notes/search_by_tag', {
-					limit: limit + 1,
-					offset: this.offset,
-					tag: this.$route.params.tag
-				}).then(notes => {
-					if (notes.length == 0) this.empty = true;
-					if (notes.length == limit + 1) {
-						notes.pop();
-						this.existMore = true;
-					}
-					res(notes);
-					this.fetching = false;
-					Progress.done();
-				}, rej);
-			}));
+		inited() {
+			Progress.done();
 		},
-		more() {
-			this.offset += limit;
-
-			const promise = this.$root.api('notes/search_by_tag', {
-				limit: limit + 1,
-				offset: this.offset,
-				tag: this.$route.params.tag
-			});
-
-			promise.then(notes => {
-				if (notes.length == limit + 1) {
-					notes.pop();
-				} else {
-					this.existMore = false;
-				}
-				for (const n of notes) {
-					(this.$refs.timeline as any).append(n);
-				}
-				this.moreFetching = false;
-			});
-
-			return promise;
-		}
 	}
 });
 </script>
diff --git a/src/client/app/mobile/views/pages/user-list.vue b/src/client/app/mobile/views/pages/user-list.vue
index cf2dd134fd..ad6e314767 100644
--- a/src/client/app/mobile/views/pages/user-list.vue
+++ b/src/client/app/mobile/views/pages/user-list.vue
@@ -46,18 +46,3 @@ export default Vue.extend({
 	}
 });
 </script>
-
-<style lang="stylus" scoped>
-main
-	width 100%
-	max-width 680px
-	margin 0 auto
-	padding 8px
-
-	@media (min-width 500px)
-		padding 16px
-
-	@media (min-width 600px)
-		padding 32px
-
-</style>
diff --git a/src/client/app/mobile/views/pages/user-lists.vue b/src/client/app/mobile/views/pages/user-lists.vue
index dc9d47de3c..530357576f 100644
--- a/src/client/app/mobile/views/pages/user-lists.vue
+++ b/src/client/app/mobile/views/pages/user-lists.vue
@@ -53,20 +53,3 @@ export default Vue.extend({
 	}
 });
 </script>
-
-<style lang="stylus" scoped>
-
-
-main
-	width 100%
-	max-width 680px
-	margin 0 auto
-	padding 8px
-
-	@media (min-width 500px)
-		padding 16px
-
-	@media (min-width 600px)
-		padding 32px
-
-</style>
diff --git a/src/client/app/mobile/views/pages/user/home.vue b/src/client/app/mobile/views/pages/user/home.vue
index 98b4f44476..f9b7e7f90a 100644
--- a/src/client/app/mobile/views/pages/user/home.vue
+++ b/src/client/app/mobile/views/pages/user/home.vue
@@ -57,7 +57,6 @@ export default Vue.extend({
 
 <style lang="stylus" scoped>
 .root.home
-	max-width 600px
 	margin 0 auto
 
 	> .mk-note-detail
diff --git a/src/client/app/mobile/views/pages/user/index.vue b/src/client/app/mobile/views/pages/user/index.vue
index d7fb3d4d58..a2360e7417 100644
--- a/src/client/app/mobile/views/pages/user/index.vue
+++ b/src/client/app/mobile/views/pages/user/index.vue
@@ -3,7 +3,7 @@
 	<template slot="header" v-if="!fetching"><img :src="avator" alt="">
 		<mk-user-name :user="user"/>
 	</template>
-	<main v-if="!fetching">
+	<div class="wwtwuxyh" v-if="!fetching">
 		<div class="is-suspended" v-if="user.isSuspended"><p><fa icon="exclamation-triangle"/> {{ $t('@.user-suspended') }}</p></div>
 		<div class="is-remote" v-if="user.host != null"><p><fa icon="exclamation-triangle"/> {{ $t('@.is-remote-user') }}<a :href="user.url || user.uri" target="_blank">{{ $t('@.view-on-remote') }}</a></p></div>
 		<header>
@@ -65,15 +65,15 @@
 				<a :data-active="page == 'media'" @click="page = 'media'"><fa icon="image"/> {{ $t('media') }}</a>
 			</div>
 		</nav>
-		<div class="body">
+		<main>
 			<template v-if="$route.name == 'user'">
 				<x-home v-if="page == 'home'" :user="user"/>
 				<mk-user-timeline v-if="page == 'notes'" :user="user" key="tl"/>
 				<mk-user-timeline v-if="page == 'media'" :user="user" :with-media="true" key="media"/>
 			</template>
 			<router-view :user="user"></router-view>
-		</div>
-	</main>
+		</main>
+	</div>
 </mk-ui>
 </template>
 
@@ -146,7 +146,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-main
+.wwtwuxyh
 	$bg = var(--face)
 
 	> .is-suspended
@@ -314,7 +314,7 @@ main
 			display flex
 			justify-content center
 			margin 0 auto
-			max-width 600px
+			max-width 616px
 
 			> a
 				display block
@@ -335,16 +335,4 @@ main
 					color var(--primary)
 					border-color var(--primary)
 
-	> .body
-		max-width 680px
-		margin 0 auto
-		padding 8px
-		color var(--text)
-
-		@media (min-width 500px)
-			padding 16px
-
-		@media (min-width 600px)
-			padding 32px
-
 </style>