From 92d9946f597c2b06552b23ba370708aea86575c0 Mon Sep 17 00:00:00 2001
From: tamaina <tamaina@hotmail.co.jp>
Date: Wed, 5 Jul 2023 00:59:37 +0900
Subject: enhance(frontend): Better Timeline(MkPagination) Experience (#11066)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* enhance(frontend): Better MkPagination Appearance

* fix

* fix

* 新規投稿が空でも先頭に戻ったらunshiftItemsする

* use Map

* refactor, 型エラー潰し

* refactor
---
 packages/frontend/src/components/MkPagination.vue | 170 ++++++++++++++++------
 1 file changed, 125 insertions(+), 45 deletions(-)

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

diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index 598529bf58..b361d568e8 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -21,14 +21,14 @@
 
 	<div v-else ref="rootEl">
 		<div v-show="pagination.reversed && more" key="_more_" class="_margin">
-			<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead">
+			<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead">
 				{{ i18n.ts.loadMore }}
 			</MkButton>
 			<MkLoading v-else class="loading"/>
 		</div>
-		<slot :items="items" :fetching="fetching || moreFetching"></slot>
+		<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
 		<div v-show="!pagination.reversed && more" key="_more_" class="_margin">
-			<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
+			<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
 				{{ i18n.ts.loadMore }}
 			</MkButton>
 			<MkLoading v-else class="loading"/>
@@ -50,6 +50,7 @@ import { i18n } from '@/i18n';
 
 const SECOND_FETCH_LIMIT = 30;
 const TOLERANCE = 16;
+const APPEAR_MINIMUM_INTERVAL = 600;
 
 export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = {
 	endpoint: E;
@@ -71,6 +72,16 @@ export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints>
 
 	pageEl?: HTMLElement;
 };
+
+type MisskeyEntityMap = Map<string, MisskeyEntity>;
+
+function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] {
+	return entities.map(en => [en.id, en]);
+}
+
+function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap {
+	return new Map([...map, ...arrayToEntries(entities)]);
+}
 </script>
 <script lang="ts" setup>
 import { infoImageUrl } from '@/instance';
@@ -94,21 +105,38 @@ let backed = $ref(false);
 
 let scrollRemove = $ref<(() => void) | null>(null);
 
-const items = ref<MisskeyEntity[]>([]);
-const queue = ref<MisskeyEntity[]>([]);
+/**
+ * 表示するアイテムのソース
+ * 最新が0番目
+ */
+const items = ref<MisskeyEntityMap>(new Map());
+
+/**
+ * タブが非アクティブなどの場合に更新を貯めておく
+ * 最新が0番目
+ */
+const queue = ref<MisskeyEntityMap>(new Map());
+
 const offset = ref(0);
+
+/**
+ * 初期化中かどうか（trueならMkLoadingで全て隠す）
+ */
 const fetching = ref(true);
+
 const moreFetching = ref(false);
 const more = ref(false);
+const preventAppearFetchMore = ref(false);
+const preventAppearFetchMoreTimer = ref<number | null>(null);
 const isBackTop = ref(false);
-const empty = computed(() => items.value.length === 0);
+const empty = computed(() => items.value.size === 0);
 const error = ref(false);
 const {
 	enableInfiniteScroll,
 } = defaultStore.reactiveState;
 
 const contentEl = $computed(() => props.pagination.pageEl ?? rootEl);
-const scrollableElement = $computed(() => getScrollContainer(contentEl));
+const scrollableElement = $computed(() => contentEl ? getScrollContainer(contentEl) : document.body);
 
 const visibility = useDocumentVisibility();
 
@@ -133,9 +161,9 @@ watch([() => props.pagination.reversed, $$(scrollableElement)], () => {
 }, { immediate: true });
 
 watch($$(rootEl), () => {
-	scrollObserver.disconnect();
+	scrollObserver?.disconnect();
 	nextTick(() => {
-		if (rootEl) scrollObserver.observe(rootEl);
+		if (rootEl) scrollObserver?.observe(rootEl);
 	});
 });
 
@@ -155,12 +183,12 @@ if (props.pagination.params && isRef(props.pagination.params)) {
 }
 
 watch(queue, (a, b) => {
-	if (a.length === 0 && b.length === 0) return;
-	emit('queue', queue.value.length);
+	if (a.size === 0 && b.size === 0) return;
+	emit('queue', queue.value.size);
 }, { deep: true });
 
 async function init(): Promise<void> {
-	queue.value = [];
+	queue.value = new Map();
 	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, {
@@ -173,11 +201,11 @@ async function init(): Promise<void> {
 		}
 
 		if (res.length === 0 || props.pagination.noPaging) {
-			items.value = res;
+			concatItems(res);
 			more.value = false;
 		} else {
 			if (props.pagination.reversed) moreFetching.value = true;
-			items.value = res;
+			concatItems(res);
 			more.value = true;
 		}
 
@@ -191,12 +219,13 @@ async function init(): Promise<void> {
 }
 
 const reload = (): Promise<void> => {
-	items.value = [];
+	items.value = new Map();
+	queue.value = new Map();
 	return init();
 };
 
 const fetchMore = async (): Promise<void> => {
-	if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
+	if (!more.value || fetching.value || moreFetching.value || items.value.size === 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, {
@@ -205,7 +234,7 @@ const fetchMore = async (): Promise<void> => {
 		...(props.pagination.offsetMode ? {
 			offset: offset.value,
 		} : {
-			untilId: items.value[items.value.length - 1].id,
+			untilId: Array.from(items.value.keys())[items.value.size - 1],
 		}),
 	}).then(res => {
 		for (let i = 0; i < res.length; i++) {
@@ -217,7 +246,7 @@ const fetchMore = async (): Promise<void> => {
 			const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight();
 			const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY;
 
-			items.value = items.value.concat(_res);
+			items.value = concatMapWithArray(items.value, _res);
 
 			return nextTick(() => {
 				if (scrollableElement) {
@@ -237,7 +266,7 @@ const fetchMore = async (): Promise<void> => {
 					moreFetching.value = false;
 				});
 			} else {
-				items.value = items.value.concat(res);
+				items.value = concatMapWithArray(items.value, res);
 				more.value = false;
 				moreFetching.value = false;
 			}
@@ -248,7 +277,7 @@ const fetchMore = async (): Promise<void> => {
 					moreFetching.value = false;
 				});
 			} else {
-				items.value = items.value.concat(res);
+				items.value = concatMapWithArray(items.value, res);
 				more.value = true;
 				moreFetching.value = false;
 			}
@@ -260,7 +289,7 @@ const fetchMore = async (): Promise<void> => {
 };
 
 const fetchMoreAhead = async (): Promise<void> => {
-	if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
+	if (!more.value || fetching.value || moreFetching.value || items.value.size === 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, {
@@ -269,14 +298,14 @@ const fetchMoreAhead = async (): Promise<void> => {
 		...(props.pagination.offsetMode ? {
 			offset: offset.value,
 		} : {
-			sinceId: items.value[items.value.length - 1].id,
+			sinceId: Array.from(items.value.keys())[items.value.size - 1],
 		}),
 	}).then(res => {
 		if (res.length === 0) {
-			items.value = items.value.concat(res);
+			items.value = concatMapWithArray(items.value, res);
 			more.value = false;
 		} else {
-			items.value = items.value.concat(res);
+			items.value = concatMapWithArray(items.value, res);
 			more.value = true;
 		}
 		offset.value += res.length;
@@ -286,7 +315,32 @@ const fetchMoreAhead = async (): Promise<void> => {
 	});
 };
 
-const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE);
+/**
+ * Appear（IntersectionObserver）によってfetchMoreが呼ばれる場合、
+ * APPEAR_MINIMUM_INTERVALミリ秒以内に2回fetchMoreが呼ばれるのを防ぐ
+ */
+const fetchMoreApperTimeoutFn = (): void => {
+	preventAppearFetchMore.value = false;
+	preventAppearFetchMoreTimer.value = null;
+};
+const fetchMoreAppearTimeout = (): void => {
+	preventAppearFetchMore.value = true;
+	preventAppearFetchMoreTimer.value = window.setTimeout(fetchMoreApperTimeoutFn, APPEAR_MINIMUM_INTERVAL);
+};
+
+const appearFetchMore = async (): Promise<void> => {
+	if (preventAppearFetchMore.value) return;
+	await fetchMore();
+	fetchMoreAppearTimeout();
+};
+
+const appearFetchMoreAhead = async (): Promise<void> => {
+	if (preventAppearFetchMore.value) return;
+	await fetchMoreAhead();
+	fetchMoreAppearTimeout();
+};
+
+const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl!, TOLERANCE);
 
 watch(visibility, () => {
 	if (visibility.value === 'hidden') {
@@ -308,10 +362,15 @@ watch(visibility, () => {
 	}
 });
 
+/**
+ * 最新のものとして1つだけアイテムを追加する
+ * ストリーミングから降ってきたアイテムはこれで追加する
+ * @param item アイテム
+ */
 const prepend = (item: MisskeyEntity): void => {
-	// 初回表示時はunshiftだけでOK
-	if (!rootEl) {
-		items.value.unshift(item);
+	if (items.value.size === 0) {
+		items.value.set(item.id, item);
+		fetching.value = false;
 		return;
 	}
 
@@ -319,38 +378,55 @@ const prepend = (item: MisskeyEntity): void => {
 	else prependQueue(item);
 };
 
+/**
+ * 新着アイテムをitemsの先頭に追加し、displayLimitを適用する
+ * @param newItems 新しいアイテムの配列
+ */
 function unshiftItems(newItems: MisskeyEntity[]) {
-	const length = newItems.length + items.value.length;
-	items.value = [...newItems, ...items.value].slice(0, props.displayLimit);
+	const length = newItems.length + items.value.size;
+	items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit));
+
+	if (length >= props.displayLimit) more.value = true;
+}
+
+/**
+ * 古いアイテムをitemsの末尾に追加し、displayLimitを適用する
+ * @param oldItems 古いアイテムの配列
+ */
+function concatItems(oldItems: MisskeyEntity[]) {
+	const length = oldItems.length + items.value.size;
+	items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit));
 
 	if (length >= props.displayLimit) more.value = true;
 }
 
 function executeQueue() {
-	if (queue.value.length === 0) return;
-	unshiftItems(queue.value);
-	queue.value = [];
+	unshiftItems(Array.from(queue.value.values()));
+	queue.value = new Map();
 }
 
 function prependQueue(newItem: MisskeyEntity) {
-	queue.value.unshift(newItem);
-	if (queue.value.length >= props.displayLimit) {
-		queue.value.pop();
-	}
+	queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]);
 }
 
+/*
+ * アイテムを末尾に追加する（使うの？）
+ */
 const appendItem = (item: MisskeyEntity): void => {
-	items.value.push(item);
+	items.value.set(item.id, item);
 };
 
-const removeItem = (finder: (item: MisskeyEntity) => boolean) => {
-	const i = items.value.findIndex(finder);
-	items.value.splice(i, 1);
+const removeItem = (id: string) => {
+	items.value.delete(id);
+	queue.value.delete(id);
 };
 
 const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => {
-	const i = items.value.findIndex(item => item.id === id);
-	items.value[i] = replacer(items.value[i]);
+	const item = items.value.get(id);
+	if (item) items.value.set(id, replacer(item));
+
+	const queueItem = queue.value.get(id);
+	if (queueItem) queue.value.set(id, replacer(queueItem));
 };
 
 const inited = init();
@@ -364,7 +440,7 @@ onDeactivated(() => {
 });
 
 function toBottom() {
-	scrollToBottom(contentEl);
+	scrollToBottom(contentEl!);
 }
 
 onMounted(() => {
@@ -388,7 +464,11 @@ onBeforeUnmount(() => {
 		clearTimeout(timerForSetPause);
 		timerForSetPause = null;
 	}
-	scrollObserver.disconnect();
+	if (preventAppearFetchMoreTimer.value) {
+		clearTimeout(preventAppearFetchMoreTimer.value);
+		preventAppearFetchMoreTimer.value = null;
+	}
+	scrollObserver?.disconnect();
 });
 
 defineExpose({
-- 
cgit v1.2.3-freya


From 06bf5c1ff1b52bc1eb72d7a3358995470ff3a97c Mon Sep 17 00:00:00 2001
From: tamaina <tamaina@hotmail.co.jp>
Date: Thu, 6 Jul 2023 06:43:05 +0000
Subject: fix(frontend): In MkPagination, init() also initializes items
 ユーザーページのノートタブで小タブを変更すると前のタイムラインが残る問題を修正
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 packages/frontend/src/components/MkPagination.vue | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

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

diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index b361d568e8..661b04c365 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -188,6 +188,7 @@ watch(queue, (a, b) => {
 }, { deep: true });
 
 async function init(): Promise<void> {
+	items.value = new Map();
 	queue.value = new Map();
 	fetching.value = true;
 	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
@@ -219,8 +220,6 @@ async function init(): Promise<void> {
 }
 
 const reload = (): Promise<void> => {
-	items.value = new Map();
-	queue.value = new Map();
 	return init();
 };
 
-- 
cgit v1.2.3-freya


From 2b6dbd4fcbec380d65b0c318932d9eeb3fcb3f7b Mon Sep 17 00:00:00 2001
From: okayurisotto <okayurisotto@proton.me>
Date: Fri, 14 Jul 2023 10:45:01 +0900
Subject: refactor: 可読性のため一部で`Array.prototype.at`を使うように (#11274)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* refactor: `Array.prototype.at`を使うように

* fixup! refactor: `Array.prototype.at`を使うように
---
 packages/backend/src/core/chart/core.ts                           | 2 +-
 packages/backend/src/misc/prelude/array.ts                        | 5 +++--
 .../src/queue/processors/CleanRemoteFilesProcessorService.ts      | 6 +++---
 .../backend/src/queue/processors/DeleteAccountProcessorService.ts | 4 ++--
 .../src/queue/processors/DeleteDriveFilesProcessorService.ts      | 6 +++---
 .../src/queue/processors/ExportBlockingProcessorService.ts        | 6 +++---
 .../src/queue/processors/ExportFavoritesProcessorService.ts       | 2 +-
 .../src/queue/processors/ExportFollowingProcessorService.ts       | 2 +-
 .../backend/src/queue/processors/ExportMutingProcessorService.ts  | 6 +++---
 .../backend/src/queue/processors/ExportNotesProcessorService.ts   | 2 +-
 packages/backend/src/server/ActivityPubServerService.ts           | 6 +++---
 packages/backend/test/utils.ts                                    | 8 ++++----
 packages/frontend/src/components/MkDrive.vue                      | 4 ++--
 packages/frontend/src/components/MkMiniChart.vue                  | 4 ++--
 packages/frontend/src/components/MkPageWindow.vue                 | 2 +-
 packages/frontend/src/components/MkPagination.vue                 | 4 ++--
 packages/frontend/src/scripts/array.ts                            | 5 +++--
 packages/frontend/src/widgets/server-metric/cpu-mem.vue           | 8 ++++----
 packages/frontend/src/widgets/server-metric/net.vue               | 8 ++++----
 19 files changed, 46 insertions(+), 44 deletions(-)

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

diff --git a/packages/backend/src/core/chart/core.ts b/packages/backend/src/core/chart/core.ts
index 5717024351..7a89233eda 100644
--- a/packages/backend/src/core/chart/core.ts
+++ b/packages/backend/src/core/chart/core.ts
@@ -627,7 +627,7 @@ export default abstract class Chart<T extends Schema> {
 			}
 
 		// 要求された範囲の最も古い箇所に位置するログが存在しなかったら
-		} else if (!isTimeSame(new Date(logs[logs.length - 1].date * 1000), gt)) {
+		} else if (!isTimeSame(new Date(logs.at(-1)!.date * 1000), gt)) {
 			// 要求された範囲の最も古い箇所時点での最も新しいログを持ってきて末尾に追加する
 			// (隙間埋めできないため)
 			const outdatedLog = await repository.findOne({
diff --git a/packages/backend/src/misc/prelude/array.ts b/packages/backend/src/misc/prelude/array.ts
index 0b2830cb7b..2524eacfb3 100644
--- a/packages/backend/src/misc/prelude/array.ts
+++ b/packages/backend/src/misc/prelude/array.ts
@@ -67,8 +67,9 @@ export function maximum(xs: number[]): number {
 export function groupBy<T>(f: EndoRelation<T>, xs: T[]): T[][] {
 	const groups = [] as T[][];
 	for (const x of xs) {
-		if (groups.length !== 0 && f(groups[groups.length - 1][0], x)) {
-			groups[groups.length - 1].push(x);
+		const lastGroup = groups.at(-1);
+		if (lastGroup !== undefined && f(lastGroup[0], x)) {
+			lastGroup.push(x);
 		} else {
 			groups.push([x]);
 		}
diff --git a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts
index c54bf59ae4..6f887089eb 100644
--- a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts
+++ b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts
@@ -1,7 +1,7 @@
 import { Inject, Injectable } from '@nestjs/common';
 import { IsNull, MoreThan, Not } from 'typeorm';
 import { DI } from '@/di-symbols.js';
-import type { DriveFilesRepository } from '@/models/index.js';
+import type { DriveFile, DriveFilesRepository } from '@/models/index.js';
 import type { Config } from '@/config.js';
 import type Logger from '@/logger.js';
 import { DriveService } from '@/core/DriveService.js';
@@ -31,7 +31,7 @@ export class CleanRemoteFilesProcessorService {
 		this.logger.info('Deleting cached remote files...');
 
 		let deletedCount = 0;
-		let cursor: any = null;
+		let cursor: DriveFile['id'] | null = null;
 
 		while (true) {
 			const files = await this.driveFilesRepository.find({
@@ -51,7 +51,7 @@ export class CleanRemoteFilesProcessorService {
 				break;
 			}
 
-			cursor = files[files.length - 1].id;
+			cursor = files.at(-1)?.id ?? null;
 
 			await Promise.all(files.map(file => this.driveService.deleteFileSync(file, true)));
 
diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts
index 65ded170b7..b2886563f4 100644
--- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts
+++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts
@@ -70,7 +70,7 @@ export class DeleteAccountProcessorService {
 					break;
 				}
 
-				cursor = notes[notes.length - 1].id;
+				cursor = notes.at(-1)?.id ?? null;
 
 				await this.notesRepository.delete(notes.map(note => note.id));
 
@@ -101,7 +101,7 @@ export class DeleteAccountProcessorService {
 					break;
 				}
 
-				cursor = files[files.length - 1].id;
+				cursor = files.at(-1)?.id ?? null;
 
 				for (const file of files) {
 					await this.driveService.deleteFileSync(file);
diff --git a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts
index 6772c5dc76..07e3762330 100644
--- a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts
+++ b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts
@@ -1,7 +1,7 @@
 import { Inject, Injectable } from '@nestjs/common';
 import { MoreThan } from 'typeorm';
 import { DI } from '@/di-symbols.js';
-import type { UsersRepository, DriveFilesRepository } from '@/models/index.js';
+import type { UsersRepository, DriveFilesRepository, DriveFile } from '@/models/index.js';
 import type { Config } from '@/config.js';
 import type Logger from '@/logger.js';
 import { DriveService } from '@/core/DriveService.js';
@@ -40,7 +40,7 @@ export class DeleteDriveFilesProcessorService {
 		}
 
 		let deletedCount = 0;
-		let cursor: any = null;
+		let cursor: DriveFile['id'] | null = null;
 
 		while (true) {
 			const files = await this.driveFilesRepository.find({
@@ -59,7 +59,7 @@ export class DeleteDriveFilesProcessorService {
 				break;
 			}
 
-			cursor = files[files.length - 1].id;
+			cursor = files.at(-1)?.id ?? null;
 
 			for (const file of files) {
 				await this.driveService.deleteFileSync(file);
diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts
index eb758e162d..d100c6d09f 100644
--- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts
@@ -3,7 +3,7 @@ import { Inject, Injectable } from '@nestjs/common';
 import { MoreThan } from 'typeorm';
 import { format as dateFormat } from 'date-fns';
 import { DI } from '@/di-symbols.js';
-import type { UsersRepository, BlockingsRepository } from '@/models/index.js';
+import type { UsersRepository, BlockingsRepository, Blocking } from '@/models/index.js';
 import type { Config } from '@/config.js';
 import type Logger from '@/logger.js';
 import { DriveService } from '@/core/DriveService.js';
@@ -53,7 +53,7 @@ export class ExportBlockingProcessorService {
 			const stream = fs.createWriteStream(path, { flags: 'a' });
 
 			let exportedCount = 0;
-			let cursor: any = null;
+			let cursor: Blocking['id'] | null = null;
 
 			while (true) {
 				const blockings = await this.blockingsRepository.find({
@@ -72,7 +72,7 @@ export class ExportBlockingProcessorService {
 					break;
 				}
 
-				cursor = blockings[blockings.length - 1].id;
+				cursor = blockings.at(-1)?.id ?? null;
 
 				for (const block of blockings) {
 					const u = await this.usersRepository.findOneBy({ id: block.blockeeId });
diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
index 76c38a6b86..2be42b1a7a 100644
--- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts
@@ -94,7 +94,7 @@ export class ExportFavoritesProcessorService {
 					break;
 				}
 
-				cursor = favorites[favorites.length - 1].id;
+				cursor = favorites.at(-1)?.id ?? null;
 
 				for (const favorite of favorites) {
 					let poll: Poll | undefined;
diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts
index 8726cb1402..d54e5e0b34 100644
--- a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts
@@ -79,7 +79,7 @@ export class ExportFollowingProcessorService {
 					break;
 				}
 
-				cursor = followings[followings.length - 1].id;
+				cursor = followings.at(-1)?.id ?? null;
 
 				for (const following of followings) {
 					const u = await this.usersRepository.findOneBy({ id: following.followeeId });
diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts
index 0f11a9e843..030e38931e 100644
--- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts
@@ -3,7 +3,7 @@ import { Inject, Injectable } from '@nestjs/common';
 import { IsNull, MoreThan } from 'typeorm';
 import { format as dateFormat } from 'date-fns';
 import { DI } from '@/di-symbols.js';
-import type { MutingsRepository, UsersRepository, BlockingsRepository } from '@/models/index.js';
+import type { MutingsRepository, UsersRepository, BlockingsRepository, Muting } from '@/models/index.js';
 import type { Config } from '@/config.js';
 import type Logger from '@/logger.js';
 import { DriveService } from '@/core/DriveService.js';
@@ -56,7 +56,7 @@ export class ExportMutingProcessorService {
 			const stream = fs.createWriteStream(path, { flags: 'a' });
 
 			let exportedCount = 0;
-			let cursor: any = null;
+			let cursor: Muting['id'] | null = null;
 
 			while (true) {
 				const mutes = await this.mutingsRepository.find({
@@ -76,7 +76,7 @@ export class ExportMutingProcessorService {
 					break;
 				}
 
-				cursor = mutes[mutes.length - 1].id;
+				cursor = mutes.at(-1)?.id ?? null;
 
 				for (const mute of mutes) {
 					const u = await this.usersRepository.findOneBy({ id: mute.muteeId });
diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts
index 24fb331883..75f32ffee3 100644
--- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts
@@ -90,7 +90,7 @@ export class ExportNotesProcessorService {
 					break;
 				}
 
-				cursor = notes[notes.length - 1].id;
+				cursor = notes.at(-1)?.id ?? null;
 
 				for (const note of notes) {
 					let poll: Poll | undefined;
diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts
index f751709345..634f5f0a4e 100644
--- a/packages/backend/src/server/ActivityPubServerService.ts
+++ b/packages/backend/src/server/ActivityPubServerService.ts
@@ -181,7 +181,7 @@ export class ActivityPubServerService {
 				undefined,
 				inStock ? `${partOf}?${url.query({
 					page: 'true',
-					cursor: followings[followings.length - 1].id,
+					cursor: followings.at(-1)!.id,
 				})}` : undefined,
 			);
 
@@ -273,7 +273,7 @@ export class ActivityPubServerService {
 				undefined,
 				inStock ? `${partOf}?${url.query({
 					page: 'true',
-					cursor: followings[followings.length - 1].id,
+					cursor: followings.at(-1)!.id,
 				})}` : undefined,
 			);
 
@@ -398,7 +398,7 @@ export class ActivityPubServerService {
 				})}` : undefined,
 				notes.length ? `${partOf}?${url.query({
 					page: 'true',
-					until_id: notes[notes.length - 1].id,
+					until_id: notes.at(-1)!.id,
 				})}` : undefined,
 			);
 
diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts
index 48947072e3..31ea3e5ab8 100644
--- a/packages/backend/test/utils.ts
+++ b/packages/backend/test/utils.ts
@@ -447,12 +447,12 @@ export async function testPaginationConsistency<Entity extends { id: string, cre
 	for (const limit of [1, 5, 10, 100, undefined]) {
 		// 1. sinceId/DateとuntilId/Dateで両端を指定して取得した結果が期待通りになっていること
 		if (ordering === 'desc') {
-			const end = expected[expected.length - 1];
+			const end = expected.at(-1)!;
 			let last = await fetchEntities(rangeToParam({ limit, since: end }));
 			const actual: Entity[] = [];
 			while (last.length !== 0) {
 				actual.push(...last);
-				last = await fetchEntities(rangeToParam({ limit, until: last[last.length - 1], since: end }));
+				last = await fetchEntities(rangeToParam({ limit, until: last.at(-1), since: end }));
 			}
 			actual.push(end);
 			assert.deepStrictEqual(
@@ -467,7 +467,7 @@ export async function testPaginationConsistency<Entity extends { id: string, cre
 			const actual: Entity[] = [];
 			while (last.length !== 0) {
 				actual.push(...last);
-				last = await fetchEntities(rangeToParam({ limit, since: last[last.length - 1] }));
+				last = await fetchEntities(rangeToParam({ limit, since: last.at(-1) }));
 			}
 			assert.deepStrictEqual(
 				actual.map(({ id, createdAt }) => id + ':' + createdAt),
@@ -480,7 +480,7 @@ export async function testPaginationConsistency<Entity extends { id: string, cre
 			const actual: Entity[] = [];
 			while (last.length !== 0) {
 				actual.push(...last);
-				last = await fetchEntities(rangeToParam({ limit, until: last[last.length - 1] }));
+				last = await fetchEntities(rangeToParam({ limit, until: last.at(-1) }));
 			}
 			assert.deepStrictEqual(
 				actual.map(({ id, createdAt }) => id + ':' + createdAt),
diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue
index 201a6ccdc8..aff227da40 100644
--- a/packages/frontend/src/components/MkDrive.vue
+++ b/packages/frontend/src/components/MkDrive.vue
@@ -568,7 +568,7 @@ function fetchMoreFolders() {
 	os.api('drive/folders', {
 		folderId: folder.value ? folder.value.id : null,
 		type: props.type,
-		untilId: folders.value[folders.value.length - 1].id,
+		untilId: folders.value.at(-1)?.id,
 		limit: max + 1,
 	}).then(folders => {
 		if (folders.length === max + 1) {
@@ -591,7 +591,7 @@ function fetchMoreFiles() {
 	os.api('drive/files', {
 		folderId: folder.value ? folder.value.id : null,
 		type: props.type,
-		untilId: files.value[files.value.length - 1].id,
+		untilId: files.value.at(-1)?.id,
 		limit: max + 1,
 	}).then(files => {
 		if (files.length === max + 1) {
diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue
index 89050e10f0..e884455709 100644
--- a/packages/frontend/src/components/MkMiniChart.vue
+++ b/packages/frontend/src/components/MkMiniChart.vue
@@ -59,8 +59,8 @@ function draw(): void {
 
 	polygonPoints = `0,${ viewBoxY } ${ polylinePoints } ${ viewBoxX },${ viewBoxY }`;
 
-	headX = _polylinePoints[_polylinePoints.length - 1][0];
-	headY = _polylinePoints[_polylinePoints.length - 1][1];
+	headX = _polylinePoints.at(-1)![0];
+	headY = _polylinePoints.at(-1)![1];
 }
 
 watch(() => props.src, draw, { immediate: true });
diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue
index 6318a9fd70..6e35ad4241 100644
--- a/packages/frontend/src/components/MkPageWindow.vue
+++ b/packages/frontend/src/components/MkPageWindow.vue
@@ -120,7 +120,7 @@ const contextmenu = $computed(() => ([{
 
 function back() {
 	history.pop();
-	router.replace(history[history.length - 1].path, history[history.length - 1].key);
+	router.replace(history.at(-1)!.path, history.at(-1)!.key);
 }
 
 function reload() {
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index 661b04c365..b9a75f6002 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -233,7 +233,7 @@ const fetchMore = async (): Promise<void> => {
 		...(props.pagination.offsetMode ? {
 			offset: offset.value,
 		} : {
-			untilId: Array.from(items.value.keys())[items.value.size - 1],
+			untilId: Array.from(items.value.keys()).at(-1),
 		}),
 	}).then(res => {
 		for (let i = 0; i < res.length; i++) {
@@ -297,7 +297,7 @@ const fetchMoreAhead = async (): Promise<void> => {
 		...(props.pagination.offsetMode ? {
 			offset: offset.value,
 		} : {
-			sinceId: Array.from(items.value.keys())[items.value.size - 1],
+			sinceId: Array.from(items.value.keys()).at(-1),
 		}),
 	}).then(res => {
 		if (res.length === 0) {
diff --git a/packages/frontend/src/scripts/array.ts b/packages/frontend/src/scripts/array.ts
index 4620c8b735..c9a146e707 100644
--- a/packages/frontend/src/scripts/array.ts
+++ b/packages/frontend/src/scripts/array.ts
@@ -78,8 +78,9 @@ export function maximum(xs: number[]): number {
 export function groupBy<T>(f: EndoRelation<T>, xs: T[]): T[][] {
 	const groups = [] as T[][];
 	for (const x of xs) {
-		if (groups.length !== 0 && f(groups[groups.length - 1][0], x)) {
-			groups[groups.length - 1].push(x);
+		const lastGroup = groups.at(-1);
+		if (lastGroup !== undefined && f(lastGroup[0], x)) {
+			lastGroup.push(x);
 		} else {
 			groups.push([x]);
 		}
diff --git a/packages/frontend/src/widgets/server-metric/cpu-mem.vue b/packages/frontend/src/widgets/server-metric/cpu-mem.vue
index c178ba5171..b9ba400b4d 100644
--- a/packages/frontend/src/widgets/server-metric/cpu-mem.vue
+++ b/packages/frontend/src/widgets/server-metric/cpu-mem.vue
@@ -121,10 +121,10 @@ function onStats(connStats) {
 	cpuPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${cpuPolylinePoints} ${viewBoxX},${viewBoxY}`;
 	memPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${memPolylinePoints} ${viewBoxX},${viewBoxY}`;
 
-	cpuHeadX = cpuPolylinePointsStats[cpuPolylinePointsStats.length - 1][0];
-	cpuHeadY = cpuPolylinePointsStats[cpuPolylinePointsStats.length - 1][1];
-	memHeadX = memPolylinePointsStats[memPolylinePointsStats.length - 1][0];
-	memHeadY = memPolylinePointsStats[memPolylinePointsStats.length - 1][1];
+	cpuHeadX = cpuPolylinePointsStats.at(-1)![0];
+	cpuHeadY = cpuPolylinePointsStats.at(-1)![1];
+	memHeadX = memPolylinePointsStats.at(-1)![0];
+	memHeadY = memPolylinePointsStats.at(-1)![1];
 
 	cpuP = (connStats.cpu * 100).toFixed(0);
 	memP = (connStats.mem.active / props.meta.mem.total * 100).toFixed(0);
diff --git a/packages/frontend/src/widgets/server-metric/net.vue b/packages/frontend/src/widgets/server-metric/net.vue
index 5a9134078d..817a422e63 100644
--- a/packages/frontend/src/widgets/server-metric/net.vue
+++ b/packages/frontend/src/widgets/server-metric/net.vue
@@ -94,10 +94,10 @@ function onStats(connStats) {
 	inPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${inPolylinePoints} ${viewBoxX},${viewBoxY}`;
 	outPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${outPolylinePoints} ${viewBoxX},${viewBoxY}`;
 
-	inHeadX = inPolylinePointsStats[inPolylinePointsStats.length - 1][0];
-	inHeadY = inPolylinePointsStats[inPolylinePointsStats.length - 1][1];
-	outHeadX = outPolylinePointsStats[outPolylinePointsStats.length - 1][0];
-	outHeadY = outPolylinePointsStats[outPolylinePointsStats.length - 1][1];
+	inHeadX = inPolylinePointsStats.at(-1)![0];
+	inHeadY = inPolylinePointsStats.at(-1)![1];
+	outHeadX = outPolylinePointsStats.at(-1)![0];
+	outHeadY = outPolylinePointsStats.at(-1)![1];
 
 	inRecent = connStats.net.rx;
 	outRecent = connStats.net.tx;
-- 
cgit v1.2.3-freya

