From 586c11251a8c0e7ca9f8f3bbaad9bf745e6ef948 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sun, 9 Jan 2022 21:35:35 +0900
Subject: [PATCH] wip: migrate paging components to composition api

#7681
---
 .../client/src/components/form/pagination.vue |  44 ---
 packages/client/src/components/notes.vue      | 127 ++-------
 .../client/src/components/notifications.vue   | 208 +++++---------
 .../client/src/components/ui/pagination.vue   | 266 +++++++++++++++---
 packages/client/src/components/user-list.vue  | 106 ++-----
 .../client/src/pages/settings/security.vue    |   8 +-
 .../client/src/pages/user/index.timeline.vue  |  60 ++--
 packages/client/src/scripts/paging.ts         | 246 ----------------
 8 files changed, 381 insertions(+), 684 deletions(-)
 delete mode 100644 packages/client/src/components/form/pagination.vue
 delete mode 100644 packages/client/src/scripts/paging.ts

diff --git a/packages/client/src/components/form/pagination.vue b/packages/client/src/components/form/pagination.vue
deleted file mode 100644
index 3d3b40a783..0000000000
--- a/packages/client/src/components/form/pagination.vue
+++ /dev/null
@@ -1,44 +0,0 @@
-<template>
-<FormSlot>
-	<template #label><slot name="label"></slot></template>
-	<div class="abcaccfa">
-		<slot :items="items"></slot>
-		<div v-if="empty" key="_empty_" class="empty">
-			<slot name="empty"></slot>
-		</div>
-		<MkButton v-show="more" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
-			<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
-			<template v-if="moreFetching"><MkLoading inline/></template>
-		</MkButton>
-	</div>
-</FormSlot>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import MkButton from '@/components/ui/button.vue';
-import FormSlot from './slot.vue';
-import paging from '@/scripts/paging';
-
-export default defineComponent({
-	components: {
-		MkButton,
-		FormSlot,
-	},
-
-	mixins: [
-		paging({}),
-	],
-
-	props: {
-		pagination: {
-			required: true
-		},
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.abcaccfa {
-}
-</style>
diff --git a/packages/client/src/components/notes.vue b/packages/client/src/components/notes.vue
index 4136f72b1b..82703d71c7 100644
--- a/packages/client/src/components/notes.vue
+++ b/packages/client/src/components/notes.vue
@@ -1,114 +1,49 @@
 <template>
-<transition name="fade" mode="out-in">
-	<MkLoading v-if="fetching"/>
-
-	<MkError v-else-if="error" @retry="init()"/>
-
-	<div v-else-if="empty" class="_fullinfo">
-		<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
-		<div>{{ $ts.noNotes }}</div>
-	</div>
-
-	<div v-else class="giivymft" :class="{ noGap }">
-		<div v-show="more && reversed" style="margin-bottom: var(--margin);">
-			<MkButton style="margin: 0 auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMoreFeature">
-				<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
-				<template v-if="moreFetching"><MkLoading inline/></template>
-			</MkButton>
+<MkPagination ref="pagingComponent" :pagination="pagination">
+	<template #empty>
+		<div class="_fullinfo">
+			<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+			<div>{{ $ts.noNotes }}</div>
 		</div>
+	</template>
 
-		<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :no-gap="noGap" :ad="true" class="notes">
-			<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note" @update:note="updated(note, $event)"/>
-		</XList>
-
-		<div v-show="more && !reversed" style="margin-top: var(--margin);">
-			<MkButton v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" style="margin: 0 auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
-				<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
-				<template v-if="moreFetching"><MkLoading inline/></template>
-			</MkButton>
+	<template #default="{ items: notes }">
+		<div class="giivymft" :class="{ noGap }">
+			<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" class="notes">
+				<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note" @update:note="updated(note, $event)"/>
+			</XList>
 		</div>
-	</div>
-</transition>
+	</template>
+</MkPagination>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
-import paging from '@/scripts/paging';
-import XNote from './note.vue';
-import XList from './date-separated-list.vue';
-import MkButton from '@/components/ui/button.vue';
+<script lang="ts" setup>
+import { ref } from 'vue';
+import XNote from '@/components/note.vue';
+import XList from '@/components/date-separated-list.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import { Paging } from '@/components/ui/pagination.vue';
 
-export default defineComponent({
-	components: {
-		XNote, XList, MkButton,
-	},
+const props = defineProps<{
+	pagination: Paging;
+	noGap?: boolean;
+}>();
 
-	mixins: [
-		paging({
-			before: (self) => {
-				self.$emit('before');
-			},
+const pagingComponent = ref<InstanceType<typeof MkPagination>>();
 
-			after: (self, e) => {
-				self.$emit('after', e);
-			}
-		}),
-	],
+const updated = (oldValue, newValue) => {
+	const i = pagingComponent.value.items.findIndex(n => n === oldValue);
+	pagingComponent.value.items[i] = newValue;
+};
 
-	props: {
-		pagination: {
-			required: true
-		},
-		prop: {
-			type: String,
-			required: false
-		},
-		noGap: {
-			type: Boolean,
-			required: false,
-			default: false
-		},
+defineExpose({
+	prepend: (note) => {
+		pagingComponent.value?.prepend(note);
 	},
-
-	emits: ['before', 'after'],
-
-	computed: {
-		notes(): any[] {
-			return this.prop ? this.items.map(item => item[this.prop]) : this.items;
-		},
-
-		reversed(): boolean {
-			return this.pagination.reversed;
-		}
-	},
-
-	methods: {
-		updated(oldValue, newValue) {
-			const i = this.notes.findIndex(n => n === oldValue);
-			if (this.prop) {
-				this.items[i][this.prop] = newValue;
-			} else {
-				this.items[i] = newValue;
-			}
-		},
-
-		focus() {
-			this.$refs.notes.focus();
-		}
-	}
 });
 </script>
 
 <style lang="scss" scoped>
-.fade-enter-active,
-.fade-leave-active {
-	transition: opacity 0.125s ease;
-}
-.fade-enter-from,
-.fade-leave-to {
-	opacity: 0;
-}
-
 .giivymft {
 	&.noGap {
 		> .notes {
diff --git a/packages/client/src/components/notifications.vue b/packages/client/src/components/notifications.vue
index 328888c355..aa4b480694 100644
--- a/packages/client/src/components/notifications.vue
+++ b/packages/client/src/components/notifications.vue
@@ -1,159 +1,85 @@
 <template>
-<transition name="fade" mode="out-in">
-	<MkLoading v-if="fetching"/>
-
-	<MkError v-else-if="error" @retry="init()"/>
-
-	<p v-else-if="empty" class="mfcuwfyp">{{ $ts.noNotifications }}</p>
-
-	<div v-else>
-		<XList v-slot="{ item: notification }" class="elsfgstc" :items="items" :no-gap="true">
+<MkPagination ref="pagingComponent" :pagination="pagination">
+	<template #empty>
+		<div class="_fullinfo">
+			<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+			<div>{{ $ts.noNotifications }}</div>
+		</div>
+	</template>
+
+	<template #default="{ items: notifications }">
+		<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true">
 			<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" @update:note="noteUpdated(notification.note, $event)"/>
 			<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/>
 		</XList>
-
-		<MkButton v-show="more" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" primary style="margin: var(--margin) auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
-			<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
-			<template v-if="moreFetching"><MkLoading inline/></template>
-		</MkButton>
-	</div>
-</transition>
+	</template>
+</MkPagination>
 </template>
 
-<script lang="ts">
-import { defineComponent, PropType, markRaw } from 'vue';
-import paging from '@/scripts/paging';
-import XNotification from './notification.vue';
-import XList from './date-separated-list.vue';
-import XNote from './note.vue';
+<script lang="ts" setup>
+import { defineComponent, PropType, markRaw, onUnmounted, onMounted, computed, ref } from 'vue';
 import { notificationTypes } from 'misskey-js';
+import MkPagination from '@/components/ui/pagination.vue';
+import { Paging } from '@/components/ui/pagination.vue';
+import XNotification from '@/components/notification.vue';
+import XList from '@/components/date-separated-list.vue';
+import XNote from '@/components/note.vue';
 import * as os from '@/os';
 import { stream } from '@/stream';
-import MkButton from '@/components/ui/button.vue';
-
-export default defineComponent({
-	components: {
-		XNotification,
-		XList,
-		XNote,
-		MkButton,
-	},
-
-	mixins: [
-		paging({}),
-	],
-
-	props: {
-		includeTypes: {
-			type: Array as PropType<typeof notificationTypes[number][]>,
-			required: false,
-			default: null,
-		},
-		unreadOnly: {
-			type: Boolean,
-			required: false,
-			default: false,
-		},
-	},
-
-	data() {
-		return {
-			connection: null,
-			pagination: {
-				endpoint: 'i/notifications',
-				limit: 10,
-				params: () => ({
-					includeTypes: this.allIncludeTypes || undefined,
-					unreadOnly: this.unreadOnly,
-				})
-			},
-		};
-	},
-
-	computed: {
-		allIncludeTypes() {
-			return this.includeTypes ?? notificationTypes.filter(x => !this.$i.mutingNotificationTypes.includes(x));
-		}
-	},
-
-	watch: {
-		includeTypes: {
-			handler() {
-				this.reload();
-			},
-			deep: true
-		},
-		unreadOnly: {
-			handler() {
-				this.reload();
-			},
-		},
-		// TODO: vue/vuexのバグか仕様かは不明なものの、プロフィール更新するなどして $i が更新されると、
-		// mutingNotificationTypes に変化が無くてもこのハンドラーが呼び出され無駄なリロードが発生するのを直す
-		'$i.mutingNotificationTypes': {
-			handler() {
-				if (this.includeTypes === null) {
-					this.reload();
-				}
-			},
-			deep: true
-		}
-	},
-
-	mounted() {
-		this.connection = markRaw(stream.useChannel('main'));
-		this.connection.on('notification', this.onNotification);
-	},
-
-	beforeUnmount() {
-		this.connection.dispose();
-	},
-
-	methods: {
-		onNotification(notification) {
-			const isMuted = !this.allIncludeTypes.includes(notification.type);
-			if (isMuted || document.visibilityState === 'visible') {
-				stream.send('readNotification', {
-					id: notification.id
-				});
-			}
-
-			if (!isMuted) {
-				this.prepend({
-					...notification,
-					isRead: document.visibilityState === 'visible'
-				});
-			}
-		},
+import { $i } from '@/account';
+
+const props = defineProps<{
+	includeTypes?: PropType<typeof notificationTypes[number][]>;
+	unreadOnly?: boolean;
+}>();
+
+const pagingComponent = ref<InstanceType<typeof MkPagination>>();
+
+const allIncludeTypes = computed(() => props.includeTypes ?? notificationTypes.filter(x => !$i.mutingNotificationTypes.includes(x)));
+
+const pagination: Paging = {
+	endpoint: 'i/notifications' as const,
+	limit: 10,
+	params: computed(() => ({
+		includeTypes: allIncludeTypes.value || undefined,
+		unreadOnly: props.unreadOnly,
+	})),
+};
+
+const onNotification = (notification) => {
+	const isMuted = !allIncludeTypes.value.includes(notification.type);
+	if (isMuted || document.visibilityState === 'visible') {
+		stream.send('readNotification', {
+			id: notification.id
+		});
+	}
 
-		noteUpdated(oldValue, newValue) {
-			const i = this.items.findIndex(n => n.note === oldValue);
-			this.items[i] = {
-				...this.items[i],
-				note: newValue
-			};
-		},
+	if (!isMuted) {
+		pagingComponent.value.prepend({
+			...notification,
+			isRead: document.visibilityState === 'visible'
+		});
 	}
+};
+
+const noteUpdated = (oldValue, newValue) => {
+	const i = pagingComponent.value.items.findIndex(n => n.note === oldValue);
+	pagingComponent.value.items[i] = {
+		...pagingComponent.value.items[i],
+		note: newValue,
+	};
+};
+
+onMounted(() => {
+	const connection = stream.useChannel('main');
+	connection.on('notification', onNotification);
+	onUnmounted(() => {
+		connection.dispose();
+	});
 });
 </script>
 
 <style lang="scss" scoped>
-.fade-enter-active,
-.fade-leave-active {
-	transition: opacity 0.125s ease;
-}
-.fade-enter-from,
-.fade-leave-to {
-	opacity: 0;
-}
-
-.mfcuwfyp {
-	margin: 0;
-	padding: 16px;
-	text-align: center;
-	color: var(--fg);
-}
-
 .elsfgstc {
 	background: var(--panel);
 }
diff --git a/packages/client/src/components/ui/pagination.vue b/packages/client/src/components/ui/pagination.vue
index 64af4a54f7..79744e528d 100644
--- a/packages/client/src/components/ui/pagination.vue
+++ b/packages/client/src/components/ui/pagination.vue
@@ -13,43 +13,247 @@
 		</slot>
 	</div>
 
-	<div v-else class="cxiknjgy">
+	<div v-else ref="rootEl">
 		<slot :items="items"></slot>
-		<div v-show="more" key="_more_" class="more _gap">
-			<MkButton v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
-				<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
-				<template v-if="moreFetching"><MkLoading inline/></template>
+		<div v-show="more" key="_more_" class="cxiknjgy _gap">
+			<MkButton v-if="!moreFetching" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
+				{{ $ts.loadMore }}
 			</MkButton>
+			<MkLoading v-else class="loading"/>
 		</div>
 	</div>
 </transition>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
-import MkButton from './button.vue';
-import paging from '@/scripts/paging';
-
-export default defineComponent({
-	components: {
-		MkButton
-	},
-
-	mixins: [
-		paging({}),
-	],
-
-	props: {
-		pagination: {
-			required: true
-		},
-
-		disableAutoLoad: {
-			type: Boolean,
-			required: false,
-			default: false,
+<script lang="ts" setup>
+import { computed, ComputedRef, isRef, markRaw, onActivated, onDeactivated, Ref, ref, watch } from 'vue';
+import * as misskey from 'misskey-js';
+import * as os from '@/os';
+import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
+import MkButton from '@/components/ui/button.vue';
+
+const SECOND_FETCH_LIMIT = 30;
+
+export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = {
+	endpoint: E;
+	limit: number;
+	params?: misskey.Endpoints[E]['req'] | ComputedRef<misskey.Endpoints[E]['req']>;
+
+	/**
+	 * 検索APIのような、ページング不可なエンドポイントを利用する場合
+	 * (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
+	 */
+	noPaging?: boolean;
+
+	/**
+	 * items 配列の中身を逆順にする(新しい方が最後)
+	 */
+	reversed?: boolean;
+};
+
+const props = withDefaults(defineProps<{
+	pagination: Paging;
+	disableAutoLoad?: boolean;
+	displayLimit?: number;
+}>(), {
+	displayLimit: 30,
+});
+
+const rootEl = ref<HTMLElement>();
+const items = ref([]);
+const queue = ref([]);
+const offset = ref(0);
+const fetching = ref(true);
+const moreFetching = ref(false);
+const inited = ref(false);
+const more = ref(false);
+const backed = ref(false); // 遡り中か否か
+const isBackTop = ref(false);
+const empty = computed(() => items.value.length === 0 && !fetching.value && inited.value);
+const error = computed(() => !fetching.value && !inited.value);
+
+const init = async () => {
+	queue.value = [];
+	fetching.value = true;
+	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
+	await os.api(props.pagination.endpoint, {
+		...params,
+		limit: props.pagination.noPaging ? (props.pagination.limit || 10) : (props.pagination.limit || 10) + 1,
+	}).then(res => {
+		for (let i = 0; i < res.length; i++) {
+			const item = res[i];
+			markRaw(item);
+			if (props.pagination.reversed) {
+				if (i === res.length - 2) item._shouldInsertAd_ = true;
+			} else {
+				if (i === 3) item._shouldInsertAd_ = true;
+			}
+		}
+		if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) {
+			res.pop();
+			items.value = props.pagination.reversed ? [...res].reverse() : res;
+			more.value = true;
+		} else {
+			items.value = props.pagination.reversed ? [...res].reverse() : res;
+			more.value = false;
+		}
+		offset.value = res.length;
+		inited.value = true;
+		fetching.value = false;
+	}, e => {
+		fetching.value = false;
+	});
+};
+
+const reload = () => {
+	items.value = [];
+	init();
+};
+
+const fetchMore = async () => {
+	if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
+	moreFetching.value = true;
+	backed.value = true;
+	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
+	await os.api(props.pagination.endpoint, {
+		...params,
+		limit: SECOND_FETCH_LIMIT + 1,
+		...(props.pagination.offsetMode ? {
+			offset: offset.value,
+		} : {
+			untilId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id,
+		}),
+	}).then(res => {
+		for (let i = 0; i < res.length; i++) {
+			const item = res[i];
+			markRaw(item);
+			if (props.pagination.reversed) {
+				if (i === res.length - 9) item._shouldInsertAd_ = true;
+			} else {
+				if (i === 10) item._shouldInsertAd_ = true;
+			}
+		}
+		if (res.length > SECOND_FETCH_LIMIT) {
+			res.pop();
+			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
+			more.value = true;
+		} else {
+			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
+			more.value = false;
+		}
+		offset.value += res.length;
+		moreFetching.value = false;
+	}, e => {
+		moreFetching.value = false;
+	});
+};
+
+const fetchMoreAhead = async () => {
+	if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
+	moreFetching.value = true;
+	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
+	await os.api(props.pagination.endpoint, {
+		...params,
+		limit: SECOND_FETCH_LIMIT + 1,
+		...(props.pagination.offsetMode ? {
+			offset: offset.value,
+		} : {
+			sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id,
+		}),
+	}).then(res => {
+		for (const item of res) {
+			markRaw(item);
 		}
-	},
+		if (res.length > SECOND_FETCH_LIMIT) {
+			res.pop();
+			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
+			more.value = true;
+		} else {
+			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
+			more.value = false;
+		}
+		offset.value += res.length;
+		moreFetching.value = false;
+	}, e => {
+		moreFetching.value = false;
+	});
+};
+
+const prepend = (item) => {
+	if (props.pagination.reversed) {
+		const container = getScrollContainer(rootEl.value);
+		const pos = getScrollPosition(rootEl.value);
+		const viewHeight = container.clientHeight;
+		const height = container.scrollHeight;
+		const isBottom = (pos + viewHeight > height - 32);
+		if (isBottom) {
+			// オーバーフローしたら古いアイテムは捨てる
+			if (items.value.length >= props.displayLimit) {
+				// このやり方だとVue 3.2以降アニメーションが動かなくなる
+				//items.value = items.value.slice(-props.displayLimit);
+				while (items.value.length >= props.displayLimit) {
+					items.value.shift();
+				}
+				more.value = true;
+			}
+		}
+		items.value.push(item);
+		// TODO
+	} else {
+		const isTop = isBackTop.value || (document.body.contains(rootEl.value) && isTopVisible(rootEl.value));
+		console.log(item, top);
+
+		if (isTop) {
+			// Prepend the item
+			items.value.unshift(item);
+
+			// オーバーフローしたら古いアイテムは捨てる
+			if (items.value.length >= props.displayLimit) {
+				// このやり方だとVue 3.2以降アニメーションが動かなくなる
+				//this.items = items.value.slice(0, props.displayLimit);
+				while (items.value.length >= props.displayLimit) {
+					items.value.pop();
+				}
+				more.value = true;
+			}
+		} else {
+			queue.value.push(item);
+			onScrollTop(rootEl.value, () => {
+				for (const item of queue.value) {
+					prepend(item);
+				}
+				queue.value = [];
+			});
+		}
+	}
+};
+
+const append = (item) => {
+	items.value.push(item);
+};
+
+watch(props.pagination.params, init, { deep: true });
+watch(queue, (a, b) => {
+	if (a.length === 0 && b.length === 0) return;
+	this.$emit('queue', queue.value.length);
+}, { deep: true });
+
+init();
+
+onActivated(() => {
+	isBackTop.value = false;
+});
+
+onDeactivated(() => {
+	isBackTop.value = window.scrollY === 0;
+});
+
+defineExpose({
+	items,
+	reload,
+	fetchMoreAhead,
+	prepend,
+	append,
 });
 </script>
 
@@ -64,11 +268,9 @@ export default defineComponent({
 }
 
 .cxiknjgy {
-	> .more > .button {
+	> .button {
 		margin-left: auto;
 		margin-right: auto;
-		height: 48px;
-		min-width: 150px;
 	}
 }
 </style>
diff --git a/packages/client/src/components/user-list.vue b/packages/client/src/components/user-list.vue
index 2148dab608..3e273721c7 100644
--- a/packages/client/src/components/user-list.vue
+++ b/packages/client/src/components/user-list.vue
@@ -1,91 +1,39 @@
 <template>
-<MkError v-if="error" @retry="init()"/>
-
-<div v-else class="efvhhmdq _isolated">
-	<div v-if="empty" class="no-users">
-		<p>{{ $ts.noUsers }}</p>
-	</div>
-	<div class="users">
-		<MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/>
-	</div>
-	<button v-show="more" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" class="more" :class="{ fetching: moreFetching }" :disabled="moreFetching" @click="fetchMore">
-		<template v-if="moreFetching"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ moreFetching ? $ts.loading : $ts.loadMore }}
-	</button>
-</div>
+<MkPagination ref="pagingComponent" :pagination="pagination">
+	<template #empty>
+		<div class="_fullinfo">
+			<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+			<div>{{ $ts.noUsers }}</div>
+		</div>
+	</template>
+
+	<template #default="{ items: users }">
+		<div class="efvhhmdq">
+			<MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/>
+		</div>
+	</template>
+</MkPagination>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
-import paging from '@/scripts/paging';
-import MkUserInfo from './user-info.vue';
+<script lang="ts" setup>
+import { ref } from 'vue';
+import MkUserInfo from '@/components/user-info.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import { Paging } from '@/components/ui/pagination.vue';
 import { userPage } from '@/filters/user';
 
-export default defineComponent({
-	components: {
-		MkUserInfo,
-	},
-
-	mixins: [
-		paging({}),
-	],
-
-	props: {
-		pagination: {
-			required: true
-		},
-		extract: {
-			required: false
-		},
-		expanded: {
-			type: Boolean,
-			default: true
-		},
-	},
+const props = defineProps<{
+	pagination: Paging;
+	noGap?: boolean;
+}>();
 
-	computed: {
-		users() {
-			return this.extract ? this.extract(this.items) : this.items;
-		}
-	},
-
-	methods: {
-		userPage
-	}
-});
+const pagingComponent = ref<InstanceType<typeof MkPagination>>();
 </script>
 
 <style lang="scss" scoped>
 .efvhhmdq {
-	> .no-users {
-		text-align: center;
-	}
-
-	> .users {
-		display: grid;
-		grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
-		grid-gap: var(--margin);
-	}
-
-	> .more {
-		display: block;
-		width: 100%;
-		padding: 16px;
-
-		&:hover {
-			background: rgba(#000, 0.025);
-		}
-
-		&:active {
-			background: rgba(#000, 0.05);
-		}
-
-		&.fetching {
-			cursor: wait;
-		}
-
-		> i {
-			margin-right: 4px;
-		}
-	}
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+	grid-gap: var(--margin);
 }
 </style>
diff --git a/packages/client/src/pages/settings/security.vue b/packages/client/src/pages/settings/security.vue
index 82a21d5b16..03f2d6300b 100644
--- a/packages/client/src/pages/settings/security.vue
+++ b/packages/client/src/pages/settings/security.vue
@@ -12,7 +12,7 @@
 	
 	<FormSection>
 		<template #label>{{ $ts.signinHistory }}</template>
-		<FormPagination :pagination="pagination">
+		<MkPagination :pagination="pagination">
 			<template v-slot="{items}">
 				<div>
 					<div v-for="item in items" :key="item.id" v-panel class="timnmucd">
@@ -25,7 +25,7 @@
 					</div>
 				</div>
 			</template>
-		</FormPagination>
+		</MkPagination>
 	</FormSection>
 
 	<FormSection>
@@ -42,7 +42,7 @@ import { defineComponent } from 'vue';
 import FormSection from '@/components/form/section.vue';
 import FormSlot from '@/components/form/slot.vue';
 import FormButton from '@/components/ui/button.vue';
-import FormPagination from '@/components/form/pagination.vue';
+import MkPagination from '@/components/ui/pagination.vue';
 import X2fa from './2fa.vue';
 import * as os from '@/os';
 import * as symbols from '@/symbols';
@@ -51,7 +51,7 @@ export default defineComponent({
 	components: {
 		FormSection,
 		FormButton,
-		FormPagination,
+		MkPagination,
 		FormSlot,
 		X2fa,
 	},
diff --git a/packages/client/src/pages/user/index.timeline.vue b/packages/client/src/pages/user/index.timeline.vue
index 2ffa496979..7396a76efe 100644
--- a/packages/client/src/pages/user/index.timeline.vue
+++ b/packages/client/src/pages/user/index.timeline.vue
@@ -1,60 +1,36 @@
 <template>
 <div v-sticky-container class="yrzkoczt">
-	<MkTab v-model="with_" class="tab">
+	<MkTab v-model="include" class="tab">
 		<option :value="null">{{ $ts.notes }}</option>
 		<option value="replies">{{ $ts.notesAndReplies }}</option>
 		<option value="files">{{ $ts.withFiles }}</option>
 	</MkTab>
-	<XNotes ref="timeline" :no-gap="true" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)"/>
+	<XNotes ref="timeline" :no-gap="true" :pagination="pagination"/>
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { ref, computed } from 'vue';
+import * as misskey from 'misskey-js';
 import XNotes from '@/components/notes.vue';
 import MkTab from '@/components/tab.vue';
 import * as os from '@/os';
 
-export default defineComponent({
-	components: {
-		XNotes,
-		MkTab,
-	},
+const props = defineProps<{
+	user: misskey.entities.UserDetailed;
+}>();
 
-	props: {
-		user: {
-			type: Object,
-			required: true,
-		},
-	},
+const include = ref<string | null>(null);
 
-	data() {
-		return {
-			date: null,
-			with_: null,
-			pagination: {
-				endpoint: 'users/notes',
-				limit: 10,
-				params: init => ({
-					userId: this.user.id,
-					includeReplies: this.with_ === 'replies',
-					withFiles: this.with_ === 'files',
-					untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
-				})
-			}
-		};
-	},
-
-	watch: {
-		user() {
-			this.$refs.timeline.reload();
-		},
-
-		with_() {
-			this.$refs.timeline.reload();
-		},
-	},
-});
+const pagination = {
+	endpoint: 'users/notes' as const,
+	limit: 10,
+	params: computed(() => ({
+		userId: props.user.id,
+		includeReplies: include.value === 'replies',
+		withFiles: include.value === 'files',
+	})),
+};
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/scripts/paging.ts b/packages/client/src/scripts/paging.ts
deleted file mode 100644
index ef63ecc450..0000000000
--- a/packages/client/src/scripts/paging.ts
+++ /dev/null
@@ -1,246 +0,0 @@
-import { markRaw } from 'vue';
-import * as os from '@/os';
-import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from './scroll';
-
-const SECOND_FETCH_LIMIT = 30;
-
-// reversed: items 配列の中身を逆順にする(新しい方が最後)
-
-export default (opts) => ({
-	emits: ['queue'],
-
-	data() {
-		return {
-			items: [],
-			queue: [],
-			offset: 0,
-			fetching: true,
-			moreFetching: false,
-			inited: false,
-			more: false,
-			backed: false, // 遡り中か否か
-			isBackTop: false,
-		};
-	},
-
-	computed: {
-		empty(): boolean {
-			return this.items.length === 0 && !this.fetching && this.inited;
-		},
-
-		error(): boolean {
-			return !this.fetching && !this.inited;
-		},
-	},
-
-	watch: {
-		pagination: {
-			handler() {
-				this.init();
-			},
-			deep: true
-		},
-
-		queue: {
-			handler(a, b) {
-				if (a.length === 0 && b.length === 0) return;
-				this.$emit('queue', this.queue.length);
-			},
-			deep: true
-		}
-	},
-
-	created() {
-		opts.displayLimit = opts.displayLimit || 30;
-		this.init();
-	},
-
-	activated() {
-		this.isBackTop = false;
-	},
-
-	deactivated() {
-		this.isBackTop = window.scrollY === 0;
-	},
-
-	methods: {
-		reload() {
-			this.items = [];
-			this.init();
-		},
-
-		replaceItem(finder, data) {
-			const i = this.items.findIndex(finder);
-			this.items[i] = data;
-		},
-
-		removeItem(finder) {
-			const i = this.items.findIndex(finder);
-			this.items.splice(i, 1);
-		},
-
-		async init() {
-			this.queue = [];
-			this.fetching = true;
-			if (opts.before) opts.before(this);
-			let params = typeof this.pagination.params === 'function' ? this.pagination.params(true) : this.pagination.params;
-			if (params && params.then) params = await params;
-			if (params === null) return;
-			const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
-			await os.api(endpoint, {
-				...params,
-				limit: this.pagination.noPaging ? (this.pagination.limit || 10) : (this.pagination.limit || 10) + 1,
-			}).then(items => {
-				for (let i = 0; i < items.length; i++) {
-					const item = items[i];
-					markRaw(item);
-					if (this.pagination.reversed) {
-						if (i === items.length - 2) item._shouldInsertAd_ = true;
-					} else {
-						if (i === 3) item._shouldInsertAd_ = true;
-					}
-				}
-				if (!this.pagination.noPaging && (items.length > (this.pagination.limit || 10))) {
-					items.pop();
-					this.items = this.pagination.reversed ? [...items].reverse() : items;
-					this.more = true;
-				} else {
-					this.items = this.pagination.reversed ? [...items].reverse() : items;
-					this.more = false;
-				}
-				this.offset = items.length;
-				this.inited = true;
-				this.fetching = false;
-				if (opts.after) opts.after(this, null);
-			}, e => {
-				this.fetching = false;
-				if (opts.after) opts.after(this, e);
-			});
-		},
-
-		async fetchMore() {
-			if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return;
-			this.moreFetching = true;
-			this.backed = true;
-			let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
-			if (params && params.then) params = await params;
-			const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
-			await os.api(endpoint, {
-				...params,
-				limit: SECOND_FETCH_LIMIT + 1,
-				...(this.pagination.offsetMode ? {
-					offset: this.offset,
-				} : {
-					untilId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
-				}),
-			}).then(items => {
-				for (let i = 0; i < items.length; i++) {
-					const item = items[i];
-					markRaw(item);
-					if (this.pagination.reversed) {
-						if (i === items.length - 9) item._shouldInsertAd_ = true;
-					} else {
-						if (i === 10) item._shouldInsertAd_ = true;
-					}
-				}
-				if (items.length > SECOND_FETCH_LIMIT) {
-					items.pop();
-					this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
-					this.more = true;
-				} else {
-					this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
-					this.more = false;
-				}
-				this.offset += items.length;
-				this.moreFetching = false;
-			}, e => {
-				this.moreFetching = false;
-			});
-		},
-
-		async fetchMoreFeature() {
-			if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return;
-			this.moreFetching = true;
-			let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
-			if (params && params.then) params = await params;
-			const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
-			await os.api(endpoint, {
-				...params,
-				limit: SECOND_FETCH_LIMIT + 1,
-				...(this.pagination.offsetMode ? {
-					offset: this.offset,
-				} : {
-					sinceId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
-				}),
-			}).then(items => {
-				for (const item of items) {
-					markRaw(item);
-				}
-				if (items.length > SECOND_FETCH_LIMIT) {
-					items.pop();
-					this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
-					this.more = true;
-				} else {
-					this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
-					this.more = false;
-				}
-				this.offset += items.length;
-				this.moreFetching = false;
-			}, e => {
-				this.moreFetching = false;
-			});
-		},
-
-		prepend(item) {
-			if (this.pagination.reversed) {
-				const container = getScrollContainer(this.$el);
-				const pos = getScrollPosition(this.$el);
-				const viewHeight = container.clientHeight;
-				const height = container.scrollHeight;
-				const isBottom = (pos + viewHeight > height - 32);
-				if (isBottom) {
-					// オーバーフローしたら古いアイテムは捨てる
-					if (this.items.length >= opts.displayLimit) {
-						// このやり方だとVue 3.2以降アニメーションが動かなくなる
-						//this.items = this.items.slice(-opts.displayLimit);
-						while (this.items.length >= opts.displayLimit) {
-							this.items.shift();
-						}
-						this.more = true;
-					}
-				}
-				this.items.push(item);
-				// TODO
-			} else {
-				const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el));
-
-				if (isTop) {
-					// Prepend the item
-					this.items.unshift(item);
-
-					// オーバーフローしたら古いアイテムは捨てる
-					if (this.items.length >= opts.displayLimit) {
-						// このやり方だとVue 3.2以降アニメーションが動かなくなる
-						//this.items = this.items.slice(0, opts.displayLimit);
-						while (this.items.length >= opts.displayLimit) {
-							this.items.pop();
-						}
-						this.more = true;
-					}
-				} else {
-					this.queue.push(item);
-					onScrollTop(this.$el, () => {
-						for (const item of this.queue) {
-							this.prepend(item);
-						}
-						this.queue = [];
-					});
-				}
-			}
-		},
-
-		append(item) {
-			this.items.push(item);
-		},
-	}
-});
-- 
GitLab