diff --git a/src/client/app/common/scripts/note-subscriber.ts b/src/client/app/common/scripts/note-subscriber.ts
index d881fe01ce..5b31a9f9d0 100644
--- a/src/client/app/common/scripts/note-subscriber.ts
+++ b/src/client/app/common/scripts/note-subscriber.ts
@@ -144,8 +144,6 @@ export default prop => ({
 					break;
 				}
 			}
-
-			this.$emit(`update:${prop}`, this.$_ns_note_);
 		},
 	}
 });
diff --git a/src/client/app/common/scripts/paging.ts b/src/client/app/common/scripts/paging.ts
new file mode 100644
index 0000000000..458ee7e5ca
--- /dev/null
+++ b/src/client/app/common/scripts/paging.ts
@@ -0,0 +1,169 @@
+import Vue from 'vue';
+
+export default (opts) => ({
+	data() {
+		return {
+			items: [],
+			queue: [],
+			fetching: true,
+			moreFetching: false,
+			inited: false,
+			more: false
+		};
+	},
+
+	computed: {
+		empty(): boolean {
+			return this.items.length == 0 && !this.fetching && this.inited;
+		},
+
+		error(): boolean {
+			return !this.fetching && !this.inited;
+		}
+	},
+
+	watch: {
+		queue(x) {
+			if (opts.onQueueChanged) opts.onQueueChanged(this, x);
+		},
+
+		pagination() {
+			this.init();
+		}
+	},
+
+	created() {
+		opts.displayLimit = opts.displayLimit || 30;
+		this.init();
+	},
+
+	mounted() {
+		if (opts.captureWindowScroll) {
+			this.isScrollTop = () => {
+				return window.scrollY <= 8;
+			};
+
+			window.addEventListener('scroll', this.onWindowScroll, { passive: true });
+		}
+	},
+
+	beforeDestroy() {
+		if (opts.captureWindowScroll) {
+			window.removeEventListener('scroll', this.onWindowScroll);
+		}
+	},
+
+	methods: {
+		updateItem(i, item) {
+			Vue.set((this as any).items, i, item);
+		},
+
+		reload() {
+			this.queue = [];
+			this.items = [];
+			this.init();
+		},
+
+		async init() {
+			this.fetching = true;
+			let params = typeof this.pagination.params === 'function' ? this.pagination.params(true) : this.pagination.params;
+			if (params && params.then) params = await params;
+			await this.$root.api(this.pagination.endpoint, {
+				limit: (this.pagination.limit || 10) + 1,
+				...params
+			}).then(x => {
+				if (x.length == (this.pagination.limit || 10) + 1) {
+					x.pop();
+					this.items = x;
+					this.more = true;
+				} else {
+					this.items = x;
+					this.more = false;
+				}
+				this.inited = true;
+				this.fetching = false;
+			}, e => {
+				this.fetching = false;
+			});
+		},
+
+		async fetchMore() {
+			if (!this.more || this.moreFetching || this.items.length === 0) return;
+			this.moreFetching = true;
+			let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
+			if (params && params.then) params = await params;
+			await this.$root.api(this.pagination.endpoint, {
+				limit: (this.pagination.limit || 10) + 1,
+				untilId: this.items[this.items.length - 1].id,
+				...params
+			}).then(x => {
+				if (x.length == (this.pagination.limit || 10) + 1) {
+					x.pop();
+					this.items = this.items.concat(x);
+					this.more = true;
+				} else {
+					this.items = this.items.concat(x);
+					this.more = false;
+				}
+				this.moreFetching = false;
+			}, e => {
+				this.moreFetching = false;
+			});
+		},
+
+		prepend(item, silent = false) {
+			if (opts.onPrepend) {
+				const cancel = opts.onPrepend(this, item, silent);
+				if (cancel) return;
+			}
+
+			if (this.isScrollTop()) {
+				// Prepend the item
+				this.items.unshift(item);
+
+				// オーバーフローしたら古い投稿は捨てる
+				if (this.items.length >= opts.displayLimit) {
+					this.items = this.items.slice(0, opts.displayLimit);
+					this.more = true;
+				}
+			} else {
+				this.queue.push(item);
+			}
+		},
+
+		append(item) {
+			this.items.push(item);
+		},
+
+		releaseQueue() {
+			for (const n of this.queue) {
+				this.prepend(n, true);
+			}
+			this.queue = [];
+		},
+
+		onWindowScroll() {
+			if (this.isScrollTop()) {
+				this.onTop();
+			}
+
+			if (this.$store.state.settings.fetchOnScroll) {
+				// 親要素が display none だったら弾く
+				// https://github.com/syuilo/misskey/issues/1569
+				// http://d.hatena.ne.jp/favril/20091105/1257403319
+				if (this.$el.offsetHeight == 0) return;
+
+				const current = window.scrollY + window.innerHeight;
+				if (current > document.body.offsetHeight - 8) this.onBottom();
+			}
+		},
+
+		onTop() {
+			this.releaseQueue();
+		},
+
+		onBottom() {
+			this.fetchMore();
+		}
+	}
+});
diff --git a/src/client/app/common/views/components/user-list.vue b/src/client/app/common/views/components/user-list.vue
index 6761886b09..d6cf750016 100644
--- a/src/client/app/common/views/components/user-list.vue
+++ b/src/client/app/common/views/components/user-list.vue
@@ -2,13 +2,13 @@
 <ui-container :body-togglable="true">
 	<template #header><slot></slot></template>
 
-	<mk-error v-if="!fetching && !inited" @retry="init()"/>
+	<mk-error v-if="error" @retry="init()"/>
 
 	<div class="efvhhmdq" :class="{ iconOnly }" v-size="[{ lt: 500, class: 'narrow' }]">
-		<div class="no-users" v-if="inited && us.length == 0">
+		<div class="no-users" v-if="empty">
 			<p>{{ $t('no-users') }}</p>
 		</div>
-		<div class="user" v-for="user in us" :key="user.id">
+		<div class="user" v-for="user in users" :key="user.id">
 			<mk-avatar class="avatar" :user="user"/>
 			<div class="body" v-if="!iconOnly">
 				<div class="name">
@@ -21,8 +21,8 @@
 				<mk-follow-button class="follow-button" v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" mini/>
 			</div>
 		</div>
-		<button class="more" :class="{ fetching: fetchingMoreUsers }" v-if="cursor != null" @click="fetchMoreUsers()" :disabled="fetchingMoreUsers">
-			<template v-if="fetchingMoreUsers"><fa icon="spinner" pulse fixed-width/></template>{{ fetchingMoreUsers ? $t('@.loading') : $t('@.load-more') }}
+		<button class="more" :class="{ fetching: moreFetching }" v-if="more" @click="fetchMoreUsers()" :disabled="moreFetching">
+			<template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template>{{ moreFetching ? $t('@.loading') : $t('@.load-more') }}
 		</button>
 	</div>
 </ui-container>
@@ -31,60 +31,31 @@
 <script lang="ts">
 import Vue from 'vue';
 import i18n from '../../../i18n';
+import paging from '../../../common/scripts/paging';
 
 export default Vue.extend({
 	i18n: i18n('common/views/components/user-list.vue'),
 
+	mixins: [
+		paging({}),
+	],
+
 	props: {
-		makePromise: {
+		pagination: {
 			required: true
 		},
+		extract: {
+			required: false
+		},
 		iconOnly: {
 			type: Boolean,
 			default: false
 		}
 	},
 
-	data() {
-		return {
-			fetching: true,
-			fetchingMoreUsers: false,
-			us: [],
-			inited: false,
-			more: false
-		};
-	},
-
-	created() {
-		this.init();
-	},
-
-	methods: {
-		async init() {
-			this.fetching = true;
-			await (this.makePromise()).then(x => {
-				if (Array.isArray(x)) {
-					this.us = x;
-				} else {
-					this.us = x.users;
-					this.cursor = x.cursor;
-				}
-				this.inited = true;
-				this.fetching = false;
-			}, e => {
-				this.fetching = false;
-			});
-		},
-
-		async fetchMoreUsers() {
-			this.fetchingMoreUsers = true;
-			await (this.makePromise(this.cursor)).then(x => {
-				this.us = this.us.concat(x.users);
-				this.cursor = x.cursor;
-				this.fetchingMoreUsers = false;
-			}, e => {
-				this.fetchingMoreUsers = false;
-			});
+	computed: {
+		users() {
+			return this.extract ? this.extract(this.items) : this.items;
 		}
 	}
 });
diff --git a/src/client/app/common/views/deck/deck.direct.vue b/src/client/app/common/views/deck/deck.direct.vue
index 29db5cb7f3..24d61be494 100644
--- a/src/client/app/common/views/deck/deck.direct.vue
+++ b/src/client/app/common/views/deck/deck.direct.vue
@@ -1,13 +1,11 @@
 <template>
-<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
+<x-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
 import XNotes from './deck.notes.vue';
 
-const fetchLimit = 10;
-
 export default Vue.extend({
 	components: {
 		XNotes
@@ -16,27 +14,16 @@ export default Vue.extend({
 	data() {
 		return {
 			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,
-						more: true
-					};
-				} else {
-					return {
-						notes: notes,
-						more: false
-					};
+			pagination: {
+				endpoint: 'notes/mentions',
+				limit: 10,
+				params: {
+					includeMyRenotes: this.$store.state.settings.showMyRenotes,
+					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+					includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
+					visibility: 'specified'
 				}
-			})
+			}
 		};
 	},
 
diff --git a/src/client/app/common/views/deck/deck.favorites-column.vue b/src/client/app/common/views/deck/deck.favorites-column.vue
deleted file mode 100644
index 526b998f87..0000000000
--- a/src/client/app/common/views/deck/deck.favorites-column.vue
+++ /dev/null
@@ -1,58 +0,0 @@
-<template>
-<x-column>
-	<template #header>
-		<fa :icon="['fa', 'star']"/>{{ $t('@.favorites') }}
-	</template>
-
-	<div>
-		<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
-	</div>
-</x-column>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-import i18n from '../../../i18n';
-import XColumn from './deck.column.vue';
-import XNotes from './deck.notes.vue';
-
-const fetchLimit = 10;
-
-export default Vue.extend({
-	i18n: i18n(),
-
-	components: {
-		XColumn,
-		XNotes,
-	},
-
-	data() {
-		return {
-			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,
-						more: true
-					};
-				} else {
-					return {
-						notes: notes,
-						more: false
-					};
-				}
-			})
-		};
-	},
-
-	methods: {
-		focus() {
-			this.$refs.timeline.focus();
-		}
-	}
-});
-</script>
diff --git a/src/client/app/common/views/deck/deck.featured-column.vue b/src/client/app/common/views/deck/deck.featured-column.vue
deleted file mode 100644
index d2c44e74ce..0000000000
--- a/src/client/app/common/views/deck/deck.featured-column.vue
+++ /dev/null
@@ -1,46 +0,0 @@
-<template>
-<x-column>
-	<template #header>
-		<fa :icon="faNewspaper"/>{{ $t('@.featured-notes') }}
-	</template>
-
-	<div>
-		<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
-	</div>
-</x-column>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-import i18n from '../../../i18n';
-import XColumn from './deck.column.vue';
-import XNotes from './deck.notes.vue';
-import { faNewspaper } from '@fortawesome/free-solid-svg-icons';
-
-export default Vue.extend({
-	i18n: i18n(),
-
-	components: {
-		XColumn,
-		XNotes,
-	},
-
-	data() {
-		return {
-			faNewspaper,
-			makePromise: cursor => this.$root.api('notes/featured', {
-				limit: 30,
-			}).then(notes => {
-				notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
-				return notes;
-			})
-		};
-	},
-
-	methods: {
-		focus() {
-			this.$refs.timeline.focus();
-		}
-	}
-});
-</script>
diff --git a/src/client/app/common/views/deck/deck.hashtag-tl.vue b/src/client/app/common/views/deck/deck.hashtag-tl.vue
index 6f89f6a23d..94d2efc430 100644
--- a/src/client/app/common/views/deck/deck.hashtag-tl.vue
+++ b/src/client/app/common/views/deck/deck.hashtag-tl.vue
@@ -1,13 +1,11 @@
 <template>
-<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
+<x-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
 import XNotes from './deck.notes.vue';
 
-const fetchLimit = 10;
-
 export default Vue.extend({
 	components: {
 		XNotes
@@ -28,28 +26,18 @@ export default Vue.extend({
 	data() {
 		return {
 			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,
-						more: true
-					};
-				} else {
-					return {
-						notes: notes,
-						more: false
-					};
-				}
-			})
+			pagination: {
+				endpoint: 'notes/search-by-tag',
+				limit: 10,
+				params: init => ({
+					untilDate: init ? undefined : (this.date ? this.date.getTime() : 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
+				})
+			}
 		};
 	},
 
diff --git a/src/client/app/common/views/deck/deck.list-tl.vue b/src/client/app/common/views/deck/deck.list-tl.vue
index 24080ad4ea..26d6ea9d58 100644
--- a/src/client/app/common/views/deck/deck.list-tl.vue
+++ b/src/client/app/common/views/deck/deck.list-tl.vue
@@ -1,13 +1,11 @@
 <template>
-<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
+<x-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
 import XNotes from './deck.notes.vue';
 
-const fetchLimit = 10;
-
 export default Vue.extend({
 	components: {
 		XNotes
@@ -28,28 +26,18 @@ export default Vue.extend({
 	data() {
 		return {
 			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,
-						more: true
-					};
-				} else {
-					return {
-						notes: notes,
-						more: false
-					};
-				}
-			})
+			pagination: {
+				endpoint: 'notes/user-list-timeline',
+				limit: 10,
+				params: init => ({
+					listId: this.list.id,
+					untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
+					withFiles: this.mediaOnly,
+					includeMyRenotes: this.$store.state.settings.showMyRenotes,
+					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
+				})
+			}
 		};
 	},
 
diff --git a/src/client/app/common/views/deck/deck.mentions.vue b/src/client/app/common/views/deck/deck.mentions.vue
index 153b4cd052..8b65bdfd2d 100644
--- a/src/client/app/common/views/deck/deck.mentions.vue
+++ b/src/client/app/common/views/deck/deck.mentions.vue
@@ -1,13 +1,11 @@
 <template>
-<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
+<x-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
 import XNotes from './deck.notes.vue';
 
-const fetchLimit = 10;
-
 export default Vue.extend({
 	components: {
 		XNotes
@@ -16,26 +14,16 @@ export default Vue.extend({
 	data() {
 		return {
 			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,
-						more: true
-					};
-				} else {
-					return {
-						notes: notes,
-						more: false
-					};
-				}
-			})
+			pagination: {
+				endpoint: 'notes/mentions',
+				limit: 10,
+				params: init => ({
+					untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
+					includeMyRenotes: this.$store.state.settings.showMyRenotes,
+					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
+				})
+			}
 		};
 	},
 
diff --git a/src/client/app/common/views/deck/deck.notes.vue b/src/client/app/common/views/deck/deck.notes.vue
index 680b44bc81..06c809c026 100644
--- a/src/client/app/common/views/deck/deck.notes.vue
+++ b/src/client/app/common/views/deck/deck.notes.vue
@@ -1,8 +1,8 @@
 <template>
 <div class="eamppglmnmimdhrlzhplwpvyeaqmmhxu">
-	<div class="empty" v-if="notes.length == 0 && !fetching && inited">{{ $t('@.no-notes') }}</div>
+	<div class="empty" v-if="empty">{{ $t('@.no-notes') }}</div>
 
-	<mk-error v-if="!fetching && !inited" @retry="init()"/>
+	<mk-error v-if="error" @retry="init()"/>
 
 	<div class="placeholder" v-if="fetching">
 		<template v-for="i in 10">
@@ -16,7 +16,6 @@
 			<mk-note
 				:note="note"
 				:key="note.id"
-				@update:note="onNoteUpdated(i, $event)"
 				:compact="true"
 			/>
 			<p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date">
@@ -39,33 +38,47 @@
 import Vue from 'vue';
 import i18n from '../../../i18n';
 import shouldMuteNote from '../../../common/scripts/should-mute-note';
-
-const displayLimit = 20;
+import paging from '../../../common/scripts/paging';
 
 export default Vue.extend({
 	i18n: i18n(),
 
 	inject: ['column', 'isScrollTop', 'count'],
 
+	mixins: [
+		paging({
+			displayLimit: 20,
+
+			onQueueChanged: (self, q) => {
+				self.count(q.length);
+			},
+
+			onPrepend: (self, note, silent) => {
+				// 弾く
+				if (shouldMuteNote(self.$store.state.i, self.$store.state.settings, note)) return false;
+
+				// タブが非表示またはスクロール位置が最上部ではないならタイトルで通知
+				if (document.hidden || !self.isScrollTop()) {
+					self.$store.commit('pushBehindNote', note);
+				}
+			}
+		}),
+	],
+
 	props: {
-		makePromise: {
+		pagination: {
 			required: true
+		},
+		extract: {
+			required: false
 		}
 	},
 
-	data() {
-		return {
-			rootEl: null,
-			notes: [],
-			queue: [],
-			fetching: true,
-			moreFetching: false,
-			inited: false,
-			more: false
-		};
-	},
-
 	computed: {
+		notes() {
+			return this.extract ? this.extract(this.items) : this.items;
+		},
+
 		_notes(): any[] {
 			return (this.notes as any).map(note => {
 				const date = new Date(note.createdAt).getDate();
@@ -77,15 +90,6 @@ export default Vue.extend({
 		}
 	},
 
-	watch: {
-		queue(q) {
-			this.count(q.length);
-		},
-		makePromise() {
-			this.init();
-		}
-	},
-
 	created() {
 		this.column.$on('top', this.onTop);
 		this.column.$on('bottom', this.onBottom);
@@ -101,87 +105,6 @@ export default Vue.extend({
 		focus() {
 			(this.$refs.notes as any).children[0].focus ? (this.$refs.notes as any).children[0].focus() : (this.$refs.notes as any).$el.children[0].focus();
 		},
-
-		onNoteUpdated(i, note) {
-			Vue.set((this as any).notes, i, note);
-		},
-
-		reload() {
-			this.init();
-		},
-
-		async init() {
-			this.queue = [];
-			this.notes = [];
-			this.fetching = true;
-			await (this.makePromise()).then(x => {
-				if (Array.isArray(x)) {
-					this.notes = x;
-				} else {
-					this.notes = x.notes;
-					this.more = x.more;
-				}
-				this.inited = true;
-				this.fetching = false;
-				this.$emit('inited');
-			}, e => {
-				this.fetching = false;
-			});
-		},
-
-		async fetchMore() {
-			if (!this.more || this.moreFetching) return;
-			this.moreFetching = true;
-			await (this.makePromise(this.notes[this.notes.length - 1].id)).then(x => {
-				this.notes = this.notes.concat(x.notes);
-				this.more = x.more;
-				this.moreFetching = false;
-			}, e => {
-				this.moreFetching = false;
-			});
-		},
-
-		prepend(note, silent = false) {
-			// 弾く
-			if (shouldMuteNote(this.$store.state.i, this.$store.state.settings, note)) return;
-
-			// タブが非表示ならタイトルで通知
-			if (document.hidden) {
-				this.$store.commit('pushBehindNote', note);
-			}
-
-			if (this.isScrollTop()) {
-				// Prepend the note
-				this.notes.unshift(note);
-
-				// オーバーフローしたら古い投稿は捨てる
-				if (this.notes.length >= displayLimit) {
-					this.notes = this.notes.slice(0, displayLimit);
-					this.more = true;
-				}
-			} else {
-				this.queue.push(note);
-			}
-		},
-
-		append(note) {
-			this.notes.push(note);
-		},
-
-		releaseQueue() {
-			for (const n of this.queue) {
-				this.prepend(n, true);
-			}
-			this.queue = [];
-		},
-
-		onTop() {
-			this.releaseQueue();
-		},
-
-		onBottom() {
-			this.fetchMore();
-		}
 	}
 });
 </script>
diff --git a/src/client/app/common/views/deck/deck.notification.vue b/src/client/app/common/views/deck/deck.notification.vue
index 3ced7b7e23..8a21bedb91 100644
--- a/src/client/app/common/views/deck/deck.notification.vue
+++ b/src/client/app/common/views/deck/deck.notification.vue
@@ -81,15 +81,15 @@
 	</div>
 
 	<template v-if="notification.type == 'quote'">
-		<mk-note :note="notification.note" @update:note="onNoteUpdated"/>
+		<mk-note :note="notification.note"/>
 	</template>
 
 	<template v-if="notification.type == 'reply'">
-		<mk-note :note="notification.note" @update:note="onNoteUpdated"/>
+		<mk-note :note="notification.note"/>
 	</template>
 
 	<template v-if="notification.type == 'mention'">
-		<mk-note :note="notification.note" @update:note="onNoteUpdated"/>
+		<mk-note :note="notification.note"/>
 	</template>
 </div>
 </template>
@@ -105,17 +105,6 @@ export default Vue.extend({
 			getNoteSummary
 		};
 	},
-	methods: {
-		onNoteUpdated(note) {
-			switch (this.notification.type) {
-				case 'quote':
-				case 'reply':
-				case 'mention':
-					Vue.set(this.notification, 'note', note);
-					break;
-			}
-		}
-	}
 });
 </script>
 
diff --git a/src/client/app/common/views/deck/deck.search-column.vue b/src/client/app/common/views/deck/deck.search-column.vue
index 17ee2ef454..a2d1142fbe 100644
--- a/src/client/app/common/views/deck/deck.search-column.vue
+++ b/src/client/app/common/views/deck/deck.search-column.vue
@@ -5,7 +5,7 @@
 	</template>
 
 	<div>
-		<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
+		<x-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/>
 	</div>
 </x-column>
 </template>
@@ -16,8 +16,6 @@ import XColumn from './deck.column.vue';
 import XNotes from './deck.notes.vue';
 import { genSearchQuery } from '../../../common/scripts/gen-search-query';
 
-const limit = 20;
-
 export default Vue.extend({
 	components: {
 		XColumn,
@@ -26,24 +24,11 @@ export default Vue.extend({
 
 	data() {
 		return {
-			makePromise: async cursor => this.$root.api('notes/search', {
-				limit: limit + 1,
-				offset: cursor ? cursor : undefined,
-				...(await genSearchQuery(this, this.q))
-			}).then(notes => {
-				if (notes.length == limit + 1) {
-					notes.pop();
-					return {
-						notes: notes,
-						cursor: cursor ? cursor + limit : limit
-					};
-				} else {
-					return {
-						notes: notes,
-						more: false
-					};
-				}
-			})
+			pagination: {
+				endpoint: 'notes/search',
+				limit: 20,
+				params: () => genSearchQuery(this, this.q)
+			}
 		};
 	},
 
diff --git a/src/client/app/common/views/deck/deck.tl.vue b/src/client/app/common/views/deck/deck.tl.vue
index 9284f06ee1..e6c716070a 100644
--- a/src/client/app/common/views/deck/deck.tl.vue
+++ b/src/client/app/common/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" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
+<x-notes v-else ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/>
 </template>
 
 <script lang="ts">
@@ -15,8 +15,6 @@ import XNotes from './deck.notes.vue';
 import { faMinusCircle } from '@fortawesome/free-solid-svg-icons';
 import i18n from '../../../i18n';
 
-const fetchLimit = 10;
-
 export default Vue.extend({
 	i18n: i18n('deck'),
 
@@ -42,7 +40,7 @@ export default Vue.extend({
 			connection: null,
 			disabled: false,
 			faMinusCircle,
-			makePromise: null
+			pagination: null
 		};
 	},
 
@@ -73,27 +71,17 @@ export default Vue.extend({
 	},
 
 	created() {
-		this.makePromise = cursor => this.$root.api(this.endpoint, {
-			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,
-					more: true
-				};
-			} else {
-				return {
-					notes: notes,
-					more: false
-				};
-			}
-		});
+		this.pagination = {
+			endpoint: this.endpoint,
+			limit: 10,
+			params: init => ({
+				untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
+				withFiles: this.mediaOnly,
+				includeMyRenotes: this.$store.state.settings.showMyRenotes,
+				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
+			})
+		};
 	},
 
 	mounted() {
diff --git a/src/client/app/common/views/deck/deck.user-column.home.vue b/src/client/app/common/views/deck/deck.user-column.home.vue
index 43a67db1ed..ad05d6548b 100644
--- a/src/client/app/common/views/deck/deck.user-column.home.vue
+++ b/src/client/app/common/views/deck/deck.user-column.home.vue
@@ -30,7 +30,7 @@
 	<ui-container>
 		<template #header><fa :icon="['far', 'comment-alt']"/> {{ $t('timeline') }}</template>
 		<div>
-			<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
+			<x-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/>
 		</div>
 	</ui-container>
 </div>
@@ -43,8 +43,6 @@ import XNotes from './deck.notes.vue';
 import { concat } from '../../../../../prelude/array';
 import ApexCharts from 'apexcharts';
 
-const fetchLimit = 10;
-
 export default Vue.extend({
 	i18n: i18n('deck/deck.user-column.vue'),
 
@@ -63,49 +61,38 @@ export default Vue.extend({
 		return {
 			withFiles: false,
 			images: [],
-			makePromise: null,
 			chart: null as ApexCharts
 		};
 	},
 
+	computed: {
+		pagination() {
+			return {
+				endpoint: 'users/notes',
+				limit: 10,
+				params: init => ({
+					userId: this.user.id,
+					untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
+					withFiles: this.withFiles,
+					includeMyRenotes: this.$store.state.settings.showMyRenotes,
+					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
+				})
+			}
+		}
+	},
+
 	watch: {
 		user() {
 			this.fetch();
-			this.genPromiseMaker();
 		}
 	},
 
 	created() {
 		this.fetch();
-		this.genPromiseMaker();
 	},
 
 	methods: {
-		genPromiseMaker() {
-			this.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,
-						more: true
-					};
-				} else {
-					return {
-						notes: notes,
-						more: false
-					};
-				}
-			});
-		},
-
 		fetch() {
 			const image = [
 				'image/jpeg',
diff --git a/src/client/app/common/views/pages/explore.vue b/src/client/app/common/views/pages/explore.vue
index bf0d7ab574..f28760e516 100644
--- a/src/client/app/common/views/pages/explore.vue
+++ b/src/client/app/common/views/pages/explore.vue
@@ -18,24 +18,24 @@
 		</div>
 	</ui-container>
 
-	<mk-user-list v-if="tag != null" :make-promise="tagUsers" :key="`${tag}-local`">
+	<mk-user-list v-if="tag != null" :pagination="tagUsers" :key="`${tag}-local`">
 		<fa :icon="faHashtag" fixed-width/>{{ tag }}
 	</mk-user-list>
-	<mk-user-list v-if="tag != null" :make-promise="tagRemoteUsers" :key="`${tag}-remote`">
+	<mk-user-list v-if="tag != null" :pagination="tagRemoteUsers" :key="`${tag}-remote`">
 		<fa :icon="faHashtag" fixed-width/>{{ tag }} ({{ $t('federated') }})
 	</mk-user-list>
 
 	<template v-if="tag == null">
-		<mk-user-list :make-promise="pinnedUsers">
+		<mk-user-list :pagination="pinnedUsers">
 			<fa :icon="faBookmark" fixed-width/>{{ $t('pinned-users') }}
 		</mk-user-list>
-		<mk-user-list :make-promise="popularUsers">
+		<mk-user-list :pagination="popularUsers">
 			<fa :icon="faChartLine" fixed-width/>{{ $t('popular-users') }}
 		</mk-user-list>
-		<mk-user-list :make-promise="recentlyUpdatedUsers">
+		<mk-user-list :pagination="recentlyUpdatedUsers">
 			<fa :icon="faCommentAlt" fixed-width/>{{ $t('recently-updated-users') }}
 		</mk-user-list>
-		<mk-user-list :make-promise="recentlyRegisteredUsers">
+		<mk-user-list :pagination="recentlyRegisteredUsers">
 			<fa :icon="faPlus" fixed-width/>{{ $t('recently-registered-users') }}
 		</mk-user-list>
 	</template>
@@ -60,24 +60,21 @@ export default Vue.extend({
 
 	data() {
 		return {
-			pinnedUsers: () => this.$root.api('pinned-users'),
-			popularUsers: () => this.$root.api('users', {
+			pinnedUsers: { endpoint: 'pinned-users' },
+			popularUsers: { endpoint: 'users', limit: 10, params: {
 				state: 'alive',
 				origin: 'local',
 				sort: '+follower',
-				limit: 10
-			}),
-			recentlyUpdatedUsers: () => this.$root.api('users', {
+			} },
+			recentlyUpdatedUsers: { endpoint: 'users', limit: 10, params: {
 				origin: 'local',
 				sort: '+updatedAt',
-				limit: 10
-			}),
-			recentlyRegisteredUsers: () => this.$root.api('users', {
+			} },
+			recentlyRegisteredUsers: { endpoint: 'users', limit: 10, params: {
 				origin: 'local',
 				state: 'alive',
 				sort: '+createdAt',
-				limit: 10
-			}),
+			} },
 			tagsLocal: [],
 			tagsRemote: [],
 			stats: null,
@@ -88,24 +85,30 @@ export default Vue.extend({
 	},
 
 	computed: {
-		tagUsers(): () => Promise<any> {
-			return () => this.$root.api('hashtags/users', {
-				tag: this.tag,
-				state: 'alive',
-				origin: 'local',
-				sort: '+follower',
-				limit: 30
-			});
+		tagUsers(): any {
+			return {
+				endpoint: 'hashtags/users',
+				limit: 30,
+				params: {
+					tag: this.tag,
+					state: 'alive',
+					origin: 'local',
+					sort: '+follower',
+				}
+			};
 		},
 
-		tagRemoteUsers(): () => Promise<any> {
-			return () => this.$root.api('hashtags/users', {
-				tag: this.tag,
-				state: 'alive',
-				origin: 'remote',
-				sort: '+follower',
-				limit: 30
-			});
+		tagRemoteUsers(): any {
+			return {
+				endpoint: 'hashtags/users',
+				limit: 30,
+				params: {
+					tag: this.tag,
+					state: 'alive',
+					origin: 'remote',
+					sort: '+follower',
+				}
+			};
 		},
 	},
 
diff --git a/src/client/app/common/views/pages/favorites.vue b/src/client/app/common/views/pages/favorites.vue
new file mode 100644
index 0000000000..36403dde52
--- /dev/null
+++ b/src/client/app/common/views/pages/favorites.vue
@@ -0,0 +1,44 @@
+<template>
+<div>
+	<component :is="notesComponent" :pagination="pagination" :extract="items => items.map(item => item.note)"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faStar } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../../i18n';
+//import Progress from '../../../common/scripts/loading';
+
+export default Vue.extend({
+	i18n: i18n(),
+
+	props: {
+		platform: {
+			type: String,
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			pagination: {
+				endpoint: 'i/favorites',
+				limit: 10,
+			},
+
+			notesComponent:
+				this.platform === 'desktop' ? () => import('../../../desktop/views/components/detail-notes.vue').then(m => m.default) :
+				this.platform === 'mobile' ? () => import('../../../mobile/views/components/detail-notes.vue').then(m => m.default) :
+				this.platform === 'deck' ? () => import('../deck/deck.notes.vue').then(m => m.default) : null
+		};
+	},
+
+	created() {
+		this.$emit('init', {
+			title: this.$t('@.favorites'),
+			icon: faStar
+		});
+	},
+});
+</script>
diff --git a/src/client/app/common/views/pages/featured.vue b/src/client/app/common/views/pages/featured.vue
new file mode 100644
index 0000000000..42c97e09fc
--- /dev/null
+++ b/src/client/app/common/views/pages/featured.vue
@@ -0,0 +1,44 @@
+<template>
+<div>
+	<component :is="notesComponent" :pagination="pagination"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faNewspaper } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../../i18n';
+//import Progress from '../../../common/scripts/loading';
+
+export default Vue.extend({
+	i18n: i18n(),
+
+	props: {
+		platform: {
+			type: String,
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			pagination: {
+				endpoint: 'notes/featured',
+				limit: 30,
+			},
+
+			notesComponent:
+				this.platform === 'desktop' ? () => import('../../../desktop/views/components/detail-notes.vue').then(m => m.default) :
+				this.platform === 'mobile' ? () => import('../../../mobile/views/components/detail-notes.vue').then(m => m.default) :
+				this.platform === 'deck' ? () => import('../deck/deck.notes.vue').then(m => m.default) : null
+		};
+	},
+
+	created() {
+		this.$emit('init', {
+			title: this.$t('@.featured-notes'),
+			icon: faNewspaper
+		});
+	},
+});
+</script>
diff --git a/src/client/app/common/views/pages/followers.vue b/src/client/app/common/views/pages/followers.vue
index 1d68d71e80..b546e69ae3 100644
--- a/src/client/app/common/views/pages/followers.vue
+++ b/src/client/app/common/views/pages/followers.vue
@@ -1,7 +1,5 @@
 <template>
-<div>
-	<mk-user-list :make-promise="makePromise">{{ $t('@.followers') }}</mk-user-list>
-</div>
+<mk-user-list :pagination="pagination" :extract="items => items.map(item => item.follower)">{{ $t('@.followers') }}</mk-user-list>
 </template>
 
 <script lang="ts">
@@ -9,31 +7,18 @@ import Vue from 'vue';
 import parseAcct from '../../../../../misc/acct/parse';
 import i18n from '../../../i18n';
 
-const fetchLimit = 30;
-
 export default Vue.extend({
 	i18n: i18n(),
 
 	data() {
 		return {
-			makePromise: cursor => this.$root.api('users/followers', {
-				...parseAcct(this.$route.params.user),
-				limit: fetchLimit + 1,
-				untilId: cursor ? cursor : undefined,
-			}).then(followings => {
-				if (followings.length == fetchLimit + 1) {
-					followings.pop();
-					return {
-						users: followings.map(following => following.follower),
-						cursor: followings[followings.length - 1].id
-					};
-				} else {
-					return {
-						users: followings.map(following => following.follower),
-						more: false
-					};
+			pagination: {
+				endpoint: 'users/followers',
+				limit: 30,
+				params: {
+					...parseAcct(this.$route.params.user),
 				}
-			}),
+			},
 		};
 	},
 });
diff --git a/src/client/app/common/views/pages/following.vue b/src/client/app/common/views/pages/following.vue
index b65d335314..4e584c19d9 100644
--- a/src/client/app/common/views/pages/following.vue
+++ b/src/client/app/common/views/pages/following.vue
@@ -1,7 +1,5 @@
 <template>
-<div>
-	<mk-user-list :make-promise="makePromise">{{ $t('@.following') }}</mk-user-list>
-</div>
+<mk-user-list :pagination="pagination" :extract="items => items.map(item => item.followee)">{{ $t('@.following') }}</mk-user-list>
 </template>
 
 <script lang="ts">
@@ -9,31 +7,18 @@ import Vue from 'vue';
 import parseAcct from '../../../../../misc/acct/parse';
 import i18n from '../../../i18n';
 
-const fetchLimit = 30;
-
 export default Vue.extend({
 	i18n: i18n(),
 
 	data() {
 		return {
-			makePromise: cursor => this.$root.api('users/following', {
-				...parseAcct(this.$route.params.user),
-				limit: fetchLimit + 1,
-				untilId: cursor ? cursor : undefined,
-			}).then(followings => {
-				if (followings.length == fetchLimit + 1) {
-					followings.pop();
-					return {
-						users: followings.map(following => following.followee),
-						cursor: followings[followings.length - 1].id
-					};
-				} else {
-					return {
-						users: followings.map(following => following.followee),
-						more: false
-					};
+			pagination: {
+				endpoint: 'users/following',
+				limit: 30,
+				params: {
+					...parseAcct(this.$route.params.user),
 				}
-			}),
+			},
 		};
 	},
 });
diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts
index c6479f477c..eab400d4c6 100644
--- a/src/client/app/desktop/script.ts
+++ b/src/client/app/desktop/script.ts
@@ -138,10 +138,10 @@ init(async (launch, os) => {
 					{ path: '/notes/:note', name: 'note', component: () => import('../common/views/deck/deck.note-column.vue').then(m => m.default) },
 					{ path: '/search', component: () => import('../common/views/deck/deck.search-column.vue').then(m => m.default) },
 					{ path: '/tags/:tag', name: 'tag', component: () => import('../common/views/deck/deck.hashtag-column.vue').then(m => m.default) },
-					{ path: '/featured', name: 'featured', component: () => import('../common/views/deck/deck.featured-column.vue').then(m => m.default) },
+					{ path: '/featured', name: 'featured', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/featured.vue').then(m => m.default), platform: 'deck' }) },
 					{ path: '/explore', name: 'explore', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default) }) },
 					{ path: '/explore/tags/:tag', name: 'explore-tag', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default), tag: route.params.tag }) },
-					{ path: '/i/favorites', component: () => import('../common/views/deck/deck.favorites-column.vue').then(m => m.default) },
+					{ path: '/i/favorites', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/favorites.vue').then(m => m.default), platform: 'deck' }) },
 					{ path: '/i/pages', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/pages.vue').then(m => m.default) }) },
 					{ path: '/i/lists', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-lists.vue').then(m => m.default) }) },
 					{ path: '/i/lists/:listId', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-list-editor.vue').then(m => m.default), listId: route.params.listId }) },
@@ -158,10 +158,10 @@ init(async (launch, os) => {
 					{ path: '/notes/:note', name: 'note', component: () => import('./views/home/note.vue').then(m => m.default) },
 					{ path: '/search', component: () => import('./views/home/search.vue').then(m => m.default) },
 					{ path: '/tags/:tag', name: 'tag', component: () => import('./views/home/tag.vue').then(m => m.default) },
-					{ path: '/featured', name: 'featured', component: () => import('./views/home/featured.vue').then(m => m.default) },
+					{ path: '/featured', name: 'featured', component: () => import('../common/views/pages/featured.vue').then(m => m.default), props: { platform: 'desktop' } },
 					{ path: '/explore', name: 'explore', component: () => import('../common/views/pages/explore.vue').then(m => m.default) },
 					{ path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('../common/views/pages/explore.vue').then(m => m.default) },
-					{ path: '/i/favorites', component: () => import('./views/home/favorites.vue').then(m => m.default) },
+					{ path: '/i/favorites', component: () => import('../common/views/pages/favorites.vue').then(m => m.default), props: { platform: 'desktop' } },
 					{ path: '/i/pages', component: () => import('../common/views/pages/pages.vue').then(m => m.default) },
 					{ path: '/i/lists', component: () => import('../common/views/pages/user-lists.vue').then(m => m.default) },
 					{ path: '/i/lists/:listId', props: true, component: () => import('../common/views/pages/user-list-editor.vue').then(m => m.default) },
diff --git a/src/client/app/desktop/views/components/detail-notes.vue b/src/client/app/desktop/views/components/detail-notes.vue
new file mode 100644
index 0000000000..e50dda7c6f
--- /dev/null
+++ b/src/client/app/desktop/views/components/detail-notes.vue
@@ -0,0 +1,56 @@
+<template>
+<div class="ecsvsegy" v-if="!fetching">
+	<sequential-entrance animation="entranceFromTop" delay="25">
+		<template v-for="note in notes">
+			<mk-note-detail class="post" :note="note" :key="note.id"/>
+		</template>
+	</sequential-entrance>
+	<div class="more" v-if="more">
+		<ui-button inline @click="fetchMore()">{{ $t('@.load-more') }}</ui-button>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import i18n from '../../../i18n';
+import paging from '../../../common/scripts/paging';
+
+export default Vue.extend({
+	i18n: i18n(),
+
+	mixins: [
+		paging({
+			captureWindowScroll: true,
+		}),
+	],
+
+	props: {
+		pagination: {
+			required: true
+		},
+		extract: {
+			required: false
+		}
+	},
+
+	computed: {
+		notes() {
+			return this.extract ? this.extract(this.items) : this.items;
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.ecsvsegy
+	margin 0 auto
+
+	> * > .post
+		margin-bottom 16px
+
+	> .more
+		margin 32px 16px 16px 16px
+		text-align center
+
+</style>
diff --git a/src/client/app/desktop/views/components/notes.vue b/src/client/app/desktop/views/components/notes.vue
index b051ff51cf..cb8b9c3ce3 100644
--- a/src/client/app/desktop/views/components/notes.vue
+++ b/src/client/app/desktop/views/components/notes.vue
@@ -4,9 +4,9 @@
 
 	<div class="newer-indicator" :style="{ top: $store.state.uiHeaderHeight + 'px' }" v-show="queue.length > 0"></div>
 
-	<div class="empty" v-if="notes.length == 0 && !fetching && inited">{{ $t('@.no-notes') }}</div>
+	<div class="empty" v-if="empty">{{ $t('@.no-notes') }}</div>
 
-	<mk-error v-if="!fetching && !inited" @retry="init()"/>
+	<mk-error v-if="error" @retry="init()"/>
 
 	<div class="placeholder" v-if="fetching">
 		<template v-for="i in 10">
@@ -17,8 +17,8 @@
 	<!-- トランジションを有効にするとなぜかメモリリークする -->
 	<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="notes transition" tag="div" ref="notes">
 		<template v-for="(note, i) in _notes">
-			<mk-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)" :compact="true" ref="note"/>
-			<p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date">
+			<mk-note :note="note" :key="note.id" :compact="true" ref="note"/>
+			<p class="date" :key="note.id + '_date'" v-if="i != items.length - 1 && note._date != _notes[i + 1]._date">
 				<span><fa icon="angle-up"/>{{ note._datetext }}</span>
 				<span><fa icon="angle-down"/>{{ _notes[i + 1]._datetext }}</span>
 			</p>
@@ -39,152 +39,66 @@ import Vue from 'vue';
 import i18n from '../../../i18n';
 import * as config from '../../../config';
 import shouldMuteNote from '../../../common/scripts/should-mute-note';
-
-const displayLimit = 30;
+import paging from '../../../common/scripts/paging';
 
 export default Vue.extend({
 	i18n: i18n(),
 
-	props: {
-		makePromise: {
-			required: true
-		}
-	},
+	mixins: [
+		paging({
+			captureWindowScroll: true,
 
-	data() {
-		return {
-			notes: [],
-			queue: [],
-			fetching: true,
-			moreFetching: false,
-			inited: false,
-			more: false
-		};
+			onQueueChanged: (self, x) => {
+				if (x.length > 0) {
+					self.$store.commit('indicate', true);
+				} else {
+					self.$store.commit('indicate', false);
+				}
+			},
+
+			onPrepend: (self, note, silent) => {
+				// 弾く
+				if (shouldMuteNote(self.$store.state.i, self.$store.state.settings, note)) return false;
+
+				// タブが非表示またはスクロール位置が最上部ではないならタイトルで通知
+				if (document.hidden || !self.isScrollTop()) {
+					self.$store.commit('pushBehindNote', note);
+				}
+
+				if (self.isScrollTop()) {
+					// サウンドを再生する
+					if (self.$store.state.device.enableSounds && !silent) {
+						const sound = new Audio(`${config.url}/assets/post.mp3`);
+						sound.volume = self.$store.state.device.soundVolume;
+						sound.play();
+					}
+				}
+			}
+		}),
+	],
+
+	props: {
+		pagination: {
+			required: true
+		},
 	},
 
 	computed: {
 		_notes(): any[] {
-			return (this.notes as any).map(note => {
-				const date = new Date(note.createdAt).getDate();
-				const month = new Date(note.createdAt).getMonth() + 1;
-				note._date = date;
-				note._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString());
-				return note;
+			return (this.items as any).map(item => {
+				const date = new Date(item.createdAt).getDate();
+				const month = new Date(item.createdAt).getMonth() + 1;
+				item._date = date;
+				item._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString());
+				return item;
 			});
 		}
 	},
 
-	created() {
-		this.init();
-	},
-
-	mounted() {
-		window.addEventListener('scroll', this.onScroll, { passive: true });
-	},
-
-	beforeDestroy() {
-		window.removeEventListener('scroll', this.onScroll);
-	},
-
 	methods: {
-		isScrollTop() {
-			return window.scrollY <= 8;
-		},
-
 		focus() {
 			(this.$refs.notes as any).children[0].focus ? (this.$refs.notes as any).children[0].focus() : (this.$refs.notes as any).$el.children[0].focus();
 		},
-
-		onNoteUpdated(i, note) {
-			Vue.set((this as any).notes, i, note);
-		},
-
-		reload() {
-			this.queue = [];
-			this.notes = [];
-			this.init();
-		},
-
-		async init() {
-			this.fetching = true;
-			await (this.makePromise()).then(x => {
-				if (Array.isArray(x)) {
-					this.notes = x;
-				} else {
-					this.notes = x.notes;
-					this.more = x.more;
-				}
-				this.inited = true;
-				this.fetching = false;
-				this.$emit('inited');
-			}, e => {
-				this.fetching = false;
-			});
-		},
-
-		async fetchMore() {
-			if (!this.more || this.moreFetching || this.notes.length === 0) return;
-			this.moreFetching = true;
-			this.makePromise(this.notes[this.notes.length - 1].id).then(x => {
-				this.notes = this.notes.concat(x.notes);
-				this.more = x.more;
-				this.moreFetching = false;
-			}, e => {
-				this.moreFetching = false;
-			});
-		},
-
-		prepend(note, silent = false) {
-			// 弾く
-			if (shouldMuteNote(this.$store.state.i, this.$store.state.settings, note)) return;
-
-			// タブが非表示またはスクロール位置が最上部ではないならタイトルで通知
-			if (document.hidden || !this.isScrollTop()) {
-				this.$store.commit('pushBehindNote', note);
-			}
-
-			if (this.isScrollTop()) {
-				// Prepend the note
-				this.notes.unshift(note);
-
-				// サウンドを再生する
-				if (this.$store.state.device.enableSounds && !silent) {
-					const sound = new Audio(`${config.url}/assets/post.mp3`);
-					sound.volume = this.$store.state.device.soundVolume;
-					sound.play();
-				}
-
-				// オーバーフローしたら古い投稿は捨てる
-				if (this.notes.length >= displayLimit) {
-					this.notes = this.notes.slice(0, displayLimit);
-					this.more = true;
-				}
-			} else {
-				this.queue.push(note);
-			}
-		},
-
-		append(note) {
-			this.notes.push(note);
-		},
-
-		releaseQueue() {
-			for (const n of this.queue) {
-				this.prepend(n, true);
-			}
-			this.queue = [];
-		},
-
-		onScroll() {
-			if (this.isScrollTop()) {
-				this.releaseQueue();
-			}
-
-			if (this.$store.state.settings.fetchOnScroll) {
-				const current = window.scrollY + window.innerHeight;
-				if (current > document.body.offsetHeight - 8) this.fetchMore();
-			}
-		}
 	}
 });
 </script>
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 4437b838f2..dae282ec5c 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,6 @@
 <template>
 <div>
-	<mk-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')">
+	<mk-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')">
 		<template #header>
 			<slot></slot>
 		</template>
@@ -11,36 +11,23 @@
 <script lang="ts">
 import Vue from 'vue';
 
-const fetchLimit = 10;
-
 export default Vue.extend({
 	props: ['list'],
 	data() {
 		return {
 			connection: null,
 			date: null,
-			makePromise: cursor => this.$root.api('notes/user-list-timeline', {
-				listId: this.list.id,
-				limit: fetchLimit + 1,
-				untilId: cursor ? cursor : undefined,
-				untilDate: cursor ? undefined : (this.date ? this.date.getTime() : 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,
-						more: true
-					};
-				} else {
-					return {
-						notes: notes,
-						more: false
-					};
-				}
-			})
+			pagination: {
+				endpoint: 'notes/user-list-timeline',
+				limit: 10,
+				params: init => ({
+					listId: this.list.id,
+					untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
+					includeMyRenotes: this.$store.state.settings.showMyRenotes,
+					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
+				})
+			}
 		};
 	},
 	watch: {
diff --git a/src/client/app/desktop/views/home/favorites.vue b/src/client/app/desktop/views/home/favorites.vue
deleted file mode 100644
index 951de97498..0000000000
--- a/src/client/app/desktop/views/home/favorites.vue
+++ /dev/null
@@ -1,83 +0,0 @@
-<template>
-<div class="ecsvsegy" v-if="!fetching">
-	<sequential-entrance animation="entranceFromTop" delay="25">
-		<template v-for="favorite in favorites">
-			<mk-note-detail class="post" :note="favorite.note" :key="favorite.note.id"/>
-		</template>
-	</sequential-entrance>
-	<div class="more" v-if="existMore">
-		<ui-button inline @click="fetchMore()">{{ $t('@.load-more') }}</ui-button>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-import i18n from '../../../i18n';
-import Progress from '../../../common/scripts/loading';
-
-export default Vue.extend({
-	i18n: i18n('.vue'),
-	data() {
-		return {
-			fetching: true,
-			favorites: [],
-			existMore: false,
-			moreFetching: false
-		};
-	},
-	created() {
-		this.fetch();
-	},
-	methods: {
-		fetch() {
-			Progress.start();
-			this.fetching = true;
-
-			this.$root.api('i/favorites', {
-				limit: 11
-			}).then(favorites => {
-				if (favorites.length == 11) {
-					this.existMore = true;
-					favorites.pop();
-				}
-
-				this.favorites = favorites;
-				this.fetching = false;
-
-				Progress.done();
-			});
-		},
-		fetchMore() {
-			this.moreFetching = true;
-			this.$root.api('i/favorites', {
-				limit: 11,
-				untilId: this.favorites[this.favorites.length - 1].id
-			}).then(favorites => {
-				if (favorites.length == 11) {
-					this.existMore = true;
-					favorites.pop();
-				} else {
-					this.existMore = false;
-				}
-
-				this.favorites = this.favorites.concat(favorites);
-				this.moreFetching = false;
-			});
-		}
-	}
-});
-</script>
-
-<style lang="stylus" scoped>
-.ecsvsegy
-	margin 0 auto
-
-	> * > .post
-		margin-bottom 16px
-
-	> .more
-		margin 32px 16px 16px 16px
-		text-align center
-
-</style>
diff --git a/src/client/app/desktop/views/home/featured.vue b/src/client/app/desktop/views/home/featured.vue
deleted file mode 100644
index 1719023289..0000000000
--- a/src/client/app/desktop/views/home/featured.vue
+++ /dev/null
@@ -1,55 +0,0 @@
-<template>
-<div class="glowckho" v-if="!fetching">
-	<sequential-entrance animation="entranceFromTop" delay="25">
-		<template v-for="note in notes">
-			<mk-note-detail class="post" :note="note" :key="note.id"/>
-		</template>
-	</sequential-entrance>
-</div>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-import Progress from '../../../common/scripts/loading';
-
-export default Vue.extend({
-	data() {
-		return {
-			fetching: true,
-			notes: [],
-		};
-	},
-	created() {
-		this.fetch();
-	},
-	methods: {
-		fetch() {
-			Progress.start();
-			this.fetching = true;
-
-			this.$root.api('notes/featured', {
-				limit: 30
-			}).then(notes => {
-				notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
-				this.notes = notes;
-				this.fetching = false;
-
-				Progress.done();
-			});
-		},
-	}
-});
-</script>
-
-<style lang="stylus" scoped>
-.glowckho
-	margin 0 auto
-
-	> * > .post
-		margin-bottom 16px
-
-	> .more
-		margin 32px 16px 16px 16px
-		text-align center
-
-</style>
diff --git a/src/client/app/desktop/views/home/search.vue b/src/client/app/desktop/views/home/search.vue
index 50c6456158..06b354b133 100644
--- a/src/client/app/desktop/views/home/search.vue
+++ b/src/client/app/desktop/views/home/search.vue
@@ -1,6 +1,6 @@
 <template>
 <div>
-	<mk-notes ref="timeline" :make-promise="makePromise" @inited="inited">
+	<mk-notes ref="timeline" :pagination="pagination" @inited="inited">
 		<template #header>
 			<header class="oxgbmvii">
 				<span><fa icon="search"/> {{ q }}</span>
@@ -16,30 +16,15 @@ import i18n from '../../../i18n';
 import Progress from '../../../common/scripts/loading';
 import { genSearchQuery } from '../../../common/scripts/gen-search-query';
 
-const limit = 20;
-
 export default Vue.extend({
 	i18n: i18n('desktop/views/pages/search.vue'),
 	data() {
 		return {
-			makePromise: async cursor => this.$root.api('notes/search', {
-				limit: limit + 1,
-				offset: cursor ? cursor : undefined,
-				...(await genSearchQuery(this, this.q))
-			}).then(notes => {
-				if (notes.length == limit + 1) {
-					notes.pop();
-					return {
-						notes: notes,
-						cursor: cursor ? cursor + limit : limit
-					};
-				} else {
-					return {
-						notes: notes,
-						more: false
-					};
-				}
-			})
+			pagination: {
+				endpoint: 'notes/search',
+				limit: 20,
+				params: () => genSearchQuery(this, this.q)
+			}
 		};
 	},
 	computed: {
diff --git a/src/client/app/desktop/views/home/tag.vue b/src/client/app/desktop/views/home/tag.vue
index 92f5a47528..b87dc1255d 100644
--- a/src/client/app/desktop/views/home/tag.vue
+++ b/src/client/app/desktop/views/home/tag.vue
@@ -1,6 +1,6 @@
 <template>
 <div>
-	<mk-notes ref="timeline" :make-promise="makePromise" @inited="inited">
+	<mk-notes ref="timeline" :pagination="pagination" @inited="inited">
 		<template #header>
 			<header class="wqraeznr">
 				<span><fa icon="hashtag"/> {{ $route.params.tag }}</span>
@@ -15,30 +15,17 @@ import Vue from 'vue';
 import i18n from '../../../i18n';
 import Progress from '../../../common/scripts/loading';
 
-const limit = 20;
-
 export default Vue.extend({
 	i18n: i18n('desktop/views/pages/tag.vue'),
 	data() {
 		return {
-			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,
-						more: false
-					};
+			pagination: {
+				endpoint: 'notes/search-by-tag',
+				limit: 20,
+				params: {
+					tag: this.$route.params.tag
 				}
-			})
+			}
 		};
 	},
 	watch: {
diff --git a/src/client/app/desktop/views/home/timeline.core.vue b/src/client/app/desktop/views/home/timeline.core.vue
index 654a1cc434..aae7dbc60e 100644
--- a/src/client/app/desktop/views/home/timeline.core.vue
+++ b/src/client/app/desktop/views/home/timeline.core.vue
@@ -1,6 +1,6 @@
 <template>
 <div>
-	<mk-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')">
+	<mk-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')">
 		<template #header>
 			<slot></slot>
 			<div v-if="src == 'home' && alone" class="ibpylqas">
@@ -16,8 +16,6 @@
 import Vue from 'vue';
 import i18n from '../../../i18n';
 
-const fetchLimit = 10;
-
 export default Vue.extend({
 	i18n: i18n('desktop/views/components/timeline.core.vue'),
 
@@ -42,7 +40,7 @@ export default Vue.extend({
 			},
 			query: {},
 			endpoint: null,
-			makePromise: null
+			pagination: null
 		};
 	},
 
@@ -109,25 +107,14 @@ export default Vue.extend({
 			this.connection.on('mention', onNote);
 		}
 
-		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,
-					more: true
-				};
-			} else {
-				return {
-					notes: notes,
-					more: false
-				};
-			}
-		});
+		this.pagination = {
+			endpoint: this.endpoint,
+			limit: 10,
+			params: init => ({
+				untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
+				...this.baseQuery, ...this.query
+			})
+		};
 	},
 
 	methods: {
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 0435d67dc7..2a97f2c96e 100644
--- a/src/client/app/desktop/views/home/user/user.timeline.vue
+++ b/src/client/app/desktop/views/home/user/user.timeline.vue
@@ -1,8 +1,8 @@
 <template>
 <div>
-	<mk-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')">
+	<mk-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')">
 		<template #header>
-			<header class="oh5y2r7l5lx8j6jj791ykeiwgihheguk">
+			<header class="kugajpep">
 				<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>
@@ -17,8 +17,6 @@
 import Vue from 'vue';
 import i18n from '../../../../i18n';
 
-const fetchLimit = 10;
-
 export default Vue.extend({
 	i18n: i18n('desktop/views/pages/user/user.timeline.vue'),
 
@@ -30,28 +28,17 @@ export default Vue.extend({
 			mode: 'default',
 			unreadCount: 0,
 			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',
-				untilDate: cursor ? undefined : (this.date ? this.date.getTime() : undefined),
-				untilId: cursor ? cursor : undefined
-			}).then(notes => {
-				if (notes.length == fetchLimit + 1) {
-					notes.pop();
-					return {
-						notes: notes,
-						more: true
-					};
-				} else {
-					return {
-						notes: notes,
-						more: false
-					};
-				}
-			})
+			pagination: {
+				endpoint: 'users/notes',
+				limit: 10,
+				params: init => ({
+					userId: this.user.id,
+					untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
+					includeReplies: this.mode == 'with-replies',
+					includeMyRenotes: this.mode != 'my-posts',
+					withFiles: this.mode == 'with-media',
+				})
+			}
 		};
 	},
 
@@ -88,7 +75,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-.oh5y2r7l5lx8j6jj791ykeiwgihheguk
+.kugajpep
 	padding 0 8px
 	z-index 10
 	background var(--faceHeader)
diff --git a/src/client/app/mobile/script.ts b/src/client/app/mobile/script.ts
index 360da01496..49e8a0f983 100644
--- a/src/client/app/mobile/script.ts
+++ b/src/client/app/mobile/script.ts
@@ -123,10 +123,10 @@ init((launch, os) => {
 					{ path: '/notes/:note', name: 'note', component: () => import('../common/views/deck/deck.note-column.vue').then(m => m.default) },
 					{ path: '/search', component: () => import('../common/views/deck/deck.search-column.vue').then(m => m.default) },
 					{ path: '/tags/:tag', name: 'tag', component: () => import('../common/views/deck/deck.hashtag-column.vue').then(m => m.default) },
-					{ path: '/featured', name: 'featured', component: () => import('../common/views/deck/deck.featured-column.vue').then(m => m.default) },
+					{ path: '/featured', name: 'featured', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/featured.vue').then(m => m.default), platform: 'deck' }) },
 					{ path: '/explore', name: 'explore', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default) }) },
 					{ path: '/explore/tags/:tag', name: 'explore-tag', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default), tag: route.params.tag }) },
-					{ path: '/i/favorites', component: () => import('../common/views/deck/deck.favorites-column.vue').then(m => m.default) },
+					{ path: '/i/favorites', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/favorites.vue').then(m => m.default), platform: 'deck' }) },
 					{ path: '/i/pages', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/pages.vue').then(m => m.default) }) },
 					{ path: '/i/lists', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-lists.vue').then(m => m.default) }) },
 					{ path: '/i/lists/:listId', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-list-editor.vue').then(m => m.default), listId: route.params.listId }) },
@@ -138,7 +138,7 @@ init((launch, os) => {
 		]),
 			{ path: '/signup', name: 'signup', component: MkSignup },
 			{ path: '/i/settings', name: 'settings', component: () => import('./views/pages/settings.vue').then(m => m.default) },
-			{ path: '/i/favorites', name: 'favorites', component: MkFavorites },
+			{ path: '/i/favorites', name: 'favorites', component: UI, props: route => ({ component: () => import('../common/views/pages/favorites.vue').then(m => m.default), platform: 'mobile' }) },
 			{ path: '/i/pages', name: 'pages', component: UI, props: route => ({ component: () => import('../common/views/pages/pages.vue').then(m => m.default) }) },
 			{ path: '/i/lists', name: 'user-lists', component: UI, props: route => ({ component: () => import('../common/views/pages/user-lists.vue').then(m => m.default) }) },
 			{ path: '/i/lists/:list', component: UI, props: route => ({ component: () => import('../common/views/pages/user-list-editor.vue').then(m => m.default), listId: route.params.list }) },
@@ -157,7 +157,7 @@ init((launch, os) => {
 			{ path: '/selectdrive', component: MkSelectDrive },
 			{ path: '/search', component: MkSearch },
 			{ path: '/tags/:tag', component: MkTag },
-			{ path: '/featured', name: 'featured', component: () => import('./views/pages/featured.vue').then(m => m.default) },
+			{ path: '/featured', name: 'featured', component: UI, props: route => ({ component: () => import('../common/views/pages/featured.vue').then(m => m.default), platform: 'mobile' }) },
 			{ path: '/explore', name: 'explore', component: UI, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default) }) },
 			{ path: '/explore/tags/:tag', name: 'explore-tag', component: UI, props: route => ({ component: () => import('../common/views/pages/explore.vue').then(m => m.default), tag: route.params.tag }) },
 			{ path: '/share', component: MkShare },
diff --git a/src/client/app/mobile/views/components/detail-notes.vue b/src/client/app/mobile/views/components/detail-notes.vue
new file mode 100644
index 0000000000..bab7949534
--- /dev/null
+++ b/src/client/app/mobile/views/components/detail-notes.vue
@@ -0,0 +1,52 @@
+<template>
+<div class="fdcvngpy">
+	<sequential-entrance animation="entranceFromTop" delay="25">
+		<template v-for="note in notes">
+			<mk-note-detail class="post" :note="note" :key="note.id"/>
+		</template>
+	</sequential-entrance>
+	<ui-button v-if="more" @click="fetchMore()">{{ $t('@.load-more') }}</ui-button>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import i18n from '../../../i18n';
+import paging from '../../../common/scripts/paging';
+
+export default Vue.extend({
+	i18n: i18n(),
+
+	mixins: [
+		paging({
+			captureWindowScroll: true,
+		}),
+	],
+
+	props: {
+		pagination: {
+			required: true
+		},
+		extract: {
+			required: false
+		}
+	},
+
+	computed: {
+		notes() {
+			return this.extract ? this.extract(this.items) : this.items;
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.fdcvngpy
+	> * > .post
+		margin-bottom 8px
+
+	@media (min-width 500px)
+		> * > .post
+			margin-bottom 16px
+
+</style>
diff --git a/src/client/app/mobile/views/components/notes.vue b/src/client/app/mobile/views/components/notes.vue
index 047c4c2714..2112259564 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="ivaojijs" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }">
-	<div class="empty" v-if="notes.length == 0 && !fetching && inited">{{ $t('@.no-notes') }}</div>
+	<div class="empty" v-if="empty">{{ $t('@.no-notes') }}</div>
 
-	<mk-error v-if="!fetching && !inited" @retry="init()"/>
+	<mk-error v-if="error" @retry="init()"/>
 
 	<div class="placeholder" v-if="fetching">
 		<template v-for="i in 10">
@@ -13,8 +13,8 @@
 	<!-- トランジションを有効にするとなぜかメモリリークする -->
 	<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="transition" tag="div">
 		<template v-for="(note, i) in _notes">
-			<mk-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/>
-			<p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date">
+			<mk-note :note="note" :key="note.id"/>
+			<p class="date" :key="note.id + '_date'" v-if="i != items.length - 1 && note._date != _notes[i + 1]._date">
 				<span><fa icon="angle-up"/>{{ note._datetext }}</span>
 				<span><fa icon="angle-down"/>{{ _notes[i + 1]._datetext }}</span>
 			</p>
@@ -34,157 +34,52 @@
 import Vue from 'vue';
 import i18n from '../../../i18n';
 import shouldMuteNote from '../../../common/scripts/should-mute-note';
-
-const displayLimit = 30;
+import paging from '../../../common/scripts/paging';
 
 export default Vue.extend({
 	i18n: i18n(),
 
-	props: {
-		makePromise: {
-			required: true
-		}
-	},
+	mixins: [
+		paging({
+			captureWindowScroll: true,
 
-	data() {
-		return {
-			notes: [],
-			queue: [],
-			fetching: true,
-			moreFetching: false,
-			inited: false,
-			more: false
-		};
+			onQueueChanged: (self, x) => {
+				if (x.length > 0) {
+					self.$store.commit('indicate', true);
+				} else {
+					self.$store.commit('indicate', false);
+				}
+			},
+
+			onPrepend: (self, note) => {
+				// 弾く
+				if (shouldMuteNote(self.$store.state.i, self.$store.state.settings, note)) return false;
+
+				// タブが非表示またはスクロール位置が最上部ではないならタイトルで通知
+				if (document.hidden || !self.isScrollTop()) {
+					self.$store.commit('pushBehindNote', note);
+				}
+			}
+		}),
+	],
+
+	props: {
+		pagination: {
+			required: true
+		},
 	},
 
 	computed: {
 		_notes(): any[] {
-			return (this.notes as any).map(note => {
-				const date = new Date(note.createdAt).getDate();
-				const month = new Date(note.createdAt).getMonth() + 1;
-				note._date = date;
-				note._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString());
-				return note;
+			return (this.items as any).map(item => {
+				const date = new Date(item.createdAt).getDate();
+				const month = new Date(item.createdAt).getMonth() + 1;
+				item._date = date;
+				item._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString());
+				return item;
 			});
 		}
 	},
-
-	watch: {
-		queue(x) {
-			if (x.length > 0) {
-				this.$store.commit('indicate', true);
-			} else {
-				this.$store.commit('indicate', false);
-			}
-		}
-	},
-
-	created() {
-		this.init();
-	},
-
-	mounted() {
-		window.addEventListener('scroll', this.onScroll, { passive: true });
-	},
-
-	beforeDestroy() {
-		window.removeEventListener('scroll', this.onScroll);
-	},
-
-	methods: {
-		isScrollTop() {
-			return window.scrollY <= 8;
-		},
-
-		onNoteUpdated(i, note) {
-			Vue.set((this as any).notes, i, note);
-		},
-
-		reload() {
-			this.queue = [];
-			this.notes = [];
-			this.init();
-		},
-
-		async init() {
-			this.fetching = true;
-			await (this.makePromise()).then(x => {
-				if (Array.isArray(x)) {
-					this.notes = x;
-				} else {
-					this.notes = x.notes;
-					this.more = x.more;
-				}
-				this.inited = true;
-				this.fetching = false;
-				this.$emit('inited');
-			}, e => {
-				this.fetching = false;
-			});
-		},
-
-		async fetchMore() {
-			if (!this.more || this.moreFetching || this.notes.length === 0) return;
-			this.moreFetching = true;
-			await (this.makePromise(this.notes[this.notes.length - 1].id)).then(x => {
-				this.notes = this.notes.concat(x.notes);
-				this.more = x.more;
-				this.moreFetching = false;
-			}, e => {
-				this.moreFetching = false;
-			});
-		},
-
-		prepend(note, silent = false) {
-			// 弾く
-			if (shouldMuteNote(this.$store.state.i, this.$store.state.settings, note)) return;
-
-			// タブが非表示またはスクロール位置が最上部ではないならタイトルで通知
-			if (document.hidden || !this.isScrollTop()) {
-				this.$store.commit('pushBehindNote', note);
-			}
-
-			if (this.isScrollTop()) {
-				// Prepend the note
-				this.notes.unshift(note);
-
-				// オーバーフローしたら古い投稿は捨てる
-				if (this.notes.length >= displayLimit) {
-					this.notes = this.notes.slice(0, displayLimit);
-					this.more = true;
-				}
-			} else {
-				this.queue.push(note);
-			}
-		},
-
-		append(note) {
-			this.notes.push(note);
-		},
-
-		releaseQueue() {
-			for (const n of this.queue) {
-				this.prepend(n, true);
-			}
-			this.queue = [];
-		},
-
-		onScroll() {
-			if (this.isScrollTop()) {
-				this.releaseQueue();
-			}
-
-			if (this.$store.state.settings.fetchOnScroll) {
-				// 親要素が display none だったら弾く
-				// https://github.com/syuilo/misskey/issues/1569
-				// http://d.hatena.ne.jp/favril/20091105/1257403319
-				if (this.$el.offsetHeight == 0) return;
-
-				const current = window.scrollY + window.innerHeight;
-				if (current > document.body.offsetHeight - 8) this.fetchMore();
-			}
-		}
-	}
 });
 </script>
 
diff --git a/src/client/app/mobile/views/components/notification.vue b/src/client/app/mobile/views/components/notification.vue
index 1128a76000..62df76cba5 100644
--- a/src/client/app/mobile/views/components/notification.vue
+++ b/src/client/app/mobile/views/components/notification.vue
@@ -71,15 +71,15 @@
 	</div>
 
 	<template v-if="notification.type == 'quote'">
-		<mk-note :note="notification.note" @update:note="onNoteUpdated"/>
+		<mk-note :note="notification.note"/>
 	</template>
 
 	<template v-if="notification.type == 'reply'">
-		<mk-note :note="notification.note" @update:note="onNoteUpdated"/>
+		<mk-note :note="notification.note"/>
 	</template>
 
 	<template v-if="notification.type == 'mention'">
-		<mk-note :note="notification.note" @update:note="onNoteUpdated"/>
+		<mk-note :note="notification.note"/>
 	</template>
 </div>
 </template>
@@ -95,17 +95,6 @@ export default Vue.extend({
 			getNoteSummary
 		};
 	},
-	methods: {
-		onNoteUpdated(note) {
-			switch (this.notification.type) {
-				case 'quote':
-				case 'reply':
-				case 'mention':
-					Vue.set(this.notification, 'note', note);
-					break;
-			}
-		}
-	}
 });
 </script>
 
diff --git a/src/client/app/mobile/views/components/notifications.vue b/src/client/app/mobile/views/components/notifications.vue
index a0edab65ec..f793f5d685 100644
--- a/src/client/app/mobile/views/components/notifications.vue
+++ b/src/client/app/mobile/views/components/notifications.vue
@@ -10,41 +10,49 @@
 	<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications" tag="div">
 		<template v-for="(notification, i) in _notifications">
 			<mk-notification :notification="notification" :key="notification.id"/>
-			<p class="date" :key="notification.id + '_date'" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date">
+			<p class="date" :key="notification.id + '_date'" v-if="i != items.length - 1 && notification._date != _notifications[i + 1]._date">
 				<span><fa icon="angle-up"/>{{ notification._datetext }}</span>
 				<span><fa icon="angle-down"/>{{ _notifications[i + 1]._datetext }}</span>
 			</p>
 		</template>
 	</component>
 
-	<button class="more" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications">
-		<template v-if="fetchingMoreNotifications"><fa icon="spinner" pulse fixed-width/></template>
-		{{ fetchingMoreNotifications ? $t('@.loading') : $t('@.load-more') }}
+	<button class="more" v-if="more" @click="fetchMore" :disabled="moreFetching">
+		<template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template>
+		{{ moreFetching ? $t('@.loading') : $t('@.load-more') }}
 	</button>
 
-	<p class="empty" v-if="notifications.length == 0 && !fetching">{{ $t('empty') }}</p>
+	<p class="empty" v-if="empty">{{ $t('empty') }}</p>
+
+	<mk-error v-if="error" @retry="init()"/>
 </div>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
 import i18n from '../../../i18n';
+import paging from '../../../common/scripts/paging';
 
 export default Vue.extend({
 	i18n: i18n('mobile/views/components/notifications.vue'),
+
+	mixins: [
+		paging({}),
+	],
+
 	data() {
 		return {
-			fetching: true,
-			fetchingMoreNotifications: false,
-			notifications: [],
-			moreNotifications: false,
-			connection: null
+			connection: null,
+			pagination: {
+				endpoint: 'i/notifications',
+				limit: 15,
+			}
 		};
 	},
 
 	computed: {
 		_notifications(): any[] {
-			return (this.notifications as any).map(notification => {
+			return (this.items as any).map(notification => {
 				const date = new Date(notification.createdAt).getDate();
 				const month = new Date(notification.createdAt).getMonth() + 1;
 				notification._date = date;
@@ -55,76 +63,23 @@ export default Vue.extend({
 	},
 
 	mounted() {
-		window.addEventListener('scroll', this.onScroll, { passive: true });
-
 		this.connection = this.$root.stream.useSharedConnection('main');
-
 		this.connection.on('notification', this.onNotification);
-
-		const max = 15;
-
-		this.$root.api('i/notifications', {
-			limit: max + 1
-		}).then(notifications => {
-			if (notifications.length == max + 1) {
-				this.moreNotifications = true;
-				notifications.pop();
-			}
-
-			this.notifications = notifications;
-			this.fetching = false;
-			this.$emit('fetched');
-		});
 	},
 
 	beforeDestroy() {
-		window.removeEventListener('scroll', this.onScroll);
 		this.connection.dispose();
 	},
 
 	methods: {
-		fetchMoreNotifications() {
-			if (this.fetchingMoreNotifications) return;
-
-			this.fetchingMoreNotifications = true;
-
-			const max = 30;
-
-			this.$root.api('i/notifications', {
-				limit: max + 1,
-				untilId: this.notifications[this.notifications.length - 1].id
-			}).then(notifications => {
-				if (notifications.length == max + 1) {
-					this.moreNotifications = true;
-					notifications.pop();
-				} else {
-					this.moreNotifications = false;
-				}
-				this.notifications = this.notifications.concat(notifications);
-				this.fetchingMoreNotifications = false;
-			});
-		},
-
 		onNotification(notification) {
 			// TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
 			this.$root.stream.send('readNotification', {
 				id: notification.id
 			});
 
-			this.notifications.unshift(notification);
+			this.prepend(notification);
 		},
-
-		onScroll() {
-			if (this.$store.state.settings.fetchOnScroll) {
-				// 親要素が display none だったら弾く
-				// https://github.com/syuilo/misskey/issues/1569
-				// http://d.hatena.ne.jp/favril/20091105/1257403319
-				if (this.$el.offsetHeight == 0) return;
-
-				const current = window.scrollY + window.innerHeight;
-				if (current > document.body.offsetHeight - 8) this.fetchMoreNotifications();
-			}
-		}
 	}
 });
 </script>
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 73bc6f3034..d9aa1dad8a 100644
--- a/src/client/app/mobile/views/components/user-list-timeline.vue
+++ b/src/client/app/mobile/views/components/user-list-timeline.vue
@@ -1,14 +1,10 @@
 <template>
-<div>
-	<mk-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
-</div>
+<mk-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
 
-const fetchLimit = 10;
-
 export default Vue.extend({
 	props: ['list'],
 
@@ -16,28 +12,17 @@ export default Vue.extend({
 		return {
 			connection: null,
 			date: null,
-			makePromise: cursor => this.$root.api('notes/user-list-timeline', {
-				listId: this.list.id,
-				limit: fetchLimit + 1,
-				untilId: cursor ? cursor : undefined,
-				untilDate: cursor ? undefined : (this.date ? this.date.getTime() : 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,
-						more: true
-					};
-				} else {
-					return {
-						notes: notes,
-						more: false
-					};
-				}
-			})
+			pagination: {
+				endpoint: 'notes/user-list-timeline',
+				limit: 10,
+				params: init => ({
+					listId: this.list.id,
+					untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
+					includeMyRenotes: this.$store.state.settings.showMyRenotes,
+					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
+					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
+				})
+			}
 		};
 	},
 
diff --git a/src/client/app/mobile/views/components/user-timeline.vue b/src/client/app/mobile/views/components/user-timeline.vue
index 8c7c8c6d7d..3b6baa76be 100644
--- a/src/client/app/mobile/views/components/user-timeline.vue
+++ b/src/client/app/mobile/views/components/user-timeline.vue
@@ -1,15 +1,11 @@
 <template>
-<div class="mk-user-timeline">
-	<mk-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
-</div>
+<mk-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
 import i18n from '../../../i18n';
 
-const fetchLimit = 10;
-
 export default Vue.extend({
 	i18n: i18n('mobile/views/components/user-timeline.vue'),
 
@@ -18,26 +14,15 @@ export default Vue.extend({
 	data() {
 		return {
 			date: null,
-			makePromise: cursor => this.$root.api('users/notes', {
-				userId: this.user.id,
-				limit: fetchLimit + 1,
-				withFiles: this.withMedia,
-				untilDate: cursor ? undefined : (this.date ? this.date.getTime() : undefined),
-				untilId: cursor ? cursor : undefined
-			}).then(notes => {
-				if (notes.length == fetchLimit + 1) {
-					notes.pop();
-					return {
-						notes: notes,
-						more: true
-					};
-				} else {
-					return {
-						notes: notes,
-						more: false
-					};
-				}
-			})
+			pagination: {
+				endpoint: 'users/notes',
+				limit: 10,
+				params: init => ({
+					userId: this.user.id,
+					withFiles: this.withMedia,
+					untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
+				})
+			}
 		};
 	},
 
diff --git a/src/client/app/mobile/views/pages/favorites.vue b/src/client/app/mobile/views/pages/favorites.vue
deleted file mode 100644
index 92823db7cc..0000000000
--- a/src/client/app/mobile/views/pages/favorites.vue
+++ /dev/null
@@ -1,86 +0,0 @@
-<template>
-<mk-ui>
-	<template #header><span style="margin-right:4px;"><fa icon="star"/></span>{{ $t('@.favorites') }}</template>
-
-	<main>
-		<sequential-entrance animation="entranceFromTop" delay="25">
-			<template v-for="favorite in favorites">
-				<mk-note-detail class="post" :note="favorite.note" :key="favorite.note.id"/>
-			</template>
-		</sequential-entrance>
-		<ui-button v-if="existMore" @click="fetchMore()">{{ $t('@.load-more') }}</ui-button>
-	</main>
-</mk-ui>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-import i18n from '../../../i18n';
-import Progress from '../../../common/scripts/loading';
-
-export default Vue.extend({
-	i18n: i18n(),
-	data() {
-		return {
-			fetching: true,
-			favorites: [],
-			existMore: false,
-			moreFetching: false
-		};
-	},
-	created() {
-		this.fetch();
-	},
-	mounted() {
-		document.title = `${this.$root.instanceName} | ${this.$t('@.favorites')}`;
-	},
-	methods: {
-		fetch() {
-			Progress.start();
-			this.fetching = true;
-
-			this.$root.api('i/favorites', {
-				limit: 11
-			}).then(favorites => {
-				if (favorites.length == 11) {
-					this.existMore = true;
-					favorites.pop();
-				}
-
-				this.favorites = favorites;
-				this.fetching = false;
-
-				Progress.done();
-			});
-		},
-		fetchMore() {
-			this.moreFetching = true;
-			this.$root.api('i/favorites', {
-				limit: 11,
-				untilId: this.favorites[this.favorites.length - 1].id
-			}).then(favorites => {
-				if (favorites.length == 11) {
-					this.existMore = true;
-					favorites.pop();
-				} else {
-					this.existMore = false;
-				}
-
-				this.favorites = this.favorites.concat(favorites);
-				this.moreFetching = false;
-			});
-		}
-	}
-});
-</script>
-
-<style lang="stylus" scoped>
-main
-	> * > .post
-		margin-bottom 8px
-
-	@media (min-width 500px)
-		> * > .post
-			margin-bottom 16px
-
-</style>
diff --git a/src/client/app/mobile/views/pages/featured.vue b/src/client/app/mobile/views/pages/featured.vue
deleted file mode 100644
index 667e199b58..0000000000
--- a/src/client/app/mobile/views/pages/featured.vue
+++ /dev/null
@@ -1,61 +0,0 @@
-<template>
-<mk-ui>
-	<template #header><span style="margin-right:4px;"><fa :icon="faNewspaper"/></span>{{ $t('@.featured-notes') }}</template>
-
-	<main>
-		<sequential-entrance animation="entranceFromTop" delay="25">
-			<template v-for="note in notes">
-				<mk-note-detail class="post" :note="note" :key="note.id"/>
-			</template>
-		</sequential-entrance>
-	</main>
-</mk-ui>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-import i18n from '../../../i18n';
-import Progress from '../../../common/scripts/loading';
-import { faNewspaper } from '@fortawesome/free-solid-svg-icons';
-
-export default Vue.extend({
-	i18n: i18n(''),
-	data() {
-		return {
-			fetching: true,
-			notes: [],
-			faNewspaper
-		};
-	},
-	created() {
-		this.fetch();
-	},
-	methods: {
-		fetch() {
-			Progress.start();
-			this.fetching = true;
-
-			this.$root.api('notes/featured', {
-				limit: 30
-			}).then(notes => {
-				notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
-				this.notes = notes;
-				this.fetching = false;
-
-				Progress.done();
-			});
-		},
-	}
-});
-</script>
-
-<style lang="stylus" scoped>
-main
-	> * > .post
-		margin-bottom 8px
-
-	@media (min-width 500px)
-		> * > .post
-			margin-bottom 16px
-
-</style>
diff --git a/src/client/app/mobile/views/pages/home.timeline.vue b/src/client/app/mobile/views/pages/home.timeline.vue
index e0754e88b4..1f0602b236 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" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
+	<mk-notes ref="timeline" :pagination="pagination" @inited="() => $emit('loaded')"/>
 </div>
 </template>
 
@@ -15,8 +15,6 @@
 import Vue from 'vue';
 import i18n from '../../../i18n';
 
-const fetchLimit = 10;
-
 export default Vue.extend({
 	i18n: i18n('mobile/views/pages/home.timeline.vue'),
 
@@ -43,7 +41,7 @@ export default Vue.extend({
 			},
 			query: {},
 			endpoint: null,
-			makePromise: null
+			pagination: null
 		};
 	},
 
@@ -110,25 +108,14 @@ export default Vue.extend({
 			this.connection.on('mention', onNote);
 		}
 
-		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,
-					more: true
-				};
-			} else {
-				return {
-					notes: notes,
-					more: false
-				};
-			}
-		});
+		this.pagination = {
+			endpoint: this.endpoint,
+			limit: 10,
+			params: init => ({
+				untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
+				...this.baseQuery, ...this.query
+			})
+		};
 	},
 
 	methods: {
diff --git a/src/client/app/mobile/views/pages/search.vue b/src/client/app/mobile/views/pages/search.vue
index 45f3837907..dca1ffd40a 100644
--- a/src/client/app/mobile/views/pages/search.vue
+++ b/src/client/app/mobile/views/pages/search.vue
@@ -3,7 +3,7 @@
 	<template #header><fa icon="search"/> {{ q }}</template>
 
 	<main>
-		<mk-notes ref="timeline" :make-promise="makePromise" @inited="inited"/>
+		<mk-notes ref="timeline" :pagination="pagination" @inited="inited"/>
 	</main>
 </mk-ui>
 </template>
@@ -14,42 +14,27 @@ import i18n from '../../../i18n';
 import Progress from '../../../common/scripts/loading';
 import { genSearchQuery } from '../../../common/scripts/gen-search-query';
 
-const limit = 20;
-
 export default Vue.extend({
 	i18n: i18n('mobile/views/pages/search.vue'),
 	data() {
 		return {
-			makePromise: async cursor => this.$root.api('notes/search', {
-				limit: limit + 1,
-				untilId: cursor ? cursor : undefined,
-				...(await genSearchQuery(this, this.q))
-			}).then(notes => {
-				if (notes.length == limit + 1) {
-					notes.pop();
-					return {
-						notes: notes,
-						more: true
-					};
-				} else {
-					return {
-						notes: notes,
-						more: false
-					};
-				}
-			})
+			pagination: {
+				endpoint: 'notes/search',
+				limit: 20,
+				params: () => genSearchQuery(this, this.q)
+			}
 		};
 	},
-	watch: {
-		$route() {
-			this.$refs.timeline.reload();
-		}
-	},
 	computed: {
 		q(): string {
 			return this.$route.query.q;
 		}
 	},
+	watch: {
+		$route() {
+			this.$refs.timeline.reload();
+		}
+	},
 	mounted() {
 		document.title = `${this.$t('search')}: ${this.q} | ${this.$root.instanceName}`;
 	},
diff --git a/src/client/app/mobile/views/pages/tag.vue b/src/client/app/mobile/views/pages/tag.vue
index f41cf1f18c..19482ec382 100644
--- a/src/client/app/mobile/views/pages/tag.vue
+++ b/src/client/app/mobile/views/pages/tag.vue
@@ -3,7 +3,7 @@
 	<template #header><span style="margin-right:4px;"><fa icon="hashtag"/></span>{{ $route.params.tag }}</template>
 
 	<main>
-		<mk-notes ref="timeline" :make-promise="makePromise" @inited="inited"/>
+		<mk-notes ref="timeline" :pagination="pagination" @inited="inited"/>
 	</main>
 </mk-ui>
 </template>
@@ -13,30 +13,17 @@ import Vue from 'vue';
 import i18n from '../../../i18n';
 import Progress from '../../../common/scripts/loading';
 
-const limit = 20;
-
 export default Vue.extend({
 	i18n: i18n('mobile/views/pages/tag.vue'),
 	data() {
 		return {
-			makePromise: cursor => this.$root.api('notes/search-by-tag', {
-				limit: limit + 1,
-				untilId: cursor ? cursor : undefined,
-				tag: this.$route.params.tag
-			}).then(notes => {
-				if (notes.length == limit + 1) {
-					notes.pop();
-					return {
-						notes: notes,
-						more: true
-					};
-				} else {
-					return {
-						notes: notes,
-						more: false
-					};
+			pagination: {
+				endpoint: 'notes/search-by-tag',
+				limit: 20,
+				params: {
+					tag: this.$route.params.tag
 				}
-			})
+			}
 		};
 	},
 	watch: {