From f1deb89e348eb8f1a39b51e33a0ae33d59529feb Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sun, 29 Jun 2025 15:11:25 +0900
Subject: refactor(frontend): improve pagination implementation

---
 .../src/components/MkStreamingNotesTimeline.vue    | 199 ++++++++++-----------
 1 file changed, 95 insertions(+), 104 deletions(-)

(limited to 'packages/frontend/src/components/MkStreamingNotesTimeline.vue')

diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
index 7e72840b7b..69602f4c1c 100644
--- a/packages/frontend/src/components/MkStreamingNotesTimeline.vue
+++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
@@ -56,14 +56,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup, onMounted, shallowRef, ref } from 'vue';
+import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup, onMounted, shallowRef, ref, markRaw } from 'vue';
 import * as Misskey from 'misskey-js';
 import { useInterval } from '@@/js/use-interval.js';
 import { getScrollContainer, scrollToTop } from '@@/js/scroll.js';
 import type { BasicTimelineType } from '@/timelines.js';
-import type { PagingCtx } from '@/composables/use-pagination.js';
 import type { SoundStore } from '@/preferences/def.js';
-import { usePagination } from '@/composables/use-pagination.js';
 import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
 import { useStream } from '@/stream.js';
 import * as sound from '@/utility/sound.js';
@@ -76,6 +74,7 @@ import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
 import { globalEvents, useGlobalEvent } from '@/events.js';
 import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
+import { Paginator } from '@/utility/paginator.js';
 
 const props = withDefaults(defineProps<{
 	src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
@@ -102,6 +101,97 @@ provide('inTimeline', true);
 provide('tl_withSensitive', computed(() => props.withSensitive));
 provide('inChannel', computed(() => props.src === 'channel'));
 
+let paginator: Paginator;
+
+if (props.src === 'antenna') {
+	paginator = markRaw(new Paginator('antennas/notes', {
+		computedParams: computed(() => ({
+			antennaId: props.antenna,
+		})),
+		useShallowRef: true,
+	}));
+} else if (props.src === 'home') {
+	paginator = markRaw(new Paginator('notes/timeline', {
+		computedParams: computed(() => ({
+			withRenotes: props.withRenotes,
+			withFiles: props.onlyFiles ? true : undefined,
+		})),
+		useShallowRef: true,
+	}));
+} else if (props.src === 'local') {
+	paginator = markRaw(new Paginator('notes/local-timeline', {
+		computedParams: computed(() => ({
+			withRenotes: props.withRenotes,
+			withReplies: props.withReplies,
+			withFiles: props.onlyFiles ? true : undefined,
+		})),
+		useShallowRef: true,
+	}));
+} else if (props.src === 'social') {
+	paginator = markRaw(new Paginator('notes/hybrid-timeline', {
+		computedParams: computed(() => ({
+			withRenotes: props.withRenotes,
+			withReplies: props.withReplies,
+			withFiles: props.onlyFiles ? true : undefined,
+		})),
+		useShallowRef: true,
+	}));
+} else if (props.src === 'global') {
+	paginator = markRaw(new Paginator('notes/global-timeline', {
+		computedParams: computed(() => ({
+			withRenotes: props.withRenotes,
+			withFiles: props.onlyFiles ? true : undefined,
+		})),
+		useShallowRef: true,
+	}));
+} else if (props.src === 'mentions') {
+	paginator = markRaw(new Paginator('notes/mentions', {
+		useShallowRef: true,
+	}));
+} else if (props.src === 'directs') {
+	paginator = markRaw(new Paginator('notes/mentions', {
+		params: {
+			visibility: 'specified',
+		},
+		useShallowRef: true,
+	}));
+} else if (props.src === 'list') {
+	paginator = markRaw(new Paginator('notes/user-list-timeline', {
+		computedParams: computed(() => ({
+			withRenotes: props.withRenotes,
+			withFiles: props.onlyFiles ? true : undefined,
+			listId: props.list,
+		})),
+		useShallowRef: true,
+	}));
+} else if (props.src === 'channel') {
+	paginator = markRaw(new Paginator('channels/timeline', {
+		computedParams: computed(() => ({
+			channelId: props.channel,
+		})),
+		useShallowRef: true,
+	}));
+} else if (props.src === 'role') {
+	paginator = markRaw(new Paginator('roles/notes', {
+		computedParams: computed(() => ({
+			roleId: props.role,
+		})),
+		useShallowRef: true,
+	}));
+} else {
+	throw new Error('Unrecognized timeline type: ' + props.src);
+}
+
+onMounted(() => {
+	paginator.init();
+
+	if (paginator.computedParams) {
+		watch(paginator.computedParams, () => {
+			paginator.reload();
+		}, { immediate: false, deep: true });
+	}
+});
+
 function isTop() {
 	if (scrollContainer == null) return true;
 	if (rootEl.value == null) return true;
@@ -133,17 +223,6 @@ onUnmounted(() => {
 	}
 });
 
-type TimelineQueryType = {
-	antennaId?: string,
-	withRenotes?: boolean,
-	withReplies?: boolean,
-	withFiles?: boolean,
-	visibility?: string,
-	listId?: string,
-	channelId?: string,
-	roleId?: string
-};
-
 let adInsertionCounter = 0;
 
 const MIN_POLLING_INTERVAL = 1000 * 10;
@@ -204,7 +283,6 @@ function prepend(note: Misskey.entities.Note) {
 
 let connection: Misskey.ChannelConnection | null = null;
 let connection2: Misskey.ChannelConnection | null = null;
-let paginationQuery: PagingCtx;
 
 const stream = store.s.realtimeMode ? useStream() : null;
 
@@ -274,100 +352,13 @@ function disconnectChannel() {
 	if (connection2) connection2.dispose();
 }
 
-function updatePaginationQuery() {
-	let endpoint: keyof Misskey.Endpoints | null;
-	let query: TimelineQueryType | null;
-
-	if (props.src === 'antenna') {
-		endpoint = 'antennas/notes';
-		query = {
-			antennaId: props.antenna,
-		};
-	} else if (props.src === 'home') {
-		endpoint = 'notes/timeline';
-		query = {
-			withRenotes: props.withRenotes,
-			withFiles: props.onlyFiles ? true : undefined,
-		};
-	} else if (props.src === 'local') {
-		endpoint = 'notes/local-timeline';
-		query = {
-			withRenotes: props.withRenotes,
-			withReplies: props.withReplies,
-			withFiles: props.onlyFiles ? true : undefined,
-		};
-	} else if (props.src === 'social') {
-		endpoint = 'notes/hybrid-timeline';
-		query = {
-			withRenotes: props.withRenotes,
-			withReplies: props.withReplies,
-			withFiles: props.onlyFiles ? true : undefined,
-		};
-	} else if (props.src === 'global') {
-		endpoint = 'notes/global-timeline';
-		query = {
-			withRenotes: props.withRenotes,
-			withFiles: props.onlyFiles ? true : undefined,
-		};
-	} else if (props.src === 'mentions') {
-		endpoint = 'notes/mentions';
-		query = null;
-	} else if (props.src === 'directs') {
-		endpoint = 'notes/mentions';
-		query = {
-			visibility: 'specified',
-		};
-	} else if (props.src === 'list') {
-		endpoint = 'notes/user-list-timeline';
-		query = {
-			withRenotes: props.withRenotes,
-			withFiles: props.onlyFiles ? true : undefined,
-			listId: props.list,
-		};
-	} else if (props.src === 'channel') {
-		endpoint = 'channels/timeline';
-		query = {
-			channelId: props.channel,
-		};
-	} else if (props.src === 'role') {
-		endpoint = 'roles/notes';
-		query = {
-			roleId: props.role,
-		};
-	} else {
-		throw new Error('Unrecognized timeline type: ' + props.src);
-	}
-
-	paginationQuery = {
-		endpoint: endpoint,
-		limit: 10,
-		params: query,
-	};
-}
-
-function refreshEndpointAndChannel() {
+watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], () => {
 	if (store.s.realtimeMode) {
 		disconnectChannel();
 		connectChannel();
 	}
-
-	updatePaginationQuery();
-}
-
-// デッキのリストカラムでwithRenotesを変更した場合に自動的に更新されるようにさせる
-// IDが切り替わったら切り替え先のTLを表示させたい
-watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel);
-
-// withSensitiveはクライアントで完結する処理のため、単にリロードするだけでOK
-watch(() => props.withSensitive, reloadTimeline);
-
-// 初回表示用
-refreshEndpointAndChannel();
-
-const paginator = usePagination({
-	ctx: paginationQuery,
-	useShallowRef: true,
 });
+watch(() => props.withSensitive, reloadTimeline);
 
 onUnmounted(() => {
 	disconnectChannel();
-- 
cgit v1.2.3-freya


From 933e252687ba84864e8d52c5a233f68d168638c6 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sun, 29 Jun 2025 15:36:39 +0900
Subject: fix of f1deb89e348eb8f1a39b51e33a0ae33d59529feb

---
 packages/frontend/src/components/MkStreamingNotesTimeline.vue | 4 ++++
 1 file changed, 4 insertions(+)

(limited to 'packages/frontend/src/components/MkStreamingNotesTimeline.vue')

diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
index 69602f4c1c..6e87c02949 100644
--- a/packages/frontend/src/components/MkStreamingNotesTimeline.vue
+++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
@@ -352,6 +352,10 @@ function disconnectChannel() {
 	if (connection2) connection2.dispose();
 }
 
+if (store.s.realtimeMode) {
+	connectChannel();
+}
+
 watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], () => {
 	if (store.s.realtimeMode) {
 		disconnectChannel();
-- 
cgit v1.2.3-freya


From 09a5e4b10aad85d27875f3cdc8f32bd820615978 Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Thu, 3 Jul 2025 11:20:26 +0900
Subject: fix(frontend): Paginatorの型エラー解消 (#16230)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* fix(frontend): fix paginator type error

* fix

* refactor

* fix

* fix

* fix(paginator): remove readonly type

* fix

* typo

* fix: R -> E

* remove any

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
 packages/frontend/src/components/MkChannelList.vue |   7 +-
 .../frontend/src/components/MkNotesTimeline.vue    |   5 +-
 packages/frontend/src/components/MkPagination.vue  |  10 +-
 .../src/components/MkPaginationControl.vue         |   4 +-
 .../src/components/MkStreamingNotesTimeline.vue    |  18 +-
 .../MkStreamingNotificationsTimeline.vue           |   2 +-
 packages/frontend/src/components/MkUserList.vue    |   7 +-
 .../src/components/MkUserSetupDialog.Follow.vue    |   5 +-
 packages/frontend/src/pages/admin/files.vue        |   4 +-
 packages/frontend/src/pages/admin/invites.vue      |   7 +-
 packages/frontend/src/pages/admin/roles.role.vue   |  10 +-
 packages/frontend/src/pages/admin/users.vue        |   4 +-
 packages/frontend/src/pages/invite.vue             |   2 +-
 packages/frontend/src/pages/note.vue               |   4 +-
 packages/frontend/src/pages/page.vue               |   4 +-
 .../frontend/src/pages/settings/drive-cleaner.vue  |   3 +-
 .../frontend/src/pages/settings/mute-block.vue     |  12 +-
 packages/frontend/src/utility/paginator.ts         | 246 +++++++++++++--------
 18 files changed, 216 insertions(+), 138 deletions(-)

(limited to 'packages/frontend/src/components/MkStreamingNotesTimeline.vue')

diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue
index 7f82e531ae..394dcb6bd1 100644
--- a/packages/frontend/src/components/MkChannelList.vue
+++ b/packages/frontend/src/components/MkChannelList.vue
@@ -14,15 +14,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import type { Paginator } from '@/utility/paginator.js';
+import * as Misskey from 'misskey-js';
+import type { IPaginator } from '@/utility/paginator.js';
 import MkChannelPreview from '@/components/MkChannelPreview.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import { i18n } from '@/i18n.js';
 
 const props = withDefaults(defineProps<{
-	paginator: Paginator;
+	paginator: IPaginator;
 	noGap?: boolean;
-	extractor?: (item: any) => any;
+	extractor?: (item: any) => Misskey.entities.Channel;
 }>(), {
 	extractor: (item) => item,
 });
diff --git a/packages/frontend/src/components/MkNotesTimeline.vue b/packages/frontend/src/components/MkNotesTimeline.vue
index 1ae97fd0c0..83af7db26f 100644
--- a/packages/frontend/src/components/MkNotesTimeline.vue
+++ b/packages/frontend/src/components/MkNotesTimeline.vue
@@ -31,8 +31,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </MkPagination>
 </template>
 
-<script lang="ts" setup generic="T extends Paginator">
-import type { Paginator } from '@/utility/paginator.js';
+<script lang="ts" setup generic="T extends IPaginator<Misskey.entities.Note>">
+import * as Misskey from 'misskey-js';
+import type { IPaginator } from '@/utility/paginator.js';
 import MkNote from '@/components/MkNote.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index fb9cd6e1f0..8ca1c80e84 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</div>
 
 			<div v-else key="_root_" class="_gaps">
-				<slot :items="paginator.items.value" :fetching="paginator.fetching.value || paginator.fetchingOlder.value"></slot>
+				<slot :items="unref(paginator.items)" :fetching="paginator.fetching.value || paginator.fetchingOlder.value"></slot>
 				<div v-if="paginator.order.value === 'oldest'">
 					<MkButton v-if="!paginator.fetchingNewer.value" :class="$style.more" :wait="paginator.fetchingNewer.value" primary rounded @click="paginator.fetchNewer()">
 						{{ i18n.ts.loadMore }}
@@ -44,11 +44,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 </component>
 </template>
 
-<script lang="ts" setup generic="T extends Paginator, I = UnwrapRef<T['items']>">
+<script lang="ts" setup generic="T extends IPaginator">
 import { isLink } from '@@/js/is-link.js';
-import { onMounted, watch } from 'vue';
+import { onMounted, watch, unref } from 'vue';
 import type { UnwrapRef } from 'vue';
-import type { Paginator } from '@/utility/paginator.js';
+import type { IPaginator } from '@/utility/paginator.js';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
 import { prefer } from '@/preferences.js';
@@ -95,7 +95,7 @@ if (props.paginator.computedParams) {
 
 defineSlots<{
 	empty: () => void;
-	default: (props: { items: I }) => void;
+	default: (props: { items: UnwrapRef<T['items']> }) => void;
 }>();
 </script>
 
diff --git a/packages/frontend/src/components/MkPaginationControl.vue b/packages/frontend/src/components/MkPaginationControl.vue
index 91630eca35..10bed575a4 100644
--- a/packages/frontend/src/components/MkPaginationControl.vue
+++ b/packages/frontend/src/components/MkPaginationControl.vue
@@ -37,9 +37,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 </div>
 </template>
 
-<script lang="ts" setup generic="T extends Paginator">
+<script lang="ts" setup generic="T extends IPaginator">
 import { ref, watch } from 'vue';
-import type { Paginator } from '@/utility/paginator.js';
+import type { IPaginator } from '@/utility/paginator.js';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
 import MkSelect from '@/components/MkSelect.vue';
diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
index 6e87c02949..b697e18f79 100644
--- a/packages/frontend/src/components/MkStreamingNotesTimeline.vue
+++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
@@ -75,6 +75,7 @@ import { i18n } from '@/i18n.js';
 import { globalEvents, useGlobalEvent } from '@/events.js';
 import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
 import { Paginator } from '@/utility/paginator.js';
+import type { IPaginator, MisskeyEntity } from '@/utility/paginator.js';
 
 const props = withDefaults(defineProps<{
 	src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
@@ -101,12 +102,12 @@ provide('inTimeline', true);
 provide('tl_withSensitive', computed(() => props.withSensitive));
 provide('inChannel', computed(() => props.src === 'channel'));
 
-let paginator: Paginator;
+let paginator: IPaginator<Misskey.entities.Note>;
 
 if (props.src === 'antenna') {
 	paginator = markRaw(new Paginator('antennas/notes', {
 		computedParams: computed(() => ({
-			antennaId: props.antenna,
+			antennaId: props.antenna!,
 		})),
 		useShallowRef: true,
 	}));
@@ -160,21 +161,21 @@ if (props.src === 'antenna') {
 		computedParams: computed(() => ({
 			withRenotes: props.withRenotes,
 			withFiles: props.onlyFiles ? true : undefined,
-			listId: props.list,
+			listId: props.list!,
 		})),
 		useShallowRef: true,
 	}));
 } else if (props.src === 'channel') {
 	paginator = markRaw(new Paginator('channels/timeline', {
 		computedParams: computed(() => ({
-			channelId: props.channel,
+			channelId: props.channel!,
 		})),
 		useShallowRef: true,
 	}));
 } else if (props.src === 'role') {
 	paginator = markRaw(new Paginator('roles/notes', {
 		computedParams: computed(() => ({
-			roleId: props.role,
+			roleId: props.role!,
 		})),
 		useShallowRef: true,
 	}));
@@ -259,7 +260,7 @@ function releaseQueue() {
 	scrollToTop(rootEl.value);
 }
 
-function prepend(note: Misskey.entities.Note) {
+function prepend(note: Misskey.entities.Note & MisskeyEntity) {
 	adInsertionCounter++;
 
 	if (instance.notesPerOneAd > 0 && adInsertionCounter % instance.notesPerOneAd === 0) {
@@ -281,12 +282,13 @@ function prepend(note: Misskey.entities.Note) {
 	}
 }
 
-let connection: Misskey.ChannelConnection | null = null;
-let connection2: Misskey.ChannelConnection | null = null;
+let connection: Misskey.IChannelConnection | null = null;
+let connection2: Misskey.IChannelConnection | null = null;
 
 const stream = store.s.realtimeMode ? useStream() : null;
 
 function connectChannel() {
+	if (stream == null) return;
 	if (props.src === 'antenna') {
 		if (props.antenna == null) return;
 		connection = stream.useChannel('antenna', {
diff --git a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
index 04b230277c..869d848d90 100644
--- a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
+++ b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
@@ -109,7 +109,7 @@ function reload() {
 	return paginator.reload();
 }
 
-let connection: Misskey.ChannelConnection<Misskey.Channels['main']> | null = null;
+let connection: Misskey.IChannelConnection<Misskey.Channels['main']> | null = null;
 
 onMounted(() => {
 	paginator.init();
diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue
index 761e1cdd04..c639e18b1d 100644
--- a/packages/frontend/src/components/MkUserList.vue
+++ b/packages/frontend/src/components/MkUserList.vue
@@ -16,15 +16,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import type { Paginator } from '@/utility/paginator.js';
+import * as Misskey from 'misskey-js';
+import type { IPaginator } from '@/utility/paginator.js';
 import MkUserInfo from '@/components/MkUserInfo.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import { i18n } from '@/i18n.js';
 
 const props = withDefaults(defineProps<{
-	paginator: Paginator;
+	paginator: IPaginator;
 	noGap?: boolean;
-	extractor?: (item: any) => any;
+	extractor?: (item: any) => Misskey.entities.UserDetailed;
 }>(), {
 	extractor: (item) => item,
 });
diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue
index 02171a123d..c853eb5054 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<MkPagination :paginator="pinnedUsersPaginator">
 			<template #default="{ items }">
 				<div :class="$style.users">
-					<XUser v-for="item in (items as Misskey.entities.UserDetailed[])" :key="item.id" :user="item"/>
+					<XUser v-for="item in items" :key="item.id" :user="item"/>
 				</div>
 			</template>
 		</MkPagination>
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<MkPagination :paginator="popularUsersPaginator">
 			<template #default="{ items }">
 				<div :class="$style.users">
-					<XUser v-for="item in (items as Misskey.entities.UserDetailed[])" :key="item.id" :user="item"/>
+					<XUser v-for="item in items" :key="item.id" :user="item"/>
 				</div>
 			</template>
 		</MkPagination>
@@ -34,7 +34,6 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import * as Misskey from 'misskey-js';
 import { markRaw } from 'vue';
 import { i18n } from '@/i18n.js';
 import MkFolder from '@/components/MkFolder.vue';
diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue
index 0f3a90b458..b4ec930997 100644
--- a/packages/frontend/src/pages/admin/files.vue
+++ b/packages/frontend/src/pages/admin/files.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<option value="local">{{ i18n.ts.local }}</option>
 					<option value="remote">{{ i18n.ts.remote }}</option>
 				</MkSelect>
-				<MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="paginator.computedParams.value.origin === 'local'">
+				<MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="paginator.computedParams?.value?.origin === 'local'">
 					<template #label>{{ i18n.ts.host }}</template>
 				</MkInput>
 			</div>
@@ -44,7 +44,7 @@ import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
 import { Paginator } from '@/utility/paginator.js';
 
-const origin = ref<Misskey.entities.AdminDriveFilesRequest['origin']>('local');
+const origin = ref<NonNullable<Misskey.entities.AdminDriveFilesRequest['origin']>>('local');
 const type = ref<string | null>(null);
 const searchHost = ref('');
 const userId = ref('');
diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue
index e1b4890513..1c551cb477 100644
--- a/packages/frontend/src/pages/admin/invites.vue
+++ b/packages/frontend/src/pages/admin/invites.vue
@@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<MkPagination :paginator="paginator">
 				<template #default="{ items }">
 					<div class="_gaps_s">
-						<MkInviteCode v-for="item in items" :key="item.id" :invite="(item as any)" :onDeleted="deleted" moderator/>
+						<MkInviteCode v-for="item in items" :key="item.id" :invite="item" :onDeleted="deleted" moderator/>
 					</div>
 				</template>
 			</MkPagination>
@@ -54,6 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
+import * as Misskey from 'misskey-js';
 import { computed, markRaw, ref, useTemplateRef } from 'vue';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
@@ -68,8 +69,8 @@ import MkInviteCode from '@/components/MkInviteCode.vue';
 import { definePage } from '@/page.js';
 import { Paginator } from '@/utility/paginator.js';
 
-const type = ref('all');
-const sort = ref('+createdAt');
+const type = ref<NonNullable<Misskey.entities.AdminInviteListRequest['type']>>('all');
+const sort = ref<NonNullable<Misskey.entities.AdminInviteListRequest['sort']>>('+createdAt');
 
 const paginator = markRaw(new Paginator('admin/invite/list', {
 	limit: 10,
diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue
index 64b6231398..1816aec21e 100644
--- a/packages/frontend/src/pages/admin/roles.role.vue
+++ b/packages/frontend/src/pages/admin/roles.role.vue
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 						<template #default="{ items }">
 							<div class="_gaps_s">
-								<div v-for="item in items" :key="item.user.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedItems.includes(item.id) }]">
+								<div v-for="item in items" :key="item.user.id" :class="[$style.userItem, { [$style.userItemOpened]: expandedItems.includes(item.id) }]">
 									<div :class="$style.userItemMain">
 										<MkA :class="$style.userItemMainBody" :to="`/admin/user/${item.user.id}`">
 											<MkUserCardMini :user="item.user"/>
@@ -76,12 +76,12 @@ const props = defineProps<{
 
 const usersPaginator = markRaw(new Paginator('admin/roles/users', {
 	limit: 20,
-	computedParams: computed(() => ({
+	computedParams: computed(() => props.id ? ({
 		roleId: props.id,
-	})),
+	}) : undefined),
 }));
 
-const expandedItems = ref([]);
+const expandedItems = ref<string[]>([]);
 
 const role = reactive(await misskeyApi('admin/roles/show', {
 	roleId: props.id,
@@ -199,7 +199,7 @@ definePage(() => ({
 	transition: transform 0.1s ease-out;
 }
 
-.userItem.userItemOpend {
+.userItem.userItemOpened {
 	.chevron {
 		transform: rotateX(180deg);
 	}
diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue
index 581eb7eb97..7cbaeba8c7 100644
--- a/packages/frontend/src/pages/admin/users.vue
+++ b/packages/frontend/src/pages/admin/users.vue
@@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<template #prefix>@</template>
 					<template #label>{{ i18n.ts.username }}</template>
 				</MkInput>
-				<MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="paginator.computedParams.value.origin === 'local'">
+				<MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="paginator.computedParams?.value?.origin === 'local'">
 					<template #prefix>@</template>
 					<template #label>{{ i18n.ts.host }}</template>
 				</MkInput>
@@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 			<MkPagination v-slot="{items}" :paginator="paginator">
 				<div :class="$style.users">
-					<MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" :class="$style.user" :to="`/admin/user/${user.id}`">
+					<MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${user.updatedAt ? dateString(user.updatedAt) : 'Unknown'}`" :class="$style.user" :to="`/admin/user/${user.id}`">
 						<MkUserCardMini :user="user"/>
 					</MkA>
 				</div>
diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue
index d69b7984c0..3b8929aeb4 100644
--- a/packages/frontend/src/pages/invite.vue
+++ b/packages/frontend/src/pages/invite.vue
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<MkPagination :paginator="paginator">
 				<template #default="{ items }">
 					<div class="_gaps_s">
-						<MkInviteCode v-for="item in (items as Misskey.entities.InviteCode[])" :key="item.id" :invite="item" :onDeleted="deleted"/>
+						<MkInviteCode v-for="item in items" :key="item.id" :invite="item" :onDeleted="deleted"/>
 					</div>
 				</template>
 			</MkPagination>
diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue
index b128cf5312..9de1ef099b 100644
--- a/packages/frontend/src/pages/note.vue
+++ b/packages/frontend/src/pages/note.vue
@@ -100,7 +100,7 @@ const prevChannelPaginator = markRaw(new Paginator('channels/timeline', {
 	limit: 10,
 	initialId: props.noteId,
 	initialDirection: 'older',
-	computedParams: computed(() => note.value ? ({
+	computedParams: computed(() => note.value && note.value.channelId != null ? ({
 		channelId: note.value.channelId,
 	}) : undefined),
 }));
@@ -109,7 +109,7 @@ const nextChannelPaginator = markRaw(new Paginator('channels/timeline', {
 	limit: 10,
 	initialId: props.noteId,
 	initialDirection: 'newer',
-	computedParams: computed(() => note.value ? ({
+	computedParams: computed(() => note.value && note.value.channelId != null ? ({
 		channelId: note.value.channelId,
 	}) : undefined),
 }));
diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue
index 99c147c8cf..cd63e51fd5 100644
--- a/packages/frontend/src/pages/page.vue
+++ b/packages/frontend/src/pages/page.vue
@@ -135,9 +135,9 @@ const page = ref<Misskey.entities.Page | null>(null);
 const error = ref<any>(null);
 const otherPostsPaginator = markRaw(new Paginator('users/pages', {
 	limit: 6,
-	computedParams: computed(() => ({
+	computedParams: computed(() => page.value ? ({
 		userId: page.value.user.id,
-	})),
+	}) : undefined),
 }));
 const path = computed(() => props.username + '/' + props.pageName);
 
diff --git a/packages/frontend/src/pages/settings/drive-cleaner.vue b/packages/frontend/src/pages/settings/drive-cleaner.vue
index 24f0fa5153..63b3c95233 100644
--- a/packages/frontend/src/pages/settings/drive-cleaner.vue
+++ b/packages/frontend/src/pages/settings/drive-cleaner.vue
@@ -48,6 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script setup lang="ts">
+import * as Misskey from 'misskey-js';
 import { computed, markRaw, ref, watch } from 'vue';
 import tinycolor from 'tinycolor2';
 import type { StyleValue } from 'vue';
@@ -62,7 +63,7 @@ import MkSelect from '@/components/MkSelect.vue';
 import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js';
 import { Paginator } from '@/utility/paginator.js';
 
-const sortMode = ref('+size');
+const sortMode = ref<Misskey.entities.DriveFilesRequest['sort']>('+size');
 const paginator = markRaw(new Paginator('drive/files', {
 	limit: 10,
 	computedParams: computed(() => ({ sort: sortMode.value })),
diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue
index 3002cd0e89..6ca313da81 100644
--- a/packages/frontend/src/pages/settings/mute-block.vue
+++ b/packages/frontend/src/pages/settings/mute-block.vue
@@ -208,9 +208,9 @@ const blockingPaginator = markRaw(new Paginator('blocking/list', {
 	limit: 10,
 }));
 
-const expandedRenoteMuteItems = ref([]);
-const expandedMuteItems = ref([]);
-const expandedBlockItems = ref([]);
+const expandedRenoteMuteItems = ref<string[]>([]);
+const expandedMuteItems = ref<string[]>([]);
+const expandedBlockItems = ref<string[]>([]);
 
 const showSoftWordMutedWord = prefer.model('showSoftWordMutedWord');
 
@@ -253,7 +253,7 @@ async function unblock(user, ev) {
 	}], ev.currentTarget ?? ev.target);
 }
 
-async function toggleRenoteMuteItem(item) {
+async function toggleRenoteMuteItem(item: { id: string }) {
 	if (expandedRenoteMuteItems.value.includes(item.id)) {
 		expandedRenoteMuteItems.value = expandedRenoteMuteItems.value.filter(x => x !== item.id);
 	} else {
@@ -261,7 +261,7 @@ async function toggleRenoteMuteItem(item) {
 	}
 }
 
-async function toggleMuteItem(item) {
+async function toggleMuteItem(item: { id: string }) {
 	if (expandedMuteItems.value.includes(item.id)) {
 		expandedMuteItems.value = expandedMuteItems.value.filter(x => x !== item.id);
 	} else {
@@ -269,7 +269,7 @@ async function toggleMuteItem(item) {
 	}
 }
 
-async function toggleBlockItem(item) {
+async function toggleBlockItem(item: { id: string }) {
 	if (expandedBlockItems.value.includes(item.id)) {
 		expandedBlockItems.value = expandedBlockItems.value.filter(x => x !== item.id);
 	} else {
diff --git a/packages/frontend/src/utility/paginator.ts b/packages/frontend/src/utility/paginator.ts
index eb805df7a8..63525b311a 100644
--- a/packages/frontend/src/utility/paginator.ts
+++ b/packages/frontend/src/utility/paginator.ts
@@ -5,7 +5,7 @@
 
 import { ref, shallowRef, triggerRef } from 'vue';
 import * as Misskey from 'misskey-js';
-import type { ComputedRef, DeepReadonly, Ref, ShallowRef } from 'vue';
+import type { ComputedRef, Ref, ShallowRef } from 'vue';
 import { misskeyApi } from '@/utility/misskey-api.js';
 
 const MAX_ITEMS = 30;
@@ -17,14 +17,60 @@ export type MisskeyEntity = {
 	id: string;
 	createdAt: string;
 	_shouldInsertAd_?: boolean;
-	[x: string]: any;
 };
 
-export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, T extends { id: string; } = (Misskey.Endpoints[Endpoint]['res'] extends (infer I)[] ? I extends { id: string } ? I : { id: string } : { id: string })> {
+type FilterByEpRes<E extends Record<string, any>> = {
+	[K in keyof E]: E[K]['res'] extends Array<{ id: string }> ? K : never
+}[keyof E];
+export type PaginatorCompatibleEndpointPaths = FilterByEpRes<Misskey.Endpoints>;
+export type PaginatorCompatibleEndpoints = {
+	[K in PaginatorCompatibleEndpointPaths]: Misskey.Endpoints[K];
+};
+
+export interface IPaginator<T = unknown, _T = T & MisskeyEntity> {
+	/**
+	 * 外部から直接操作しないでください
+	 */
+	items: Ref<_T[]> | ShallowRef<_T[]>;
+	queuedAheadItemsCount: Ref<number>;
+	fetching: Ref<boolean>;
+	fetchingOlder: Ref<boolean>;
+	fetchingNewer: Ref<boolean>;
+	canFetchOlder: Ref<boolean>;
+	canSearch: boolean;
+	error: Ref<boolean>;
+	computedParams: ComputedRef<Misskey.Endpoints[PaginatorCompatibleEndpointPaths]['req'] | null | undefined> | null;
+	initialId: MisskeyEntity['id'] | null;
+	initialDate: number | null;
+	initialDirection: 'newer' | 'older';
+	noPaging: boolean;
+	searchQuery: Ref<null | string>;
+	order: Ref<'newest' | 'oldest'>;
+
+	init(): Promise<void>;
+	reload(): Promise<void>;
+	fetchOlder(): Promise<void>;
+	fetchNewer(options?: { toQueue?: boolean }): Promise<void>;
+	trim(trigger?: boolean): void;
+	unshiftItems(newItems: (_T)[]): void;
+	pushItems(oldItems: (_T)[]): void;
+	prepend(item: _T): void;
+	enqueue(item: _T): void;
+	releaseQueue(): void;
+	removeItem(id: string): void;
+	updateItem(id: string, updater: (item: _T) => _T): void;
+}
+
+export class Paginator<
+	Endpoint extends PaginatorCompatibleEndpointPaths,
+	E extends PaginatorCompatibleEndpoints[Endpoint] = PaginatorCompatibleEndpoints[Endpoint],
+	T extends E['res'][number] & MisskeyEntity = E['res'][number] & MisskeyEntity,
+	SRef extends boolean = false,
+> implements IPaginator {
 	/**
 	 * 外部から直接操作しないでください
 	 */
-	public items: ShallowRef<T[]> | Ref<T[]>;
+	public items: SRef extends true ? ShallowRef<T[]> : Ref<T[]>;
 
 	public queuedAheadItemsCount = ref(0);
 	public fetching = ref(true);
@@ -35,18 +81,18 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey.
 	public error = ref(false);
 	private endpoint: Endpoint;
 	private limit: number;
-	private params: Misskey.Endpoints[Endpoint]['req'] | (() => Misskey.Endpoints[Endpoint]['req']);
-	public computedParams: ComputedRef<Misskey.Endpoints[Endpoint]['req']> | null;
+	private params: E['req'] | (() => E['req']);
+	public computedParams: ComputedRef<E['req'] | null | undefined> | null;
 	public initialId: MisskeyEntity['id'] | null = null;
 	public initialDate: number | null = null;
 	public initialDirection: 'newer' | 'older';
 	private offsetMode: boolean;
 	public noPaging: boolean;
 	public searchQuery = ref<null | string>('');
-	private searchParamName: string;
+	private searchParamName: keyof E['req'] | 'search';
 	private canFetchDetection: 'safe' | 'limit' | null = null;
 	private aheadQueue: T[] = [];
-	private useShallowRef: boolean;
+	private useShallowRef: SRef;
 
 	// 配列内の要素をどのような順序で並べるか
 	// newest: 新しいものが先頭 (default)
@@ -56,8 +102,8 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey.
 
 	constructor(endpoint: Endpoint, props: {
 		limit?: number;
-		params?: Misskey.Endpoints[Endpoint]['req'] | (() => Misskey.Endpoints[Endpoint]['req']);
-		computedParams?: ComputedRef<Misskey.Endpoints[Endpoint]['req']>;
+		params?: E['req'] | (() => E['req']);
+		computedParams?: ComputedRef<E['req'] | null | undefined>;
 
 		/**
 		 * 検索APIのような、ページング不可なエンドポイントを利用する場合
@@ -75,14 +121,19 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey.
 		// 一部のAPIはさらに遡れる場合でもパフォーマンス上の理由でlimit以下の結果を返す場合があり、その場合はsafe、それ以外はlimitにすることを推奨
 		canFetchDetection?: 'safe' | 'limit';
 
-		useShallowRef?: boolean;
+		useShallowRef?: SRef;
 
 		canSearch?: boolean;
-		searchParamName?: keyof Misskey.Endpoints[Endpoint]['req'];
+		searchParamName?: keyof E['req'];
 	}) {
 		this.endpoint = endpoint;
-		this.useShallowRef = props.useShallowRef ?? false;
-		this.items = this.useShallowRef ? shallowRef([] as T[]) : ref([] as T[]);
+		this.useShallowRef = (props.useShallowRef ?? false) as SRef;
+		if (this.useShallowRef) {
+			this.items = shallowRef<T[]>([]);
+		} else {
+			this.items = ref<T[]>([]) as Ref<T[]>;
+		}
+
 		this.limit = props.limit ?? FIRST_FETCH_LIMIT;
 		this.params = props.params ?? {};
 		this.computedParams = props.computedParams ?? null;
@@ -130,7 +181,7 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey.
 		this.queuedAheadItemsCount.value = 0;
 		this.fetching.value = true;
 
-		await misskeyApi<T[]>(this.endpoint, {
+		const data: E['req'] = {
 			...(typeof this.params === 'function' ? this.params() : this.params),
 			...(this.computedParams ? this.computedParams.value : {}),
 			...(this.searchQuery.value != null && this.searchQuery.value.trim() !== '' ? { [this.searchParamName]: this.searchQuery.value } : {}),
@@ -145,39 +196,46 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey.
 				untilId: this.initialId ?? undefined,
 				untilDate: this.initialDate ?? undefined,
 			} : {}),
-		}).then(res => {
-			// 逆順で返ってくるので
-			if ((this.initialId || this.initialDate) && this.initialDirection === 'newer') {
-				res.reverse();
-			}
+		};
 
-			for (let i = 0; i < res.length; i++) {
-				const item = res[i];
-				if (i === 3) item._shouldInsertAd_ = true;
-			}
+		const apiRes = (await misskeyApi(this.endpoint, data).catch(err => {
+			this.error.value = true;
+			this.fetching.value = false;
+			return null;
+		})) as T[] | null;
+
+		if (apiRes == null) {
+			return;
+		}
+
+		// 逆順で返ってくるので
+		if ((this.initialId || this.initialDate) && this.initialDirection === 'newer') {
+			apiRes.reverse();
+		}
+
+		for (let i = 0; i < apiRes.length; i++) {
+			const item = apiRes[i];
+			if (i === 3) item._shouldInsertAd_ = true;
+		}
+
+		this.pushItems(apiRes);
 
-			this.pushItems(res);
-
-			if (this.canFetchDetection === 'limit') {
-				if (res.length < FIRST_FETCH_LIMIT) {
-					this.canFetchOlder.value = false;
-				} else {
-					this.canFetchOlder.value = true;
-				}
-			} else if (this.canFetchDetection === 'safe' || this.canFetchDetection == null) {
-				if (res.length === 0 || this.noPaging) {
-					this.canFetchOlder.value = false;
-				} else {
-					this.canFetchOlder.value = true;
-				}
+		if (this.canFetchDetection === 'limit') {
+			if (apiRes.length < FIRST_FETCH_LIMIT) {
+				this.canFetchOlder.value = false;
+			} else {
+				this.canFetchOlder.value = true;
 			}
+		} else if (this.canFetchDetection === 'safe' || this.canFetchDetection == null) {
+			if (apiRes.length === 0 || this.noPaging) {
+				this.canFetchOlder.value = false;
+			} else {
+				this.canFetchOlder.value = true;
+			}
+		}
 
-			this.error.value = false;
-			this.fetching.value = false;
-		}, err => {
-			this.error.value = true;
-			this.fetching.value = false;
-		});
+		this.error.value = false;
+		this.fetching.value = false;
 	}
 
 	public reload(): Promise<void> {
@@ -187,7 +245,8 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey.
 	public async fetchOlder(): Promise<void> {
 		if (!this.canFetchOlder.value || this.fetching.value || this.fetchingOlder.value || this.items.value.length === 0) return;
 		this.fetchingOlder.value = true;
-		await misskeyApi<T[]>(this.endpoint, {
+
+		const data: E['req'] = {
 			...(typeof this.params === 'function' ? this.params() : this.params),
 			...(this.computedParams ? this.computedParams.value : {}),
 			...(this.searchQuery.value != null && this.searchQuery.value.trim() !== '' ? { [this.searchParamName]: this.searchQuery.value } : {}),
@@ -197,37 +256,46 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey.
 			} : {
 				untilId: this.getOldestId(),
 			}),
-		}).then(res => {
-			for (let i = 0; i < res.length; i++) {
-				const item = res[i];
-				if (i === 10) item._shouldInsertAd_ = true;
-			}
+		};
+
+		const apiRes = (await misskeyApi<T[]>(this.endpoint, data).catch(err => {
+			return null;
+		})) as T[] | null;
+
+		this.fetchingOlder.value = false;
+
+		if (apiRes == null) {
+			return;
+		}
+
+		for (let i = 0; i < apiRes.length; i++) {
+			const item = apiRes[i];
+			if (i === 10) item._shouldInsertAd_ = true;
+		}
+
+		this.pushItems(apiRes);
 
-			this.pushItems(res);
-
-			if (this.canFetchDetection === 'limit') {
-				if (res.length < FIRST_FETCH_LIMIT) {
-					this.canFetchOlder.value = false;
-				} else {
-					this.canFetchOlder.value = true;
-				}
-			} else if (this.canFetchDetection === 'safe' || this.canFetchDetection == null) {
-				if (res.length === 0) {
-					this.canFetchOlder.value = false;
-				} else {
-					this.canFetchOlder.value = true;
-				}
+		if (this.canFetchDetection === 'limit') {
+			if (apiRes.length < FIRST_FETCH_LIMIT) {
+				this.canFetchOlder.value = false;
+			} else {
+				this.canFetchOlder.value = true;
+			}
+		} else if (this.canFetchDetection === 'safe' || this.canFetchDetection == null) {
+			if (apiRes.length === 0) {
+				this.canFetchOlder.value = false;
+			} else {
+				this.canFetchOlder.value = true;
 			}
-		}).finally(() => {
-			this.fetchingOlder.value = false;
-		});
+		}
 	}
 
 	public async fetchNewer(options: {
 		toQueue?: boolean;
 	} = {}): Promise<void> {
 		this.fetchingNewer.value = true;
-		await misskeyApi<T[]>(this.endpoint, {
+
+		const data: E['req'] = {
 			...(typeof this.params === 'function' ? this.params() : this.params),
 			...(this.computedParams ? this.computedParams.value : {}),
 			...(this.searchQuery.value != null && this.searchQuery.value.trim() !== '' ? { [this.searchParamName]: this.searchQuery.value } : {}),
@@ -237,25 +305,29 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey.
 			} : {
 				sinceId: this.getNewestId(),
 			}),
-		}).then(res => {
-			if (res.length === 0) return; // これやらないと余計なre-renderが走る
-
-			if (options.toQueue) {
-				this.aheadQueue.unshift(...res.toReversed());
-				if (this.aheadQueue.length > MAX_QUEUE_ITEMS) {
-					this.aheadQueue = this.aheadQueue.slice(0, MAX_QUEUE_ITEMS);
-				}
-				this.queuedAheadItemsCount.value = this.aheadQueue.length;
+		};
+
+		const apiRes = (await misskeyApi<T[]>(this.endpoint, data).catch(err => {
+			return null;
+		})) as T[] | null;
+
+		this.fetchingNewer.value = false;
+
+		if (apiRes == null || apiRes.length === 0) return; // これやらないと余計なre-renderが走る
+
+		if (options.toQueue) {
+			this.aheadQueue.unshift(...apiRes.toReversed());
+			if (this.aheadQueue.length > MAX_QUEUE_ITEMS) {
+				this.aheadQueue = this.aheadQueue.slice(0, MAX_QUEUE_ITEMS);
+			}
+			this.queuedAheadItemsCount.value = this.aheadQueue.length;
+		} else {
+			if (this.order.value === 'oldest') {
+				this.pushItems(apiRes);
 			} else {
-				if (this.order.value === 'oldest') {
-					this.pushItems(res);
-				} else {
-					this.unshiftItems(res.toReversed());
-				}
+				this.unshiftItems(apiRes.toReversed());
 			}
-		}).finally(() => {
-			this.fetchingNewer.value = false;
-		});
+		}
 	}
 
 	public trim(trigger = true): void {
@@ -309,13 +381,13 @@ export class Paginator<Endpoint extends keyof Misskey.Endpoints = keyof Misskey.
 		}
 	}
 
-	public updateItem(id: string, updator: (item: T) => T): void {
+	public updateItem(id: string, updater: (item: T) => T): void {
 		// TODO: queueのも更新
 
 		const index = this.items.value.findIndex(x => x.id === id);
 		if (index !== -1) {
 			const item = this.items.value[index]!;
-			this.items.value[index] = updator(item);
+			this.items.value[index] = updater(item);
 			if (this.useShallowRef) triggerRef(this.items);
 		}
 	}
-- 
cgit v1.2.3-freya


From 179d990c39ed5c7f79e7481ca86b07ec9f70d474 Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Thu, 3 Jul 2025 18:52:16 +0900
Subject: fix(frontend):
 タブが不可視なあいだのpaginationのアップデートを停止するように (#16243)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* fix(frontend): タブが不可視なあいだのpaginationのアップデートを停止するように

* fix lint

* 待たない
---
 .../src/components/MkStreamingNotesTimeline.vue    | 23 ++++++++--
 .../MkStreamingNotificationsTimeline.vue           | 51 +++++++++++++++++++++-
 2 files changed, 69 insertions(+), 5 deletions(-)

(limited to 'packages/frontend/src/components/MkStreamingNotesTimeline.vue')

diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
index b697e18f79..44f873b6e3 100644
--- a/packages/frontend/src/components/MkStreamingNotesTimeline.vue
+++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
@@ -59,6 +59,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup, onMounted, shallowRef, ref, markRaw } from 'vue';
 import * as Misskey from 'misskey-js';
 import { useInterval } from '@@/js/use-interval.js';
+import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
 import { getScrollContainer, scrollToTop } from '@@/js/scroll.js';
 import type { BasicTimelineType } from '@/timelines.js';
 import type { SoundStore } from '@/preferences/def.js';
@@ -224,6 +225,20 @@ onUnmounted(() => {
 	}
 });
 
+const visibility = useDocumentVisibility();
+let isPausingUpdate = false;
+
+watch(visibility, () => {
+	if (visibility.value === 'hidden') {
+		isPausingUpdate = true;
+	} else { // 'visible'
+		isPausingUpdate = false;
+		if (isTop()) {
+			releaseQueue();
+		}
+	}
+});
+
 let adInsertionCounter = 0;
 
 const MIN_POLLING_INTERVAL = 1000 * 10;
@@ -237,7 +252,7 @@ if (!store.s.realtimeMode) {
 	// TODO: 先頭のノートの作成日時が1日以上前であれば流速が遅いTLと見做してインターバルを通常より延ばす
 	useInterval(async () => {
 		paginator.fetchNewer({
-			toQueue: !isTop(),
+			toQueue: !isTop() || isPausingUpdate,
 		});
 	}, POLLING_INTERVAL, {
 		immediate: false,
@@ -246,7 +261,7 @@ if (!store.s.realtimeMode) {
 
 	useGlobalEvent('notePosted', (note) => {
 		paginator.fetchNewer({
-			toQueue: !isTop(),
+			toQueue: !isTop() || isPausingUpdate,
 		});
 	});
 }
@@ -257,7 +272,7 @@ useGlobalEvent('noteDeleted', (noteId) => {
 
 function releaseQueue() {
 	paginator.releaseQueue();
-	scrollToTop(rootEl.value);
+	scrollToTop(rootEl.value!);
 }
 
 function prepend(note: Misskey.entities.Note & MisskeyEntity) {
@@ -267,7 +282,7 @@ function prepend(note: Misskey.entities.Note & MisskeyEntity) {
 		note._shouldInsertAd_ = true;
 	}
 
-	if (isTop()) {
+	if (isTop() && !isPausingUpdate) {
 		paginator.prepend(note);
 	} else {
 		paginator.enqueue(note);
diff --git a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
index 869d848d90..e21adab36c 100644
--- a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
+++ b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
@@ -45,7 +45,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onUnmounted, onMounted, computed, useTemplateRef, TransitionGroup, markRaw, watch } from 'vue';
 import * as Misskey from 'misskey-js';
 import { useInterval } from '@@/js/use-interval.js';
+import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
 import type { notificationTypes } from '@@/js/const.js';
+import { getScrollContainer, scrollToTop } from '@@/js/scroll.js';
 import XNotification from '@/components/MkNotification.vue';
 import MkNote from '@/components/MkNote.vue';
 import { useStream } from '@/stream.js';
@@ -92,6 +94,49 @@ if (!store.s.realtimeMode) {
 	});
 }
 
+function isTop() {
+	if (scrollContainer == null) return true;
+	if (rootEl.value == null) return true;
+	const scrollTop = scrollContainer.scrollTop;
+	const tlTop = rootEl.value.offsetTop - scrollContainer.offsetTop;
+	return scrollTop <= tlTop;
+}
+
+function releaseQueue() {
+	paginator.releaseQueue();
+	scrollToTop(rootEl.value!);
+}
+
+let scrollContainer: HTMLElement | null = null;
+
+function onScrollContainerScroll() {
+	if (isTop()) {
+		paginator.releaseQueue();
+	}
+}
+
+watch(rootEl, (el) => {
+	if (el && scrollContainer == null) {
+		scrollContainer = getScrollContainer(el);
+		if (scrollContainer == null) return;
+		scrollContainer.addEventListener('scroll', onScrollContainerScroll, { passive: true }); // ほんとはscrollendにしたいけどiosが非対応
+	}
+}, { immediate: true });
+
+const visibility = useDocumentVisibility();
+let isPausingUpdate = false;
+
+watch(visibility, () => {
+	if (visibility.value === 'hidden') {
+		isPausingUpdate = true;
+	} else { // 'visible'
+		isPausingUpdate = false;
+		if (isTop()) {
+			releaseQueue();
+		}
+	}
+});
+
 function onNotification(notification) {
 	const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;
 	if (isMuted || window.document.visibilityState === 'visible') {
@@ -101,7 +146,11 @@ function onNotification(notification) {
 	}
 
 	if (!isMuted) {
-		paginator.prepend(notification);
+		if (isTop() && !isPausingUpdate) {
+			paginator.prepend(notification);
+		} else {
+			paginator.enqueue(notification);
+		}
 	}
 }
 
-- 
cgit v1.2.3-freya


From 64eb338d65eccc722f559909975e3132b521cb08 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sat, 5 Jul 2025 11:56:20 +0900
Subject: 🎨
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 packages/frontend/src/components/MkStreamingNotesTimeline.vue         | 3 +--
 packages/frontend/src/components/MkStreamingNotificationsTimeline.vue | 3 +--
 2 files changed, 2 insertions(+), 4 deletions(-)

(limited to 'packages/frontend/src/components/MkStreamingNotesTimeline.vue')

diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
index 44f873b6e3..693f551ffc 100644
--- a/packages/frontend/src/components/MkStreamingNotesTimeline.vue
+++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
@@ -63,6 +63,7 @@ import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
 import { getScrollContainer, scrollToTop } from '@@/js/scroll.js';
 import type { BasicTimelineType } from '@/timelines.js';
 import type { SoundStore } from '@/preferences/def.js';
+import type { IPaginator, MisskeyEntity } from '@/utility/paginator.js';
 import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
 import { useStream } from '@/stream.js';
 import * as sound from '@/utility/sound.js';
@@ -76,7 +77,6 @@ import { i18n } from '@/i18n.js';
 import { globalEvents, useGlobalEvent } from '@/events.js';
 import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
 import { Paginator } from '@/utility/paginator.js';
-import type { IPaginator, MisskeyEntity } from '@/utility/paginator.js';
 
 const props = withDefaults(defineProps<{
 	src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
@@ -524,7 +524,6 @@ defineExpose({
 	align-items: center;
 	justify-content: center;
 	gap: 1em;
-	opacity: 0.75;
 	padding: 8px 8px;
 	margin: 0 auto;
 	border-bottom: solid 0.5px var(--MI_THEME-divider);
diff --git a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
index e21adab36c..0276b7eaee 100644
--- a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
+++ b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
@@ -46,8 +46,8 @@ import { onUnmounted, onMounted, computed, useTemplateRef, TransitionGroup, mark
 import * as Misskey from 'misskey-js';
 import { useInterval } from '@@/js/use-interval.js';
 import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
-import type { notificationTypes } from '@@/js/const.js';
 import { getScrollContainer, scrollToTop } from '@@/js/scroll.js';
+import type { notificationTypes } from '@@/js/const.js';
 import XNotification from '@/components/MkNotification.vue';
 import MkNote from '@/components/MkNote.vue';
 import { useStream } from '@/stream.js';
@@ -235,7 +235,6 @@ defineExpose({
 	align-items: center;
 	justify-content: center;
 	gap: 1em;
-	opacity: 0.75;
 	padding: 8px 8px;
 	margin: 0 auto;
 	border-bottom: solid 0.5px var(--MI_THEME-divider);
-- 
cgit v1.2.3-freya


From a6865927344ea0b2b97770c937fc3b79efa54f18 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Thu, 17 Jul 2025 20:02:55 +0900
Subject: enhance(frontend): disable InfiniteScroll to improve stability

#16229
---
 CHANGELOG.md                                                          | 1 +
 packages/frontend/src/components/MkStreamingNotesTimeline.vue         | 2 +-
 packages/frontend/src/components/MkStreamingNotificationsTimeline.vue | 2 +-
 packages/frontend/src/pages/settings/preferences.vue                  | 2 ++
 4 files changed, 5 insertions(+), 2 deletions(-)

(limited to 'packages/frontend/src/components/MkStreamingNotesTimeline.vue')

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 112b73f331..2a31031830 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@
 - Enhance: ウォーターマーク機能をロールで制御可能に
 
 ### Client
+- Note: 「自動でもっと見る」オプションは無効になっています
 - Feat: モデログを検索できるように
 - Enhance: 設定の自動バックアップをオンにした直後に自動バックアップするように
 - Enhance: ファイルアップロード前にキャプション設定を行えるように
diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
index 693f551ffc..3e50bdefd2 100644
--- a/packages/frontend/src/components/MkStreamingNotesTimeline.vue
+++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
@@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
 			</template>
 		</component>
-		<button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
+		<button v-show="paginator.canFetchOlder.value" key="_more_" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
 			<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
 			<MkLoading v-else :inline="true"/>
 		</button>
diff --git a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
index 0276b7eaee..4617e659c8 100644
--- a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
+++ b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
@@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<XNotification v-else :class="$style.content" :notification="notification" :withTime="true" :full="true"/>
 			</div>
 		</component>
-		<button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
+		<button v-show="paginator.canFetchOlder.value" key="_more_" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
 			<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
 			<MkLoading v-else/>
 		</button>
diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue
index ef7b73c2b1..0e400778aa 100644
--- a/packages/frontend/src/pages/settings/preferences.vue
+++ b/packages/frontend/src/pages/settings/preferences.vue
@@ -110,6 +110,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 								</MkPreferenceContainer>
 							</SearchMarker>
 
+							<!--
 							<SearchMarker :keywords="['auto', 'load', 'auto', 'more', 'scroll']">
 								<MkPreferenceContainer k="enableInfiniteScroll">
 									<MkSwitch v-model="enableInfiniteScroll">
@@ -117,6 +118,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 									</MkSwitch>
 								</MkPreferenceContainer>
 							</SearchMarker>
+							-->
 						</div>
 
 						<SearchMarker :keywords="['emoji', 'style', 'native', 'system', 'fluent', 'twemoji']">
-- 
cgit v1.2.3-freya

