From 8c2ab25e5f2040fcbc81bc2a02a279fed40e1c11 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Fri, 9 May 2025 17:40:08 +0900
Subject: Feat: No websocket mode (#15851)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* wip

* wip

* wip

* wip

* Update MkTimeline.vue

* wip

* wip

* wip

* Update MkTimeline.vue

* Update use-pagination.ts

* wip

* wip

* Update MkTimeline.vue

* Update MkTimeline.vue

* wip

* wip

* Update MkTimeline.vue

* Update MkTimeline.vue

* Update MkTimeline.vue

* wip

* Update use-pagination.ts

* wip

* Update use-pagination.ts

* Update MkNotifications.vue

* Update MkNotifications.vue

* wip

* wip

* wip

* Update use-note-capture.ts

* Update use-note-capture.ts

* Update use-note-capture.ts

* wip

* wip

* wip

* wip

* Update MkNoteDetailed.vue

* wip

* wip

* Update MkTimeline.vue

* wip

* fix

* Update MkTimeline.vue

* wip

* test

* Revert "test"

This reverts commit 3375619396c54dcda5e564eb1da444c2391208c9.

* Update use-pagination.ts

* test

* Revert "test"

This reverts commit 42c53c830e28485d2fb49061fa7cdeee31bc6a22.

* test

* Revert "test"

This reverts commit c4f8cda4aa1cec9d1eb97557145f3ad3d2d0e469.

* Update style.scss

* Update MkTimeline.vue

* Update MkTimeline.vue

* Update MkTimeline.vue

* ✌️

* Update MkTimeline.vue

* wip

* wip

* test

* Update MkPullToRefresh.vue

* Update MkPullToRefresh.vue

* Update MkPullToRefresh.vue

* Update MkPullToRefresh.vue

* Update MkTimeline.vue

* wip

* tweak navbar

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update home.vue

* wip

* refactor

* wip

* wip

* Update note.vue

* Update navbar.vue

* Update MkPullToRefresh.vue

* Update MkPullToRefresh.vue

* Update MkPullToRefresh.vue

* wip

* Update MkStreamingNotificationsTimeline.vue

* Update use-pagination.ts

* wip

* improve perf

* wip

* Update MkNotesTimeline.vue

* wip

* megre

* Update use-pagination.ts

* Update use-pagination.ts

* Update MkStreamingNotesTimeline.vue

* Update use-pagination.ts

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md
---
 .../src/components/MkStreamingNotesTimeline.vue    | 531 +++++++++++++++++++++
 1 file changed, 531 insertions(+)
 create mode 100644 packages/frontend/src/components/MkStreamingNotesTimeline.vue

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

diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
new file mode 100644
index 0000000000..75b2d10100
--- /dev/null
+++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
@@ -0,0 +1,531 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reloadTimeline()">
+	<MkLoading v-if="paginator.fetching.value"/>
+
+	<MkError v-else-if="paginator.error.value" @retry="paginator.init()"/>
+
+	<div v-else-if="paginator.items.value.length === 0" key="_empty_">
+		<slot name="empty"><MkResult type="empty" :text="i18n.ts.noNotes"/></slot>
+	</div>
+
+	<div v-else ref="rootEl">
+		<div v-if="paginator.queuedAheadItemsCount.value > 0" :class="$style.new">
+			<div :class="$style.newBg1"></div>
+			<div :class="$style.newBg2"></div>
+			<button class="_button" :class="$style.newButton" @click="releaseQueue()"><i class="ti ti-circle-arrow-up"></i> {{ i18n.ts.newNote }}</button>
+		</div>
+		<component
+			:is="prefer.s.animation ? TransitionGroup : 'div'"
+			:class="$style.notes"
+			:enterActiveClass="$style.transition_x_enterActive"
+			:leaveActiveClass="$style.transition_x_leaveActive"
+			:enterFromClass="$style.transition_x_enterFrom"
+			:leaveToClass="$style.transition_x_leaveTo"
+			:moveClass="$style.transition_x_move"
+			tag="div"
+		>
+			<template v-for="(note, i) in paginator.items.value" :key="note.id">
+				<div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, note.createdAt)" :data-scroll-anchor="note.id">
+					<div :class="$style.date">
+						<span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt).prevText }}</span>
+						<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
+						<span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span>
+					</div>
+					<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
+				</div>
+				<div v-else-if="note._shouldInsertAd_" :data-scroll-anchor="note.id">
+					<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
+					<div :class="$style.ad">
+						<MkAd :preferForms="['horizontal', 'horizontal-big']"/>
+					</div>
+				</div>
+				<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">
+			<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
+			<MkLoading v-else :inline="true"/>
+		</button>
+	</div>
+</component>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup, onMounted, shallowRef, ref } 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 '@/use/use-pagination.js';
+import { usePagination } from '@/use/use-pagination.js';
+import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
+import { useStream } from '@/stream.js';
+import * as sound from '@/utility/sound.js';
+import { $i } from '@/i.js';
+import { instance } from '@/instance.js';
+import { prefer } from '@/preferences.js';
+import { store } from '@/store.js';
+import MkNote from '@/components/MkNote.vue';
+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';
+
+const props = withDefaults(defineProps<{
+	src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
+	list?: string;
+	antenna?: string;
+	channel?: string;
+	role?: string;
+	sound?: boolean;
+	withRenotes?: boolean;
+	withReplies?: boolean;
+	withSensitive?: boolean;
+	onlyFiles?: boolean;
+}>(), {
+	withRenotes: true,
+	withReplies: false,
+	withSensitive: true,
+	onlyFiles: false,
+});
+
+provide('inTimeline', true);
+provide('tl_withSensitive', computed(() => props.withSensitive));
+provide('inChannel', computed(() => props.src === 'channel'));
+
+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;
+}
+
+let scrollContainer: HTMLElement | null = null;
+
+function onScrollContainerScroll() {
+	if (isTop()) {
+		paginator.releaseQueue();
+	}
+}
+
+const rootEl = useTemplateRef('rootEl');
+watch(rootEl, (el) => {
+	if (el && scrollContainer == null) {
+		scrollContainer = getScrollContainer(el);
+		if (scrollContainer == null) return;
+		scrollContainer.addEventListener('scroll', onScrollContainerScroll, { passive: true }); // ほんとはscrollendにしたいけどiosが非対応
+	}
+}, { immediate: true });
+
+onUnmounted(() => {
+	if (scrollContainer) {
+		scrollContainer.removeEventListener('scroll', onScrollContainerScroll);
+	}
+});
+
+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;
+const POLLING_INTERVAL =
+	prefer.s.pollingInterval === 1 ? MIN_POLLING_INTERVAL * 1.5 * 1.5 :
+	prefer.s.pollingInterval === 2 ? MIN_POLLING_INTERVAL * 1.5 :
+	prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL :
+	MIN_POLLING_INTERVAL;
+
+if (!store.s.realtimeMode) {
+	// TODO: 先頭のノートの作成日時が1日以上前であれば流速が遅いTLと見做してインターバルを通常より延ばす
+	useInterval(async () => {
+		paginator.fetchNewer({
+			toQueue: !isTop(),
+		});
+	}, POLLING_INTERVAL, {
+		immediate: false,
+		afterMounted: true,
+	});
+
+	useGlobalEvent('notePosted', (note) => {
+		paginator.fetchNewer({
+			toQueue: !isTop(),
+		});
+	});
+}
+
+useGlobalEvent('noteDeleted', (noteId) => {
+	paginator.removeItem(noteId);
+});
+
+function releaseQueue() {
+	paginator.releaseQueue();
+	scrollToTop(rootEl.value);
+}
+
+function prepend(note: Misskey.entities.Note) {
+	adInsertionCounter++;
+
+	if (instance.notesPerOneAd > 0 && adInsertionCounter % instance.notesPerOneAd === 0) {
+		note._shouldInsertAd_ = true;
+	}
+
+	if (isTop()) {
+		paginator.prepend(note);
+	} else {
+		paginator.enqueue(note);
+	}
+
+	if (props.sound) {
+		sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note');
+	}
+}
+
+let connection: Misskey.ChannelConnection | null = null;
+let connection2: Misskey.ChannelConnection | null = null;
+let paginationQuery: PagingCtx;
+
+const stream = store.s.realtimeMode ? useStream() : null;
+
+function connectChannel() {
+	if (props.src === 'antenna') {
+		if (props.antenna == null) return;
+		connection = stream.useChannel('antenna', {
+			antennaId: props.antenna,
+		});
+	} else if (props.src === 'home') {
+		connection = stream.useChannel('homeTimeline', {
+			withRenotes: props.withRenotes,
+			withFiles: props.onlyFiles ? true : undefined,
+		});
+		connection2 = stream.useChannel('main');
+	} else if (props.src === 'local') {
+		connection = stream.useChannel('localTimeline', {
+			withRenotes: props.withRenotes,
+			withReplies: props.withReplies,
+			withFiles: props.onlyFiles ? true : undefined,
+		});
+	} else if (props.src === 'social') {
+		connection = stream.useChannel('hybridTimeline', {
+			withRenotes: props.withRenotes,
+			withReplies: props.withReplies,
+			withFiles: props.onlyFiles ? true : undefined,
+		});
+	} else if (props.src === 'global') {
+		connection = stream.useChannel('globalTimeline', {
+			withRenotes: props.withRenotes,
+			withFiles: props.onlyFiles ? true : undefined,
+		});
+	} else if (props.src === 'mentions') {
+		connection = stream.useChannel('main');
+		connection.on('mention', prepend);
+	} else if (props.src === 'directs') {
+		const onNote = note => {
+			if (note.visibility === 'specified') {
+				prepend(note);
+			}
+		};
+		connection = stream.useChannel('main');
+		connection.on('mention', onNote);
+	} else if (props.src === 'list') {
+		if (props.list == null) return;
+		connection = stream.useChannel('userList', {
+			withRenotes: props.withRenotes,
+			withFiles: props.onlyFiles ? true : undefined,
+			listId: props.list,
+		});
+	} else if (props.src === 'channel') {
+		if (props.channel == null) return;
+		connection = stream.useChannel('channel', {
+			channelId: props.channel,
+		});
+	} else if (props.src === 'role') {
+		if (props.role == null) return;
+		connection = stream.useChannel('roleTimeline', {
+			roleId: props.role,
+		});
+	}
+	if (props.src !== 'directs' && props.src !== 'mentions') connection?.on('note', prepend);
+}
+
+function disconnectChannel() {
+	if (connection) connection.dispose();
+	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() {
+	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,
+});
+
+onUnmounted(() => {
+	disconnectChannel();
+});
+
+function reloadTimeline() {
+	return new Promise<void>((res) => {
+		adInsertionCounter = 0;
+
+		paginator.reload().then(() => {
+			res();
+		});
+	});
+}
+
+defineExpose({
+	reloadTimeline,
+});
+</script>
+
+<style lang="scss" module>
+.transition_x_move {
+	transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+}
+
+.transition_x_enterActive {
+	transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+
+	&.note,
+	.note {
+		/* Skip Note Rendering有効時、TransitionGroupでnoteを追加するときに一瞬がくっとなる問題を抑制する */
+		content-visibility: visible !important;
+	}
+}
+
+.transition_x_leaveActive {
+	transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
+}
+
+.transition_x_enterFrom {
+	opacity: 0;
+	transform: translateY(max(-64px, -100%));
+}
+
+@supports (interpolate-size: allow-keywords) {
+	.transition_x_leaveTo {
+		interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
+		height: 0;
+	}
+}
+
+.transition_x_leaveTo {
+	opacity: 0;
+}
+
+.notes {
+	container-type: inline-size;
+	background: var(--MI_THEME-panel);
+}
+
+.note {
+	border-bottom: solid 0.5px var(--MI_THEME-divider);
+}
+
+.new {
+	--gapFill: 0.5px; // 上位ヘッダーの高さにフォントの関係などで少数が含まれると、レンダリングエンジンによっては隙間が表示されてしまうため、隙間を隠すために少しずらす
+
+	position: sticky;
+	top: calc(var(--MI-stickyTop, 0px) - var(--gapFill));
+	z-index: 1000;
+	width: 100%;
+	box-sizing: border-box;
+	padding: calc(10px + var(--gapFill)) 0 10px 0;
+}
+
+/* 疑似progressive blur */
+.newBg1, .newBg2 {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+}
+
+.newBg1 {
+	height: 100%;
+	-webkit-backdrop-filter: var(--MI-blur, blur(2px));
+	backdrop-filter: var(--MI-blur, blur(2px));
+	mask-image: linear-gradient( /* 疑似Easing Linear Gradients */
+		to top,
+		rgb(0 0 0 / 0%) 0%,
+		rgb(0 0 0 / 4.9%) 7.75%,
+		rgb(0 0 0 / 10.4%) 11.25%,
+		rgb(0 0 0 / 45%) 23.55%,
+		rgb(0 0 0 / 55%) 26.45%,
+		rgb(0 0 0 / 89.6%) 38.75%,
+		rgb(0 0 0 / 95.1%) 42.25%,
+		rgb(0 0 0 / 100%) 50%
+	);
+}
+
+.newBg2 {
+	height: 75%;
+	-webkit-backdrop-filter: var(--MI-blur, blur(4px));
+	backdrop-filter: var(--MI-blur, blur(4px));
+	mask-image: linear-gradient( /* 疑似Easing Linear Gradients */
+		to top,
+		rgb(0 0 0 / 0%) 0%,
+		rgb(0 0 0 / 4.9%) 15.5%,
+		rgb(0 0 0 / 10.4%) 22.5%,
+		rgb(0 0 0 / 45%) 47.1%,
+		rgb(0 0 0 / 55%) 52.9%,
+		rgb(0 0 0 / 89.6%) 77.5%,
+		rgb(0 0 0 / 95.1%) 91.9%,
+		rgb(0 0 0 / 100%) 100%
+	);
+}
+
+.newButton {
+	position: relative;
+	display: block;
+	padding: 6px 12px;
+	border-radius: 999px;
+	width: max-content;
+	margin: auto;
+	background: var(--MI_THEME-accent);
+	color: var(--MI_THEME-fgOnAccent);
+	font-size: 90%;
+
+	&:hover {
+		background: hsl(from var(--MI_THEME-accent) h s calc(l + 5));
+	}
+
+	&:active {
+		background: hsl(from var(--MI_THEME-accent) h s calc(l - 5));
+	}
+}
+
+.date {
+	display: flex;
+	font-size: 85%;
+	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);
+}
+
+.ad {
+	padding: 8px;
+	background-size: auto auto;
+	background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px);
+	border-bottom: solid 0.5px var(--MI_THEME-divider);
+
+	&:empty {
+		display: none;
+	}
+}
+
+.more {
+	display: block;
+	width: 100%;
+	box-sizing: border-box;
+	padding: 16px;
+	background: var(--MI_THEME-panel);
+}
+</style>
-- 
cgit v1.2.3-freya


From e1cd7c94fb13f8e49667b17554d22ce8de627a2a Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sat, 10 May 2025 07:58:26 +0900
Subject: refactor(frontend): use* 関数の格納場所のフォルダ名を composables
 に変更 (#16004)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* refactor(frontend): use* 関数の格納場所を正式名称（composables）に変更

* migrate

* move useLoading
---
 packages/frontend/src/components/MkChannelList.vue |   2 +-
 packages/frontend/src/components/MkChart.vue       |   2 +-
 packages/frontend/src/components/MkHeatmap.vue     |   2 +-
 .../frontend/src/components/MkInstanceStats.vue    |   2 +-
 packages/frontend/src/components/MkLink.vue        |   2 +-
 packages/frontend/src/components/MkNote.vue        |   4 +-
 .../frontend/src/components/MkNoteDetailed.vue     |   4 +-
 .../frontend/src/components/MkNotesTimeline.vue    |   2 +-
 packages/frontend/src/components/MkPagination.vue  |   4 +-
 .../frontend/src/components/MkReactionIcon.vue     |   2 +-
 .../src/components/MkReactionsViewer.reaction.vue  |   4 +-
 .../frontend/src/components/MkRetentionHeatmap.vue |   2 +-
 .../src/components/MkRetentionLineChart.vue        |   2 +-
 .../src/components/MkStreamingNotesTimeline.vue    |   4 +-
 .../MkStreamingNotificationsTimeline.vue           |   2 +-
 packages/frontend/src/components/MkUserList.vue    |   2 +-
 .../src/components/MkUserSetupDialog.Follow.vue    |   2 +-
 .../MkVisitorDashboard.ActiveUsersChart.vue        |   2 +-
 packages/frontend/src/components/global/MkUrl.vue  |   2 +-
 .../src/components/global/PageWithHeader.vue       |   2 +-
 .../frontend/src/components/grid/MkDataCell.vue    |   2 +-
 .../frontend/src/components/hook/useLoading.ts     |  52 ----
 .../frontend/src/composables/use-chart-tooltip.ts  |  63 +++++
 packages/frontend/src/composables/use-form.ts      |  57 +++++
 .../frontend/src/composables/use-leave-guard.ts    |  50 ++++
 packages/frontend/src/composables/use-loading.ts   |  52 ++++
 .../src/composables/use-mutation-observer.ts       |  21 ++
 .../frontend/src/composables/use-note-capture.ts   | 283 +++++++++++++++++++++
 .../frontend/src/composables/use-pagination.ts     | 258 +++++++++++++++++++
 .../src/composables/use-scroll-position-keeper.ts  |  77 ++++++
 packages/frontend/src/composables/use-tooltip.ts   | 106 ++++++++
 packages/frontend/src/pages/about.federation.vue   |   2 +-
 .../frontend/src/pages/admin/bot-protection.vue    |   2 +-
 .../admin/custom-emojis-manager.local.list.vue     |   2 +-
 .../pages/admin/custom-emojis-manager.remote.vue   |   2 +-
 .../admin/federation-job-queue.chart.chart.vue     |   2 +-
 packages/frontend/src/pages/admin/invites.vue      |   2 +-
 .../frontend/src/pages/admin/job-queue.chart.vue   |   2 +-
 .../src/pages/admin/overview.active-users.vue      |   2 +-
 .../src/pages/admin/overview.ap-requests.vue       |   2 +-
 .../src/pages/admin/overview.federation.vue        |   2 +-
 packages/frontend/src/pages/admin/overview.pie.vue |   2 +-
 .../src/pages/admin/overview.queue.chart.vue       |   2 +-
 packages/frontend/src/pages/admin/performance.vue  |   2 +-
 packages/frontend/src/pages/admin/security.vue     |   2 +-
 packages/frontend/src/pages/admin/settings.vue     |   2 +-
 packages/frontend/src/pages/chat/room.vue          |   2 +-
 packages/frontend/src/pages/drive.file.notes.vue   |   2 +-
 packages/frontend/src/pages/follow-requests.vue    |   2 +-
 packages/frontend/src/pages/instance-info.vue      |   2 +-
 packages/frontend/src/pages/invite.vue             |   2 +-
 packages/frontend/src/pages/note.vue               |   2 +-
 packages/frontend/src/pages/search.note.vue        |   2 +-
 packages/frontend/src/pages/search.user.vue        |   2 +-
 packages/frontend/src/pages/theme-editor.vue       |   2 +-
 .../frontend/src/pages/user/activity.following.vue |   2 +-
 .../frontend/src/pages/user/activity.notes.vue     |   2 +-
 packages/frontend/src/pages/user/activity.pv.vue   |   2 +-
 packages/frontend/src/use/use-chart-tooltip.ts     |  63 -----
 packages/frontend/src/use/use-form.ts              |  57 -----
 packages/frontend/src/use/use-leave-guard.ts       |  50 ----
 packages/frontend/src/use/use-mutation-observer.ts |  21 --
 packages/frontend/src/use/use-note-capture.ts      | 283 ---------------------
 packages/frontend/src/use/use-pagination.ts        | 258 -------------------
 .../frontend/src/use/use-scroll-position-keeper.ts |  77 ------
 packages/frontend/src/use/use-tooltip.ts           | 106 --------
 66 files changed, 1020 insertions(+), 1020 deletions(-)
 delete mode 100644 packages/frontend/src/components/hook/useLoading.ts
 create mode 100644 packages/frontend/src/composables/use-chart-tooltip.ts
 create mode 100644 packages/frontend/src/composables/use-form.ts
 create mode 100644 packages/frontend/src/composables/use-leave-guard.ts
 create mode 100644 packages/frontend/src/composables/use-loading.ts
 create mode 100644 packages/frontend/src/composables/use-mutation-observer.ts
 create mode 100644 packages/frontend/src/composables/use-note-capture.ts
 create mode 100644 packages/frontend/src/composables/use-pagination.ts
 create mode 100644 packages/frontend/src/composables/use-scroll-position-keeper.ts
 create mode 100644 packages/frontend/src/composables/use-tooltip.ts
 delete mode 100644 packages/frontend/src/use/use-chart-tooltip.ts
 delete mode 100644 packages/frontend/src/use/use-form.ts
 delete mode 100644 packages/frontend/src/use/use-leave-guard.ts
 delete mode 100644 packages/frontend/src/use/use-mutation-observer.ts
 delete mode 100644 packages/frontend/src/use/use-note-capture.ts
 delete mode 100644 packages/frontend/src/use/use-pagination.ts
 delete mode 100644 packages/frontend/src/use/use-scroll-position-keeper.ts
 delete mode 100644 packages/frontend/src/use/use-tooltip.ts

(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 5562be682b..0968452ca7 100644
--- a/packages/frontend/src/components/MkChannelList.vue
+++ b/packages/frontend/src/components/MkChannelList.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import type { PagingCtx } from '@/use/use-pagination.js';
+import type { PagingCtx } from '@/composables/use-pagination.js';
 import MkChannelPreview from '@/components/MkChannelPreview.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue
index 7e164362c1..4d67bba70d 100644
--- a/packages/frontend/src/components/MkChart.vue
+++ b/packages/frontend/src/components/MkChart.vue
@@ -51,7 +51,7 @@ import { Chart } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import { misskeyApiGet } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/use/use-chart-tooltip.js';
+import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { alpha } from '@/utility/color.js';
 import date from '@/filters/date.js';
diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue
index 28bb936755..abbf86004b 100644
--- a/packages/frontend/src/components/MkHeatmap.vue
+++ b/packages/frontend/src/components/MkHeatmap.vue
@@ -18,7 +18,7 @@ import { Chart } from 'chart.js';
 import * as Misskey from 'misskey-js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/use/use-chart-tooltip.js';
+import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
 import { alpha } from '@/utility/color.js';
 import { initChart } from '@/utility/init-chart.js';
 
diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue
index 90391005bc..07d88d6575 100644
--- a/packages/frontend/src/components/MkInstanceStats.vue
+++ b/packages/frontend/src/components/MkInstanceStats.vue
@@ -89,7 +89,7 @@ import { Chart } from 'chart.js';
 import type { HeatmapSource } from '@/components/MkHeatmap.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkChart from '@/components/MkChart.vue';
-import { useChartTooltip } from '@/use/use-chart-tooltip.js';
+import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
 import { $i } from '@/i.js';
 import * as os from '@/os.js';
 import { misskeyApiGet } from '@/utility/misskey-api.js';
diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue
index 4cbf289448..7589c4900b 100644
--- a/packages/frontend/src/components/MkLink.vue
+++ b/packages/frontend/src/components/MkLink.vue
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { defineAsyncComponent, ref } from 'vue';
 import { url as local } from '@@/js/config.js';
-import { useTooltip } from '@/use/use-tooltip.js';
+import { useTooltip } from '@/composables/use-tooltip.js';
 import * as os from '@/os.js';
 import { isEnabledUrlPreview } from '@/instance.js';
 import type { MkABehavior } from '@/components/global/MkA.vue';
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 9b7658292d..bbcf5de3b5 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -227,9 +227,9 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
 import { $i } from '@/i.js';
 import { i18n } from '@/i18n.js';
 import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
-import { noteEvents, useNoteCapture } from '@/use/use-note-capture.js';
+import { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js';
 import { deepClone } from '@/utility/clone.js';
-import { useTooltip } from '@/use/use-tooltip.js';
+import { useTooltip } from '@/composables/use-tooltip.js';
 import { claimAchievement } from '@/utility/achievements.js';
 import { getNoteSummary } from '@/utility/get-note-summary.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 93e79e7c1f..5a2af6ac63 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -258,9 +258,9 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
 import { $i } from '@/i.js';
 import { i18n } from '@/i18n.js';
 import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
-import { noteEvents, useNoteCapture } from '@/use/use-note-capture.js';
+import { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js';
 import { deepClone } from '@/utility/clone.js';
-import { useTooltip } from '@/use/use-tooltip.js';
+import { useTooltip } from '@/composables/use-tooltip.js';
 import { claimAchievement } from '@/utility/achievements.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import { showMovedDialog } from '@/utility/show-moved-dialog.js';
diff --git a/packages/frontend/src/components/MkNotesTimeline.vue b/packages/frontend/src/components/MkNotesTimeline.vue
index 71dd8e51a0..aaaed5987e 100644
--- a/packages/frontend/src/components/MkNotesTimeline.vue
+++ b/packages/frontend/src/components/MkNotesTimeline.vue
@@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { useTemplateRef } from 'vue';
-import type { PagingCtx } from '@/use/use-pagination.js';
+import type { PagingCtx } from '@/composables/use-pagination.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 37e15df39b..717bc78cc4 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -41,11 +41,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import type { PagingCtx } from '@/use/use-pagination.js';
+import type { PagingCtx } from '@/composables/use-pagination.js';
 import MkButton from '@/components/MkButton.vue';
 import { i18n } from '@/i18n.js';
 import { prefer } from '@/preferences.js';
-import { usePagination } from '@/use/use-pagination.js';
+import { usePagination } from '@/composables/use-pagination.js';
 import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
 
 const props = withDefaults(defineProps<{
diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue
index 453253f0fc..36d1103549 100644
--- a/packages/frontend/src/components/MkReactionIcon.vue
+++ b/packages/frontend/src/components/MkReactionIcon.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { defineAsyncComponent, useTemplateRef } from 'vue';
-import { useTooltip } from '@/use/use-tooltip.js';
+import { useTooltip } from '@/composables/use-tooltip.js';
 import * as os from '@/os.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index 9027ffd0ae..3a34084c0a 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -26,7 +26,7 @@ import XDetails from '@/components/MkReactionsViewer.details.vue';
 import MkReactionIcon from '@/components/MkReactionIcon.vue';
 import * as os from '@/os.js';
 import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
-import { useTooltip } from '@/use/use-tooltip.js';
+import { useTooltip } from '@/composables/use-tooltip.js';
 import { $i } from '@/i.js';
 import MkReactionEffect from '@/components/MkReactionEffect.vue';
 import { i18n } from '@/i18n.js';
@@ -35,7 +35,7 @@ import { checkReactionPermissions } from '@/utility/check-reaction-permissions.j
 import { customEmojisMap } from '@/custom-emojis.js';
 import { prefer } from '@/preferences.js';
 import { DI } from '@/di.js';
-import { noteEvents } from '@/use/use-note-capture.js';
+import { noteEvents } from '@/composables/use-note-capture.js';
 
 const props = defineProps<{
 	noteId: Misskey.entities.Note['id'];
diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue
index 1ab2397337..a204bc3bf1 100644
--- a/packages/frontend/src/components/MkRetentionHeatmap.vue
+++ b/packages/frontend/src/components/MkRetentionHeatmap.vue
@@ -17,7 +17,7 @@ import { onMounted, nextTick, useTemplateRef, ref } from 'vue';
 import { Chart } from 'chart.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/use/use-chart-tooltip.js';
+import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
 import { alpha } from '@/utility/color.js';
 import { initChart } from '@/utility/init-chart.js';
 
diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue
index ba66ffecc0..21c20f944b 100644
--- a/packages/frontend/src/components/MkRetentionLineChart.vue
+++ b/packages/frontend/src/components/MkRetentionLineChart.vue
@@ -12,7 +12,7 @@ import { onMounted, useTemplateRef } from 'vue';
 import { Chart } from 'chart.js';
 import tinycolor from 'tinycolor2';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/use/use-chart-tooltip.js';
+import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { alpha } from '@/utility/color.js';
 import { initChart } from '@/utility/init-chart.js';
diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
index 75b2d10100..576a0cf8cc 100644
--- a/packages/frontend/src/components/MkStreamingNotesTimeline.vue
+++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue
@@ -61,8 +61,8 @@ 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 '@/use/use-pagination.js';
-import { usePagination } from '@/use/use-pagination.js';
+import type { PagingCtx } from '@/composables/use-pagination.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';
diff --git a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
index 931f6ae115..b12effd0d1 100644
--- a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
+++ b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue
@@ -53,7 +53,7 @@ import { i18n } from '@/i18n.js';
 import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
 import { prefer } from '@/preferences.js';
 import { store } from '@/store.js';
-import { usePagination } from '@/use/use-pagination.js';
+import { usePagination } from '@/composables/use-pagination.js';
 import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
 
 const props = defineProps<{
diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue
index 03ffd7e470..1d4cdfd5cb 100644
--- a/packages/frontend/src/components/MkUserList.vue
+++ b/packages/frontend/src/components/MkUserList.vue
@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import type { PagingCtx } from '@/use/use-pagination.js';
+import type { PagingCtx } from '@/composables/use-pagination.js';
 import MkUserInfo from '@/components/MkUserInfo.vue';
 import MkPagination from '@/components/MkPagination.vue';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue
index 1c1247e3e8..1441d69a6a 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue
@@ -39,7 +39,7 @@ import { i18n } from '@/i18n.js';
 import MkFolder from '@/components/MkFolder.vue';
 import XUser from '@/components/MkUserSetupDialog.User.vue';
 import MkPagination from '@/components/MkPagination.vue';
-import type { PagingCtx } from '@/use/use-pagination.js';
+import type { PagingCtx } from '@/composables/use-pagination.js';
 
 const pinnedUsers: PagingCtx = {
 	endpoint: 'pinned-users',
diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
index 79c9e739c4..6aaee76565 100644
--- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
+++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue
@@ -19,7 +19,7 @@ import gradient from 'chartjs-plugin-gradient';
 import tinycolor from 'tinycolor2';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/use/use-chart-tooltip.js';
+import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { initChart } from '@/utility/init-chart.js';
 
diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue
index 49f716d886..2478b59f00 100644
--- a/packages/frontend/src/components/global/MkUrl.vue
+++ b/packages/frontend/src/components/global/MkUrl.vue
@@ -29,7 +29,7 @@ import { defineAsyncComponent, ref } from 'vue';
 import { toUnicode as decodePunycode } from 'punycode.js';
 import { url as local } from '@@/js/config.js';
 import * as os from '@/os.js';
-import { useTooltip } from '@/use/use-tooltip.js';
+import { useTooltip } from '@/composables/use-tooltip.js';
 import { isEnabledUrlPreview } from '@/instance.js';
 import type { MkABehavior } from '@/components/global/MkA.vue';
 import { maybeMakeRelative } from '@@/js/url.js';
diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue
index 33a34e0b67..d90afb652e 100644
--- a/packages/frontend/src/components/global/PageWithHeader.vue
+++ b/packages/frontend/src/components/global/PageWithHeader.vue
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, useTemplateRef } from 'vue';
 import { scrollInContainer } from '@@/js/scroll.js';
 import type { PageHeaderProps } from './MkPageHeader.vue';
-import { useScrollPositionKeeper } from '@/use/use-scroll-position-keeper.js';
+import { useScrollPositionKeeper } from '@/composables/use-scroll-position-keeper.js';
 import MkSwiper from '@/components/MkSwiper.vue';
 import { useRouter } from '@/router.js';
 import { prefer } from '@/preferences.js';
diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue
index 55de0df690..444509e6b3 100644
--- a/packages/frontend/src/components/grid/MkDataCell.vue
+++ b/packages/frontend/src/components/grid/MkDataCell.vue
@@ -95,7 +95,7 @@ import type { Size } from '@/components/grid/grid.js';
 import type { CellValue, GridCell } from '@/components/grid/cell.js';
 import type { GridRowSetting } from '@/components/grid/row.js';
 import { GridEventEmitter } from '@/components/grid/grid.js';
-import { useTooltip } from '@/use/use-tooltip.js';
+import { useTooltip } from '@/composables/use-tooltip.js';
 import * as os from '@/os.js';
 import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js';
 
diff --git a/packages/frontend/src/components/hook/useLoading.ts b/packages/frontend/src/components/hook/useLoading.ts
deleted file mode 100644
index 6c6ff6ae0d..0000000000
--- a/packages/frontend/src/components/hook/useLoading.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { computed, h, ref } from 'vue';
-import MkLoading from '@/components/global/MkLoading.vue';
-
-export const useLoading = (props?: {
-	static?: boolean;
-	inline?: boolean;
-	colored?: boolean;
-	mini?: boolean;
-	em?: boolean;
-}) => {
-	const showingCnt = ref(0);
-
-	const show = () => {
-		showingCnt.value++;
-	};
-
-	const close = (force?: boolean) => {
-		if (force) {
-			showingCnt.value = 0;
-		} else {
-			showingCnt.value = Math.max(0, showingCnt.value - 1);
-		}
-	};
-
-	const scope = <T>(fn: () => T) => {
-		show();
-
-		const result = fn();
-		if (result instanceof Promise) {
-			return result.finally(() => close());
-		} else {
-			close();
-			return result;
-		}
-	};
-
-	const showing = computed(() => showingCnt.value > 0);
-	const component = computed(() => showing.value ? h(MkLoading, props) : null);
-
-	return {
-		show,
-		close,
-		scope,
-		component,
-		showing,
-	};
-};
diff --git a/packages/frontend/src/composables/use-chart-tooltip.ts b/packages/frontend/src/composables/use-chart-tooltip.ts
new file mode 100644
index 0000000000..bba64fc6ee
--- /dev/null
+++ b/packages/frontend/src/composables/use-chart-tooltip.ts
@@ -0,0 +1,63 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { onUnmounted, onDeactivated, ref } from 'vue';
+import * as os from '@/os.js';
+import MkChartTooltip from '@/components/MkChartTooltip.vue';
+
+export function useChartTooltip(opts: { position: 'top' | 'middle' } = { position: 'top' }) {
+	const tooltipShowing = ref(false);
+	const tooltipX = ref(0);
+	const tooltipY = ref(0);
+	const tooltipTitle = ref<string | null>(null);
+	const tooltipSeries = ref<{
+		backgroundColor: string;
+		borderColor: string;
+		text: string;
+	}[] | null>(null);
+	const { dispose: disposeTooltipComponent } = os.popup(MkChartTooltip, {
+		showing: tooltipShowing,
+		x: tooltipX,
+		y: tooltipY,
+		title: tooltipTitle,
+		series: tooltipSeries,
+	}, {});
+
+	onUnmounted(() => {
+		disposeTooltipComponent();
+	});
+
+	onDeactivated(() => {
+		tooltipShowing.value = false;
+	});
+
+	function handler(context) {
+		if (context.tooltip.opacity === 0) {
+			tooltipShowing.value = false;
+			return;
+		}
+
+		tooltipTitle.value = context.tooltip.title[0];
+		tooltipSeries.value = context.tooltip.body.map((b, i) => ({
+			backgroundColor: context.tooltip.labelColors[i].backgroundColor,
+			borderColor: context.tooltip.labelColors[i].borderColor,
+			text: b.lines[0],
+		}));
+
+		const rect = context.chart.canvas.getBoundingClientRect();
+
+		tooltipShowing.value = true;
+		tooltipX.value = rect.left + window.scrollX + context.tooltip.caretX;
+		if (opts.position === 'top') {
+			tooltipY.value = rect.top + window.scrollY;
+		} else if (opts.position === 'middle') {
+			tooltipY.value = rect.top + window.scrollY + context.tooltip.caretY;
+		}
+	}
+
+	return {
+		handler,
+	};
+}
diff --git a/packages/frontend/src/composables/use-form.ts b/packages/frontend/src/composables/use-form.ts
new file mode 100644
index 0000000000..1c93557413
--- /dev/null
+++ b/packages/frontend/src/composables/use-form.ts
@@ -0,0 +1,57 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { computed, reactive, watch } from 'vue';
+import type { Reactive } from 'vue';
+import { deepEqual } from '@/utility/deep-equal';
+
+function copy<T>(v: T): T {
+	return JSON.parse(JSON.stringify(v));
+}
+
+function unwrapReactive<T>(v: Reactive<T>): T {
+	return JSON.parse(JSON.stringify(v));
+}
+
+export function useForm<T extends Record<string, any>>(initialState: T, save: (newState: T) => Promise<void>) {
+	const currentState = reactive<T>(copy(initialState));
+	const previousState = reactive<T>(copy(initialState));
+
+	const modifiedStates = reactive<Record<keyof T, boolean>>({} as any);
+	for (const key in currentState) {
+		modifiedStates[key] = false;
+	}
+	const modified = computed(() => Object.values(modifiedStates).some(v => v));
+	const modifiedCount = computed(() => Object.values(modifiedStates).filter(v => v).length);
+
+	watch([currentState, previousState], () => {
+		for (const key in modifiedStates) {
+			modifiedStates[key] = !deepEqual(currentState[key], previousState[key]);
+		}
+	}, { deep: true });
+
+	async function _save() {
+		await save(unwrapReactive(currentState));
+		for (const key in currentState) {
+			previousState[key] = copy(currentState[key]);
+		}
+	}
+
+	function discard() {
+		for (const key in currentState) {
+			currentState[key] = copy(previousState[key]);
+		}
+	}
+
+	return {
+		state: currentState,
+		savedState: previousState,
+		modifiedStates,
+		modified,
+		modifiedCount,
+		save: _save,
+		discard,
+	};
+}
diff --git a/packages/frontend/src/composables/use-leave-guard.ts b/packages/frontend/src/composables/use-leave-guard.ts
new file mode 100644
index 0000000000..395c12a756
--- /dev/null
+++ b/packages/frontend/src/composables/use-leave-guard.ts
@@ -0,0 +1,50 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import type { Ref } from 'vue';
+
+export function useLeaveGuard(enabled: Ref<boolean>) {
+	/* TODO
+	const setLeaveGuard = inject('setLeaveGuard');
+
+	if (setLeaveGuard) {
+		setLeaveGuard(async () => {
+			if (!enabled.value) return false;
+
+			const { canceled } = await os.confirm({
+				type: 'warning',
+				text: i18n.ts.leaveConfirm,
+			});
+
+			return canceled;
+		});
+	} else {
+		onBeforeRouteLeave(async (to, from) => {
+			if (!enabled.value) return true;
+
+			const { canceled } = await os.confirm({
+				type: 'warning',
+				text: i18n.ts.leaveConfirm,
+			});
+
+			return !canceled;
+		});
+	}
+	*/
+
+	/*
+	function onBeforeLeave(ev: BeforeUnloadEvent) {
+		if (enabled.value) {
+			ev.preventDefault();
+			ev.returnValue = '';
+		}
+	}
+
+	window.addEventListener('beforeunload', onBeforeLeave);
+	onUnmounted(() => {
+		window.removeEventListener('beforeunload', onBeforeLeave);
+	});
+	*/
+}
diff --git a/packages/frontend/src/composables/use-loading.ts b/packages/frontend/src/composables/use-loading.ts
new file mode 100644
index 0000000000..6c6ff6ae0d
--- /dev/null
+++ b/packages/frontend/src/composables/use-loading.ts
@@ -0,0 +1,52 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { computed, h, ref } from 'vue';
+import MkLoading from '@/components/global/MkLoading.vue';
+
+export const useLoading = (props?: {
+	static?: boolean;
+	inline?: boolean;
+	colored?: boolean;
+	mini?: boolean;
+	em?: boolean;
+}) => {
+	const showingCnt = ref(0);
+
+	const show = () => {
+		showingCnt.value++;
+	};
+
+	const close = (force?: boolean) => {
+		if (force) {
+			showingCnt.value = 0;
+		} else {
+			showingCnt.value = Math.max(0, showingCnt.value - 1);
+		}
+	};
+
+	const scope = <T>(fn: () => T) => {
+		show();
+
+		const result = fn();
+		if (result instanceof Promise) {
+			return result.finally(() => close());
+		} else {
+			close();
+			return result;
+		}
+	};
+
+	const showing = computed(() => showingCnt.value > 0);
+	const component = computed(() => showing.value ? h(MkLoading, props) : null);
+
+	return {
+		show,
+		close,
+		scope,
+		component,
+		showing,
+	};
+};
diff --git a/packages/frontend/src/composables/use-mutation-observer.ts b/packages/frontend/src/composables/use-mutation-observer.ts
new file mode 100644
index 0000000000..7b774022dc
--- /dev/null
+++ b/packages/frontend/src/composables/use-mutation-observer.ts
@@ -0,0 +1,21 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { onUnmounted, watch } from 'vue';
+import type { Ref } from 'vue';
+
+export function useMutationObserver(targetNodeRef: Ref<HTMLElement | null | undefined>, options: MutationObserverInit, callback: MutationCallback): void {
+	const observer = new MutationObserver(callback);
+
+	watch(targetNodeRef, (targetNode) => {
+		if (targetNode) {
+			observer.observe(targetNode, options);
+		}
+	}, { immediate: true });
+
+	onUnmounted(() => {
+		observer.disconnect();
+	});
+}
diff --git a/packages/frontend/src/composables/use-note-capture.ts b/packages/frontend/src/composables/use-note-capture.ts
new file mode 100644
index 0000000000..2f33c25a0a
--- /dev/null
+++ b/packages/frontend/src/composables/use-note-capture.ts
@@ -0,0 +1,283 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { onUnmounted } from 'vue';
+import * as Misskey from 'misskey-js';
+import { EventEmitter } from 'eventemitter3';
+import type { Reactive, Ref } from 'vue';
+import { useStream } from '@/stream.js';
+import { $i } from '@/i.js';
+import { store } from '@/store.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { prefer } from '@/preferences.js';
+import { globalEvents } from '@/events.js';
+
+export const noteEvents = new EventEmitter<{
+	[ev: `reacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void;
+	[ev: `unreacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void;
+	[ev: `pollVoted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; choice: string; }) => void;
+}>();
+
+const fetchEvent = new EventEmitter<{
+	[id: string]: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>;
+}>();
+
+const pollingQueue = new Map<string, {
+	referenceCount: number;
+	lastAddedAt: number;
+}>();
+
+function pollingEnqueue(note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>) {
+	if (pollingQueue.has(note.id)) {
+		const data = pollingQueue.get(note.id)!;
+		pollingQueue.set(note.id, {
+			...data,
+			referenceCount: data.referenceCount + 1,
+			lastAddedAt: Date.now(),
+		});
+	} else {
+		pollingQueue.set(note.id, {
+			referenceCount: 1,
+			lastAddedAt: Date.now(),
+		});
+	}
+}
+
+function pollingDequeue(note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>) {
+	const data = pollingQueue.get(note.id);
+	if (data == null) return;
+
+	if (data.referenceCount === 1) {
+		pollingQueue.delete(note.id);
+	} else {
+		pollingQueue.set(note.id, {
+			...data,
+			referenceCount: data.referenceCount - 1,
+		});
+	}
+}
+
+const CAPTURE_MAX = 30;
+const MIN_POLLING_INTERVAL = 1000 * 10;
+const POLLING_INTERVAL =
+	prefer.s.pollingInterval === 1 ? MIN_POLLING_INTERVAL * 1.5 * 1.5 :
+	prefer.s.pollingInterval === 2 ? MIN_POLLING_INTERVAL * 1.5 :
+	prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL :
+	MIN_POLLING_INTERVAL;
+
+window.setInterval(() => {
+	const ids = [...pollingQueue.entries()]
+		.filter(([k, v]) => Date.now() - v.lastAddedAt < 1000 * 60 * 5) // 追加されてから一定時間経過したものは省く
+		.map(([k, v]) => k)
+		.sort((a, b) => (a > b ? -1 : 1)) // 新しいものを優先するためにIDで降順ソート
+		.slice(0, CAPTURE_MAX);
+
+	if (ids.length === 0) return;
+	if (window.document.hidden) return;
+
+	// まとめてリクエストするのではなく、個別にHTTPリクエスト投げてCDNにキャッシュさせた方がサーバーの負荷低減には良いかもしれない？
+	misskeyApi('notes/show-partial-bulk', {
+		noteIds: ids,
+	}).then((items) => {
+		for (const item of items) {
+			fetchEvent.emit(item.id, {
+				reactions: item.reactions,
+				reactionEmojis: item.reactionEmojis,
+			});
+		}
+	});
+}, POLLING_INTERVAL);
+
+function pollingSubscribe(props: {
+	note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>;
+	$note: ReactiveNoteData;
+}) {
+	const { note, $note } = props;
+
+	function onFetched(data: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>): void {
+		$note.reactions = data.reactions;
+		$note.reactionCount = Object.values(data.reactions).reduce((a, b) => a + b, 0);
+		$note.reactionEmojis = data.reactionEmojis;
+	}
+
+	pollingEnqueue(note);
+	fetchEvent.on(note.id, onFetched);
+
+	onUnmounted(() => {
+		pollingDequeue(note);
+		fetchEvent.off(note.id, onFetched);
+	});
+}
+
+function realtimeSubscribe(props: {
+	note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>;
+}): void {
+	const note = props.note;
+	const connection = useStream();
+
+	function onStreamNoteUpdated(noteData): void {
+		const { type, id, body } = noteData;
+
+		if (id !== note.id) return;
+
+		switch (type) {
+			case 'reacted': {
+				noteEvents.emit(`reacted:${id}`, {
+					userId: body.userId,
+					reaction: body.reaction,
+					emoji: body.emoji,
+				});
+				break;
+			}
+
+			case 'unreacted': {
+				noteEvents.emit(`unreacted:${id}`, {
+					userId: body.userId,
+					reaction: body.reaction,
+					emoji: body.emoji,
+				});
+				break;
+			}
+
+			case 'pollVoted': {
+				noteEvents.emit(`pollVoted:${id}`, {
+					userId: body.userId,
+					choice: body.choice,
+				});
+				break;
+			}
+
+			case 'deleted': {
+				globalEvents.emit('noteDeleted', id);
+				break;
+			}
+		}
+	}
+
+	function capture(withHandler = false): void {
+		connection.send('sr', { id: note.id });
+		if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
+	}
+
+	function decapture(withHandler = false): void {
+		connection.send('un', { id: note.id });
+		if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
+	}
+
+	function onStreamConnected() {
+		capture(false);
+	}
+
+	capture(true);
+	connection.on('_connected_', onStreamConnected);
+
+	onUnmounted(() => {
+		decapture(true);
+		connection.off('_connected_', onStreamConnected);
+	});
+}
+
+type ReactiveNoteData = Reactive<{
+	reactions: Misskey.entities.Note['reactions'];
+	reactionCount: Misskey.entities.Note['reactionCount'];
+	reactionEmojis: Misskey.entities.Note['reactionEmojis'];
+	myReaction: Misskey.entities.Note['myReaction'];
+	pollChoices: NonNullable<Misskey.entities.Note['poll']>['choices'];
+}>;
+
+export function useNoteCapture(props: {
+	note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>;
+	parentNote: Misskey.entities.Note | null;
+	$note: ReactiveNoteData;
+}) {
+	const { note, parentNote, $note } = props;
+
+	noteEvents.on(`reacted:${note.id}`, onReacted);
+	noteEvents.on(`unreacted:${note.id}`, onUnreacted);
+	noteEvents.on(`pollVoted:${note.id}`, onPollVoted);
+
+	let latestReactedKey: string | null = null;
+	let latestUnreactedKey: string | null = null;
+	let latestPollVotedKey: string | null = null;
+
+	function onReacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void {
+		const newReactedKey = `${ctx.userId}:${ctx.reaction}`;
+		if (newReactedKey === latestReactedKey) return;
+		latestReactedKey = newReactedKey;
+
+		if (ctx.emoji && !(ctx.emoji.name in $note.reactionEmojis)) {
+			$note.reactionEmojis[ctx.emoji.name] = ctx.emoji.url;
+		}
+
+		const currentCount = $note.reactions[ctx.reaction] || 0;
+
+		$note.reactions[ctx.reaction] = currentCount + 1;
+		$note.reactionCount += 1;
+
+		if ($i && (ctx.userId === $i.id)) {
+			$note.myReaction = ctx.reaction;
+		}
+	}
+
+	function onUnreacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void {
+		const newUnreactedKey = `${ctx.userId}:${ctx.reaction}`;
+		if (newUnreactedKey === latestUnreactedKey) return;
+		latestUnreactedKey = newUnreactedKey;
+
+		const currentCount = $note.reactions[ctx.reaction] || 0;
+
+		$note.reactions[ctx.reaction] = Math.max(0, currentCount - 1);
+		$note.reactionCount = Math.max(0, $note.reactionCount - 1);
+		if ($note.reactions[ctx.reaction] === 0) delete $note.reactions[ctx.reaction];
+
+		if ($i && (ctx.userId === $i.id)) {
+			$note.myReaction = null;
+		}
+	}
+
+	function onPollVoted(ctx: { userId: Misskey.entities.User['id']; choice: string; }): void {
+		const newPollVotedKey = `${ctx.userId}:${ctx.choice}`;
+		if (newPollVotedKey === latestPollVotedKey) return;
+		latestPollVotedKey = newPollVotedKey;
+
+		const choices = [...$note.pollChoices];
+		choices[ctx.choice] = {
+			...choices[ctx.choice],
+			votes: choices[ctx.choice].votes + 1,
+			...($i && (ctx.userId === $i.id) ? {
+				isVoted: true,
+			} : {}),
+		};
+
+		$note.pollChoices = choices;
+	}
+
+	onUnmounted(() => {
+		noteEvents.off(`reacted:${note.id}`, onReacted);
+		noteEvents.off(`unreacted:${note.id}`, onUnreacted);
+		noteEvents.off(`pollVoted:${note.id}`, onPollVoted);
+	});
+
+	// 投稿からある程度経過している(=タイムラインを遡って表示した)ノートは、イベントが発生する可能性が低いためそもそも購読しない
+	// ただし「リノートされたばかりの過去のノート」(= parentNoteが存在し、かつparentNoteの投稿日時が最近)はイベント発生が考えられるため購読する
+	// TODO: デバイスとサーバーの時計がズレていると不具合の元になるため、ズレを検知して警告を表示するなどのケアが必要かもしれない
+	if (parentNote == null) {
+		if ((Date.now() - new Date(note.createdAt).getTime()) > 1000 * 60 * 5) { // 5min
+			// リノートで表示されているノートでもないし、投稿からある程度経過しているので購読しない
+			return;
+		}
+	} else {
+		if ((Date.now() - new Date(parentNote.createdAt).getTime()) > 1000 * 60 * 5) { // 5min
+			// リノートで表示されているノートだが、リノートされてからある程度経過しているので購読しない
+			return;
+		}
+	}
+
+	if ($i && store.s.realtimeMode) {
+		realtimeSubscribe(props);
+	} else {
+		pollingSubscribe(props);
+	}
+}
diff --git a/packages/frontend/src/composables/use-pagination.ts b/packages/frontend/src/composables/use-pagination.ts
new file mode 100644
index 0000000000..f1042985bf
--- /dev/null
+++ b/packages/frontend/src/composables/use-pagination.ts
@@ -0,0 +1,258 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { computed, isRef, onMounted, ref, shallowRef, triggerRef, watch } from 'vue';
+import * as Misskey from 'misskey-js';
+import type { ComputedRef, DeepReadonly, Ref, ShallowRef } from 'vue';
+import { misskeyApi } from '@/utility/misskey-api.js';
+
+const MAX_ITEMS = 30;
+const MAX_QUEUE_ITEMS = 100;
+const FIRST_FETCH_LIMIT = 15;
+const SECOND_FETCH_LIMIT = 30;
+
+export type MisskeyEntity = {
+	id: string;
+	createdAt: string;
+	_shouldInsertAd_?: boolean;
+	[x: string]: any;
+};
+
+export type PagingCtx<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;
+
+	offsetMode?: boolean;
+
+	baseId?: MisskeyEntity['id'];
+	direction?: 'newer' | 'older';
+};
+
+export function usePagination<T extends MisskeyEntity>(props: {
+	ctx: PagingCtx;
+	useShallowRef?: boolean;
+}) {
+	const items = props.useShallowRef ? shallowRef<T[]>([]) : ref<T[]>([]);
+	let aheadQueue: T[] = [];
+	const queuedAheadItemsCount = ref(0);
+	const fetching = ref(true);
+	const fetchingOlder = ref(false);
+	const canFetchOlder = ref(false);
+	const error = ref(false);
+
+	// パラメータに何らかの変更があった際、再読込したい（チャンネル等のIDが変わったなど）
+	watch(() => [props.ctx.endpoint, props.ctx.params], init, { deep: true });
+
+	function getNewestId(): string | null | undefined {
+		// 様々な要因により並び順は保証されないのでソートが必要
+		if (aheadQueue.length > 0) {
+			return aheadQueue.map(x => x.id).sort().at(-1);
+		}
+		return items.value.map(x => x.id).sort().at(-1);
+	}
+
+	function getOldestId(): string | null | undefined {
+		// 様々な要因により並び順は保証されないのでソートが必要
+		return items.value.map(x => x.id).sort().at(0);
+	}
+
+	async function init(): Promise<void> {
+		items.value = [];
+		aheadQueue = [];
+		queuedAheadItemsCount.value = 0;
+		fetching.value = true;
+		const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {};
+
+		await misskeyApi<T[]>(props.ctx.endpoint, {
+			...params,
+			limit: props.ctx.limit ?? FIRST_FETCH_LIMIT,
+			allowPartial: true,
+			...(props.ctx.baseId && props.ctx.direction === 'newer' ? {
+				sinceId: props.ctx.baseId,
+			} : props.ctx.baseId && props.ctx.direction === 'older' ? {
+				untilId: props.ctx.baseId,
+			} : {}),
+		}).then(res => {
+			// 逆順で返ってくるので
+			if (props.ctx.baseId && props.ctx.direction === 'newer') {
+				res.reverse();
+			}
+
+			for (let i = 0; i < res.length; i++) {
+				const item = res[i];
+				if (i === 3) item._shouldInsertAd_ = true;
+			}
+
+			if (res.length === 0 || props.ctx.noPaging) {
+				pushItems(res);
+				canFetchOlder.value = false;
+			} else {
+				pushItems(res);
+				canFetchOlder.value = true;
+			}
+
+			error.value = false;
+			fetching.value = false;
+		}, err => {
+			error.value = true;
+			fetching.value = false;
+		});
+	}
+
+	function reload(): Promise<void> {
+		return init();
+	}
+
+	async function fetchOlder(): Promise<void> {
+		if (!canFetchOlder.value || fetching.value || fetchingOlder.value || items.value.length === 0) return;
+		fetchingOlder.value = true;
+		const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {};
+		await misskeyApi<T[]>(props.ctx.endpoint, {
+			...params,
+			limit: SECOND_FETCH_LIMIT,
+			...(props.ctx.offsetMode ? {
+				offset: items.value.length,
+			} : {
+				untilId: getOldestId(),
+			}),
+		}).then(res => {
+			for (let i = 0; i < res.length; i++) {
+				const item = res[i];
+				if (i === 10) item._shouldInsertAd_ = true;
+			}
+
+			if (res.length === 0) {
+				canFetchOlder.value = false;
+				fetchingOlder.value = false;
+			} else {
+				pushItems(res);
+				canFetchOlder.value = true;
+				fetchingOlder.value = false;
+			}
+		}, err => {
+			fetchingOlder.value = false;
+		});
+	}
+
+	async function fetchNewer(options: {
+		toQueue?: boolean;
+	} = {}): Promise<void> {
+		const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {};
+		await misskeyApi<T[]>(props.ctx.endpoint, {
+			...params,
+			limit: SECOND_FETCH_LIMIT,
+			...(props.ctx.offsetMode ? {
+				offset: items.value.length,
+			} : {
+				sinceId: getNewestId(),
+			}),
+		}).then(res => {
+			if (res.length === 0) return; // これやらないと余計なre-renderが走る
+
+			if (options.toQueue) {
+				aheadQueue.unshift(...res.toReversed());
+				if (aheadQueue.length > MAX_QUEUE_ITEMS) {
+					aheadQueue = aheadQueue.slice(0, MAX_QUEUE_ITEMS);
+				}
+				queuedAheadItemsCount.value = aheadQueue.length;
+			} else {
+				unshiftItems(res.toReversed());
+			}
+		});
+	}
+
+	function trim(trigger = true) {
+		if (items.value.length >= MAX_ITEMS) canFetchOlder.value = true;
+		items.value = items.value.slice(0, MAX_ITEMS);
+		if (props.useShallowRef && trigger) triggerRef(items);
+	}
+
+	function unshiftItems(newItems: T[]) {
+		if (newItems.length === 0) return; // これやらないと余計なre-renderが走る
+		items.value.unshift(...newItems.filter(x => !items.value.some(y => y.id === x.id))); // ストリーミングやポーリングのタイミングによっては重複することがあるため
+		trim(false);
+		if (props.useShallowRef) triggerRef(items);
+	}
+
+	function pushItems(oldItems: T[]) {
+		if (oldItems.length === 0) return; // これやらないと余計なre-renderが走る
+		items.value.push(...oldItems);
+		if (props.useShallowRef) triggerRef(items);
+	}
+
+	function prepend(item: T) {
+		if (items.value.some(x => x.id === item.id)) return;
+		items.value.unshift(item);
+		trim(false);
+		if (props.useShallowRef) triggerRef(items);
+	}
+
+	function enqueue(item: T) {
+		aheadQueue.unshift(item);
+		if (aheadQueue.length > MAX_QUEUE_ITEMS) {
+			aheadQueue.pop();
+		}
+		queuedAheadItemsCount.value = aheadQueue.length;
+	}
+
+	function releaseQueue() {
+		if (aheadQueue.length === 0) return; // これやらないと余計なre-renderが走る
+		unshiftItems(aheadQueue);
+		aheadQueue = [];
+		queuedAheadItemsCount.value = 0;
+	}
+
+	function removeItem(id: string) {
+		// TODO: queueからも消す
+
+		const index = items.value.findIndex(x => x.id === id);
+		if (index !== -1) {
+			items.value.splice(index, 1);
+			if (props.useShallowRef) triggerRef(items);
+		}
+	}
+
+	function updateItem(id: string, updator: (item: T) => T) {
+		// TODO: queueのも更新
+
+		const index = items.value.findIndex(x => x.id === id);
+		if (index !== -1) {
+			const item = items.value[index]!;
+			items.value[index] = updator(item);
+			if (props.useShallowRef) triggerRef(items);
+		}
+	}
+
+	onMounted(() => {
+		init();
+	});
+
+	return {
+		items: items as DeepReadonly<ShallowRef<T[]>>,
+		queuedAheadItemsCount,
+		fetching,
+		fetchingOlder,
+		canFetchOlder,
+		init,
+		reload,
+		fetchOlder,
+		fetchNewer,
+		unshiftItems,
+		prepend,
+		trim,
+		removeItem,
+		updateItem,
+		enqueue,
+		releaseQueue,
+		error,
+	};
+}
diff --git a/packages/frontend/src/composables/use-scroll-position-keeper.ts b/packages/frontend/src/composables/use-scroll-position-keeper.ts
new file mode 100644
index 0000000000..b584171cbe
--- /dev/null
+++ b/packages/frontend/src/composables/use-scroll-position-keeper.ts
@@ -0,0 +1,77 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { throttle } from 'throttle-debounce';
+import { nextTick, onActivated, onDeactivated, onUnmounted, watch } from 'vue';
+import type { Ref } from 'vue';
+
+// note render skippingがオンだとズレるため、遷移直前にスクロール範囲に表示されているdata-scroll-anchor要素を特定して、復元時に当該要素までスクロールするようにする
+
+// TODO: data-scroll-anchor がひとつも存在しない場合、または手動で useAnchor みたいなフラグをfalseで呼ばれた場合、単純にスクロール位置を使用する処理にフォールバックするようにする
+
+export function useScrollPositionKeeper(scrollContainerRef: Ref<HTMLElement | null | undefined>): void {
+	let anchorId: string | null = null;
+	let ready = true;
+
+	watch(scrollContainerRef, (el) => {
+		if (!el) return;
+
+		const onScroll = () => {
+			if (!el) return;
+			if (!ready) return;
+
+			const scrollContainerRect = el.getBoundingClientRect();
+			const viewPosition = scrollContainerRect.height / 2;
+
+			const anchorEls = el.querySelectorAll('[data-scroll-anchor]');
+			for (let i = anchorEls.length - 1; i > -1; i--) { // 下から見た方が速い
+				const anchorEl = anchorEls[i] as HTMLElement;
+				const anchorRect = anchorEl.getBoundingClientRect();
+				const anchorTop = anchorRect.top;
+				const anchorBottom = anchorRect.bottom;
+				if (anchorTop <= viewPosition && anchorBottom >= viewPosition) {
+					anchorId = anchorEl.getAttribute('data-scroll-anchor');
+					break;
+				}
+			}
+		};
+
+		// ほんとはscrollイベントじゃなくてonBeforeDeactivatedでやりたい
+		// https://github.com/vuejs/vue/issues/9454
+		// https://github.com/vuejs/rfcs/pull/284
+		el.addEventListener('scroll', throttle(1000, onScroll), { passive: true });
+	}, {
+		immediate: true,
+	});
+
+	const restore = () => {
+		if (!anchorId) return;
+		const scrollContainer = scrollContainerRef.value;
+		if (!scrollContainer) return;
+		const scrollAnchorEl = scrollContainer.querySelector(`[data-scroll-anchor="${anchorId}"]`);
+		if (!scrollAnchorEl) return;
+		scrollAnchorEl.scrollIntoView({
+			behavior: 'instant',
+			block: 'center',
+			inline: 'center',
+		});
+	};
+
+	onDeactivated(() => {
+		ready = false;
+	});
+
+	onActivated(() => {
+		restore();
+		nextTick(() => {
+			restore();
+			window.setTimeout(() => {
+				restore();
+
+				ready = true;
+			}, 100);
+		});
+	});
+}
diff --git a/packages/frontend/src/composables/use-tooltip.ts b/packages/frontend/src/composables/use-tooltip.ts
new file mode 100644
index 0000000000..af76a3a1e8
--- /dev/null
+++ b/packages/frontend/src/composables/use-tooltip.ts
@@ -0,0 +1,106 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { ref, watch, onUnmounted } from 'vue';
+import type { Ref } from 'vue';
+
+export function useTooltip(
+	elRef: Ref<HTMLElement | { $el: HTMLElement } | null | undefined>,
+	onShow: (showing: Ref<boolean>) => void,
+	delay = 300,
+): void {
+	let isHovering = false;
+
+	// iOS(Androidも？)では、要素をタップした直後に(おせっかいで)mouseoverイベントを発火させたりするため、それを無視するためのフラグ
+	// 無視しないと、画面に触れてないのにツールチップが出たりし、ユーザビリティが損なわれる
+	// TODO: 一度でもタップすると二度とマウスでツールチップ出せなくなるのをどうにかする 定期的にfalseに戻すとか...？
+	let shouldIgnoreMouseover = false;
+
+	let timeoutId: number;
+
+	let changeShowingState: (() => void) | null;
+
+	let autoHidingTimer;
+
+	const open = () => {
+		close();
+		if (!isHovering) return;
+		if (elRef.value == null) return;
+		const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
+		if (!window.document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため
+
+		const showing = ref(true);
+		onShow(showing);
+		changeShowingState = () => {
+			showing.value = false;
+		};
+
+		autoHidingTimer = window.setInterval(() => {
+			if (elRef.value == null || !window.document.body.contains(elRef.value instanceof Element ? elRef.value : elRef.value.$el)) {
+				if (!isHovering) return;
+				isHovering = false;
+				window.clearTimeout(timeoutId);
+				close();
+				window.clearInterval(autoHidingTimer);
+			}
+		}, 1000);
+	};
+
+	const close = () => {
+		if (changeShowingState != null) {
+			changeShowingState();
+			changeShowingState = null;
+		}
+	};
+
+	const onMouseover = () => {
+		if (isHovering) return;
+		if (shouldIgnoreMouseover) return;
+		isHovering = true;
+		timeoutId = window.setTimeout(open, delay);
+	};
+
+	const onMouseleave = () => {
+		if (!isHovering) return;
+		isHovering = false;
+		window.clearTimeout(timeoutId);
+		window.clearInterval(autoHidingTimer);
+		close();
+	};
+
+	const onTouchstart = () => {
+		shouldIgnoreMouseover = true;
+		if (isHovering) return;
+		isHovering = true;
+		timeoutId = window.setTimeout(open, delay);
+	};
+
+	const onTouchend = () => {
+		if (!isHovering) return;
+		isHovering = false;
+		window.clearTimeout(timeoutId);
+		window.clearInterval(autoHidingTimer);
+		close();
+	};
+
+	const stop = watch(elRef, () => {
+		if (elRef.value) {
+			stop();
+			const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
+			el.addEventListener('mouseover', onMouseover, { passive: true });
+			el.addEventListener('mouseleave', onMouseleave, { passive: true });
+			el.addEventListener('touchstart', onTouchstart, { passive: true });
+			el.addEventListener('touchend', onTouchend, { passive: true });
+			el.addEventListener('click', close, { passive: true });
+		}
+	}, {
+		immediate: true,
+		flush: 'post',
+	});
+
+	onUnmounted(() => {
+		close();
+	});
+}
diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue
index dc3c906217..7e5abb4b34 100644
--- a/packages/frontend/src/pages/about.federation.vue
+++ b/packages/frontend/src/pages/about.federation.vue
@@ -55,7 +55,7 @@ import { computed, ref } from 'vue';
 import MkInput from '@/components/MkInput.vue';
 import MkSelect from '@/components/MkSelect.vue';
 import MkPagination from '@/components/MkPagination.vue';
-import type { PagingCtx } from '@/use/use-pagination.js';
+import type { PagingCtx } from '@/composables/use-pagination.js';
 import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
 import FormSplit from '@/components/form/split.vue';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue
index 2bd734f7d3..6c580f87f1 100644
--- a/packages/frontend/src/pages/admin/bot-protection.vue
+++ b/packages/frontend/src/pages/admin/bot-protection.vue
@@ -163,7 +163,7 @@ import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { fetchInstance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
-import { useForm } from '@/use/use-form.js';
+import { useForm } from '@/composables/use-form.js';
 import MkFormFooter from '@/components/MkFormFooter.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkInfo from '@/components/MkInfo.vue';
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue
index 59b780bff6..0ff251abb8 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue
@@ -89,7 +89,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 import MkPagingButtons from '@/components/MkPagingButtons.vue';
 import { selectFile } from '@/utility/select-file.js';
 import { copyGridDataToClipboard, removeDataFromGrid } from '@/components/grid/grid-utils.js';
-import { useLoading } from '@/components/hook/useLoading.js';
+import { useLoading } from '@/composables/use-loading.js';
 
 type GridItem = {
 	checked: boolean;
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue
index 2fd7e331a2..d9c9227105 100644
--- a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue
@@ -159,7 +159,7 @@ import * as os from '@/os.js';
 import { deviceKind } from '@/utility/device-kind.js';
 import MkPagingButtons from '@/components/MkPagingButtons.vue';
 import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue';
-import { useLoading } from '@/components/hook/useLoading.js';
+import { useLoading } from '@/composables/use-loading.js';
 
 type GridItem = {
 	checked: boolean;
diff --git a/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue b/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue
index 5dd2887024..9a311b5772 100644
--- a/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue
+++ b/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue
@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, useTemplateRef } from 'vue';
 import { Chart } from 'chart.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/use/use-chart-tooltip.js';
+import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { alpha } from '@/utility/color.js';
 import { initChart } from '@/utility/init-chart.js';
diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue
index 754bf74c2a..f1584fc864 100644
--- a/packages/frontend/src/pages/admin/invites.vue
+++ b/packages/frontend/src/pages/admin/invites.vue
@@ -55,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { computed, ref, useTemplateRef } from 'vue';
-import type { PagingCtx } from '@/use/use-pagination.js';
+import type { PagingCtx } from '@/composables/use-pagination.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
diff --git a/packages/frontend/src/pages/admin/job-queue.chart.vue b/packages/frontend/src/pages/admin/job-queue.chart.vue
index f42b35105e..f7b7eef87f 100644
--- a/packages/frontend/src/pages/admin/job-queue.chart.vue
+++ b/packages/frontend/src/pages/admin/job-queue.chart.vue
@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, useTemplateRef, watch } from 'vue';
 import { Chart } from 'chart.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/use/use-chart-tooltip.js';
+import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { alpha } from '@/utility/color.js';
 import { initChart } from '@/utility/init-chart.js';
diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue
index 5b7f669f6b..f74356599b 100644
--- a/packages/frontend/src/pages/admin/overview.active-users.vue
+++ b/packages/frontend/src/pages/admin/overview.active-users.vue
@@ -18,7 +18,7 @@ import { Chart } from 'chart.js';
 import gradient from 'chartjs-plugin-gradient';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/use/use-chart-tooltip.js';
+import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { initChart } from '@/utility/init-chart.js';
 
diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue
index 4c06d94d6d..96ea4749dc 100644
--- a/packages/frontend/src/pages/admin/overview.ap-requests.vue
+++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue
@@ -25,7 +25,7 @@ import { Chart } from 'chart.js';
 import gradient from 'chartjs-plugin-gradient';
 import isChromatic from 'chromatic';
 import { misskeyApi } from '@/utility/misskey-api.js';
-import { useChartTooltip } from '@/use/use-chart-tooltip.js';
+import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { store } from '@/store.js';
 import { alpha } from '@/utility/color.js';
diff --git a/packages/frontend/src/pages/admin/overview.federation.vue b/packages/frontend/src/pages/admin/overview.federation.vue
index 6d6d431863..50f12cbf45 100644
--- a/packages/frontend/src/pages/admin/overview.federation.vue
+++ b/packages/frontend/src/pages/admin/overview.federation.vue
@@ -54,7 +54,7 @@ import { misskeyApiGet } from '@/utility/misskey-api.js';
 import number from '@/filters/number.js';
 import MkNumberDiff from '@/components/MkNumberDiff.vue';
 import { i18n } from '@/i18n.js';
-import { useChartTooltip } from '@/use/use-chart-tooltip.js';
+import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
 
 const topSubInstancesForPie = ref<InstanceForPie[] | null>(null);
 const topPubInstancesForPie = ref<InstanceForPie[] | null>(null);
diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue
index 86c5eff4da..ec2b558cee 100644
--- a/packages/frontend/src/pages/admin/overview.pie.vue
+++ b/packages/frontend/src/pages/admin/overview.pie.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { onMounted, useTemplateRef } from 'vue';
 import { Chart } from 'chart.js';
-import { useChartTooltip } from '@/use/use-chart-tooltip.js';
+import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
 import { initChart } from '@/utility/init-chart.js';
 
 export type InstanceForPie = {
diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue
index 6fc941a848..9b9618c4ac 100644
--- a/packages/frontend/src/pages/admin/overview.queue.chart.vue
+++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue
@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { onMounted, useTemplateRef } from 'vue';
 import { Chart } from 'chart.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/use/use-chart-tooltip.js';
+import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { alpha } from '@/utility/color.js';
 import { initChart } from '@/utility/init-chart.js';
diff --git a/packages/frontend/src/pages/admin/performance.vue b/packages/frontend/src/pages/admin/performance.vue
index a272b9adea..c28621b11e 100644
--- a/packages/frontend/src/pages/admin/performance.vue
+++ b/packages/frontend/src/pages/admin/performance.vue
@@ -117,7 +117,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkLink from '@/components/MkLink.vue';
-import { useForm } from '@/use/use-form.js';
+import { useForm } from '@/composables/use-form.js';
 import MkFormFooter from '@/components/MkFormFooter.vue';
 
 const meta = await misskeyApi('admin/meta');
diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue
index ffb34f6e52..9e907a4469 100644
--- a/packages/frontend/src/pages/admin/security.vue
+++ b/packages/frontend/src/pages/admin/security.vue
@@ -133,7 +133,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
 import { fetchInstance } from '@/instance.js';
 import { i18n } from '@/i18n.js';
 import { definePage } from '@/page.js';
-import { useForm } from '@/use/use-form.js';
+import { useForm } from '@/composables/use-form.js';
 import MkFormFooter from '@/components/MkFormFooter.vue';
 
 const meta = await misskeyApi('admin/meta');
diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue
index 5914e243c6..82083b4e08 100644
--- a/packages/frontend/src/pages/admin/settings.vue
+++ b/packages/frontend/src/pages/admin/settings.vue
@@ -294,7 +294,7 @@ import { definePage } from '@/page.js';
 import MkButton from '@/components/MkButton.vue';
 import MkFolder from '@/components/MkFolder.vue';
 import MkKeyValue from '@/components/MkKeyValue.vue';
-import { useForm } from '@/use/use-form.js';
+import { useForm } from '@/composables/use-form.js';
 import MkFormFooter from '@/components/MkFormFooter.vue';
 import MkRadios from '@/components/MkRadios.vue';
 
diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue
index e05125a3b2..64d3420166 100644
--- a/packages/frontend/src/pages/chat/room.vue
+++ b/packages/frontend/src/pages/chat/room.vue
@@ -108,7 +108,7 @@ import { definePage } from '@/page.js';
 import { prefer } from '@/preferences.js';
 import MkButton from '@/components/MkButton.vue';
 import { useRouter } from '@/router.js';
-import { useMutationObserver } from '@/use/use-mutation-observer.js';
+import { useMutationObserver } from '@/composables/use-mutation-observer.js';
 import MkInfo from '@/components/MkInfo.vue';
 import { makeDateSeparatedTimelineComputedRef } from '@/utility/timeline-date-separate.js';
 
diff --git a/packages/frontend/src/pages/drive.file.notes.vue b/packages/frontend/src/pages/drive.file.notes.vue
index 95ac5082bb..cf45470588 100644
--- a/packages/frontend/src/pages/drive.file.notes.vue
+++ b/packages/frontend/src/pages/drive.file.notes.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { ref, computed } from 'vue';
 import { i18n } from '@/i18n.js';
-import type { PagingCtx } from '@/use/use-pagination.js';
+import type { PagingCtx } from '@/composables/use-pagination.js';
 import MkInfo from '@/components/MkInfo.vue';
 import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
 
diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue
index e98ae99a10..e02abdc393 100644
--- a/packages/frontend/src/pages/follow-requests.vue
+++ b/packages/frontend/src/pages/follow-requests.vue
@@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import * as Misskey from 'misskey-js';
 import { useTemplateRef, computed, ref } from 'vue';
-import type { PagingCtx } from '@/use/use-pagination.js';
+import type { PagingCtx } from '@/composables/use-pagination.js';
 import MkPagination from '@/components/MkPagination.vue';
 import MkButton from '@/components/MkButton.vue';
 import { userPage, acct } from '@/filters/user.js';
diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue
index c25a5b36d8..0057106411 100644
--- a/packages/frontend/src/pages/instance-info.vue
+++ b/packages/frontend/src/pages/instance-info.vue
@@ -133,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref, computed, watch } from 'vue';
 import * as Misskey from 'misskey-js';
 import type { ChartSrc } from '@/components/MkChart.vue';
-import type { PagingCtx } from '@/use/use-pagination.js';
+import type { PagingCtx } from '@/composables/use-pagination.js';
 import MkChart from '@/components/MkChart.vue';
 import MkObjectView from '@/components/MkObjectView.vue';
 import FormLink from '@/components/form/link.vue';
diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue
index 4cc9021424..98e3190e4b 100644
--- a/packages/frontend/src/pages/invite.vue
+++ b/packages/frontend/src/pages/invite.vue
@@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, ref, useTemplateRef } from 'vue';
 import * as Misskey from 'misskey-js';
-import type { PagingCtx } from '@/use/use-pagination.js';
+import type { PagingCtx } from '@/composables/use-pagination.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue
index 68a009f648..8a645e417c 100644
--- a/packages/frontend/src/pages/note.vue
+++ b/packages/frontend/src/pages/note.vue
@@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, watch, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import { host } from '@@/js/config.js';
-import type { PagingCtx } from '@/use/use-pagination.js';
+import type { PagingCtx } from '@/composables/use-pagination.js';
 import MkNoteDetailed from '@/components/MkNoteDetailed.vue';
 import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
 import MkRemoteCaution from '@/components/MkRemoteCaution.vue';
diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue
index 35609912eb..352564bc9c 100644
--- a/packages/frontend/src/pages/search.note.vue
+++ b/packages/frontend/src/pages/search.note.vue
@@ -113,7 +113,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { computed, ref, shallowRef, toRef } from 'vue';
 import type * as Misskey from 'misskey-js';
-import type { PagingCtx } from '@/use/use-pagination.js';
+import type { PagingCtx } from '@/composables/use-pagination.js';
 import { $i } from '@/i.js';
 import { host as localHost } from '@@/js/config.js';
 import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue
index fb4166fe36..d98b58c748 100644
--- a/packages/frontend/src/pages/search.user.vue
+++ b/packages/frontend/src/pages/search.user.vue
@@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { ref, toRef } from 'vue';
 import type { Endpoints } from 'misskey-js';
-import type { PagingCtx } from '@/use/use-pagination.js';
+import type { PagingCtx } from '@/composables/use-pagination.js';
 import MkUserList from '@/components/MkUserList.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkRadios from '@/components/MkRadios.vue';
diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue
index e8007b9779..a2ee04b555 100644
--- a/packages/frontend/src/pages/theme-editor.vue
+++ b/packages/frontend/src/pages/theme-editor.vue
@@ -90,7 +90,7 @@ import { addTheme, applyTheme } from '@/theme.js';
 import * as os from '@/os.js';
 import { store } from '@/store.js';
 import { i18n } from '@/i18n.js';
-import { useLeaveGuard } from '@/use/use-leave-guard.js';
+import { useLeaveGuard } from '@/composables/use-leave-guard.js';
 import { definePage } from '@/page.js';
 import { prefer } from '@/preferences.js';
 
diff --git a/packages/frontend/src/pages/user/activity.following.vue b/packages/frontend/src/pages/user/activity.following.vue
index f5d2002669..f2a5ad8e75 100644
--- a/packages/frontend/src/pages/user/activity.following.vue
+++ b/packages/frontend/src/pages/user/activity.following.vue
@@ -21,7 +21,7 @@ import gradient from 'chartjs-plugin-gradient';
 import type { ChartDataset } from 'chart.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/use/use-chart-tooltip.js';
+import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { initChart } from '@/utility/init-chart.js';
 import { chartLegend } from '@/utility/chart-legend.js';
diff --git a/packages/frontend/src/pages/user/activity.notes.vue b/packages/frontend/src/pages/user/activity.notes.vue
index 01c62810d4..ddde84ef25 100644
--- a/packages/frontend/src/pages/user/activity.notes.vue
+++ b/packages/frontend/src/pages/user/activity.notes.vue
@@ -21,7 +21,7 @@ import gradient from 'chartjs-plugin-gradient';
 import type { ChartDataset } from 'chart.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/use/use-chart-tooltip.js';
+import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { initChart } from '@/utility/init-chart.js';
 import { chartLegend } from '@/utility/chart-legend.js';
diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue
index ed12b1b5c7..34e1fe3abf 100644
--- a/packages/frontend/src/pages/user/activity.pv.vue
+++ b/packages/frontend/src/pages/user/activity.pv.vue
@@ -21,7 +21,7 @@ import gradient from 'chartjs-plugin-gradient';
 import type { ChartDataset } from 'chart.js';
 import { misskeyApi } from '@/utility/misskey-api.js';
 import { store } from '@/store.js';
-import { useChartTooltip } from '@/use/use-chart-tooltip.js';
+import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
 import { chartVLine } from '@/utility/chart-vline.js';
 import { initChart } from '@/utility/init-chart.js';
 import { chartLegend } from '@/utility/chart-legend.js';
diff --git a/packages/frontend/src/use/use-chart-tooltip.ts b/packages/frontend/src/use/use-chart-tooltip.ts
deleted file mode 100644
index bba64fc6ee..0000000000
--- a/packages/frontend/src/use/use-chart-tooltip.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { onUnmounted, onDeactivated, ref } from 'vue';
-import * as os from '@/os.js';
-import MkChartTooltip from '@/components/MkChartTooltip.vue';
-
-export function useChartTooltip(opts: { position: 'top' | 'middle' } = { position: 'top' }) {
-	const tooltipShowing = ref(false);
-	const tooltipX = ref(0);
-	const tooltipY = ref(0);
-	const tooltipTitle = ref<string | null>(null);
-	const tooltipSeries = ref<{
-		backgroundColor: string;
-		borderColor: string;
-		text: string;
-	}[] | null>(null);
-	const { dispose: disposeTooltipComponent } = os.popup(MkChartTooltip, {
-		showing: tooltipShowing,
-		x: tooltipX,
-		y: tooltipY,
-		title: tooltipTitle,
-		series: tooltipSeries,
-	}, {});
-
-	onUnmounted(() => {
-		disposeTooltipComponent();
-	});
-
-	onDeactivated(() => {
-		tooltipShowing.value = false;
-	});
-
-	function handler(context) {
-		if (context.tooltip.opacity === 0) {
-			tooltipShowing.value = false;
-			return;
-		}
-
-		tooltipTitle.value = context.tooltip.title[0];
-		tooltipSeries.value = context.tooltip.body.map((b, i) => ({
-			backgroundColor: context.tooltip.labelColors[i].backgroundColor,
-			borderColor: context.tooltip.labelColors[i].borderColor,
-			text: b.lines[0],
-		}));
-
-		const rect = context.chart.canvas.getBoundingClientRect();
-
-		tooltipShowing.value = true;
-		tooltipX.value = rect.left + window.scrollX + context.tooltip.caretX;
-		if (opts.position === 'top') {
-			tooltipY.value = rect.top + window.scrollY;
-		} else if (opts.position === 'middle') {
-			tooltipY.value = rect.top + window.scrollY + context.tooltip.caretY;
-		}
-	}
-
-	return {
-		handler,
-	};
-}
diff --git a/packages/frontend/src/use/use-form.ts b/packages/frontend/src/use/use-form.ts
deleted file mode 100644
index 1c93557413..0000000000
--- a/packages/frontend/src/use/use-form.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { computed, reactive, watch } from 'vue';
-import type { Reactive } from 'vue';
-import { deepEqual } from '@/utility/deep-equal';
-
-function copy<T>(v: T): T {
-	return JSON.parse(JSON.stringify(v));
-}
-
-function unwrapReactive<T>(v: Reactive<T>): T {
-	return JSON.parse(JSON.stringify(v));
-}
-
-export function useForm<T extends Record<string, any>>(initialState: T, save: (newState: T) => Promise<void>) {
-	const currentState = reactive<T>(copy(initialState));
-	const previousState = reactive<T>(copy(initialState));
-
-	const modifiedStates = reactive<Record<keyof T, boolean>>({} as any);
-	for (const key in currentState) {
-		modifiedStates[key] = false;
-	}
-	const modified = computed(() => Object.values(modifiedStates).some(v => v));
-	const modifiedCount = computed(() => Object.values(modifiedStates).filter(v => v).length);
-
-	watch([currentState, previousState], () => {
-		for (const key in modifiedStates) {
-			modifiedStates[key] = !deepEqual(currentState[key], previousState[key]);
-		}
-	}, { deep: true });
-
-	async function _save() {
-		await save(unwrapReactive(currentState));
-		for (const key in currentState) {
-			previousState[key] = copy(currentState[key]);
-		}
-	}
-
-	function discard() {
-		for (const key in currentState) {
-			currentState[key] = copy(previousState[key]);
-		}
-	}
-
-	return {
-		state: currentState,
-		savedState: previousState,
-		modifiedStates,
-		modified,
-		modifiedCount,
-		save: _save,
-		discard,
-	};
-}
diff --git a/packages/frontend/src/use/use-leave-guard.ts b/packages/frontend/src/use/use-leave-guard.ts
deleted file mode 100644
index 395c12a756..0000000000
--- a/packages/frontend/src/use/use-leave-guard.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import type { Ref } from 'vue';
-
-export function useLeaveGuard(enabled: Ref<boolean>) {
-	/* TODO
-	const setLeaveGuard = inject('setLeaveGuard');
-
-	if (setLeaveGuard) {
-		setLeaveGuard(async () => {
-			if (!enabled.value) return false;
-
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: i18n.ts.leaveConfirm,
-			});
-
-			return canceled;
-		});
-	} else {
-		onBeforeRouteLeave(async (to, from) => {
-			if (!enabled.value) return true;
-
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: i18n.ts.leaveConfirm,
-			});
-
-			return !canceled;
-		});
-	}
-	*/
-
-	/*
-	function onBeforeLeave(ev: BeforeUnloadEvent) {
-		if (enabled.value) {
-			ev.preventDefault();
-			ev.returnValue = '';
-		}
-	}
-
-	window.addEventListener('beforeunload', onBeforeLeave);
-	onUnmounted(() => {
-		window.removeEventListener('beforeunload', onBeforeLeave);
-	});
-	*/
-}
diff --git a/packages/frontend/src/use/use-mutation-observer.ts b/packages/frontend/src/use/use-mutation-observer.ts
deleted file mode 100644
index 7b774022dc..0000000000
--- a/packages/frontend/src/use/use-mutation-observer.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { onUnmounted, watch } from 'vue';
-import type { Ref } from 'vue';
-
-export function useMutationObserver(targetNodeRef: Ref<HTMLElement | null | undefined>, options: MutationObserverInit, callback: MutationCallback): void {
-	const observer = new MutationObserver(callback);
-
-	watch(targetNodeRef, (targetNode) => {
-		if (targetNode) {
-			observer.observe(targetNode, options);
-		}
-	}, { immediate: true });
-
-	onUnmounted(() => {
-		observer.disconnect();
-	});
-}
diff --git a/packages/frontend/src/use/use-note-capture.ts b/packages/frontend/src/use/use-note-capture.ts
deleted file mode 100644
index 2f33c25a0a..0000000000
--- a/packages/frontend/src/use/use-note-capture.ts
+++ /dev/null
@@ -1,283 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { onUnmounted } from 'vue';
-import * as Misskey from 'misskey-js';
-import { EventEmitter } from 'eventemitter3';
-import type { Reactive, Ref } from 'vue';
-import { useStream } from '@/stream.js';
-import { $i } from '@/i.js';
-import { store } from '@/store.js';
-import { misskeyApi } from '@/utility/misskey-api.js';
-import { prefer } from '@/preferences.js';
-import { globalEvents } from '@/events.js';
-
-export const noteEvents = new EventEmitter<{
-	[ev: `reacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void;
-	[ev: `unreacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void;
-	[ev: `pollVoted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; choice: string; }) => void;
-}>();
-
-const fetchEvent = new EventEmitter<{
-	[id: string]: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>;
-}>();
-
-const pollingQueue = new Map<string, {
-	referenceCount: number;
-	lastAddedAt: number;
-}>();
-
-function pollingEnqueue(note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>) {
-	if (pollingQueue.has(note.id)) {
-		const data = pollingQueue.get(note.id)!;
-		pollingQueue.set(note.id, {
-			...data,
-			referenceCount: data.referenceCount + 1,
-			lastAddedAt: Date.now(),
-		});
-	} else {
-		pollingQueue.set(note.id, {
-			referenceCount: 1,
-			lastAddedAt: Date.now(),
-		});
-	}
-}
-
-function pollingDequeue(note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>) {
-	const data = pollingQueue.get(note.id);
-	if (data == null) return;
-
-	if (data.referenceCount === 1) {
-		pollingQueue.delete(note.id);
-	} else {
-		pollingQueue.set(note.id, {
-			...data,
-			referenceCount: data.referenceCount - 1,
-		});
-	}
-}
-
-const CAPTURE_MAX = 30;
-const MIN_POLLING_INTERVAL = 1000 * 10;
-const POLLING_INTERVAL =
-	prefer.s.pollingInterval === 1 ? MIN_POLLING_INTERVAL * 1.5 * 1.5 :
-	prefer.s.pollingInterval === 2 ? MIN_POLLING_INTERVAL * 1.5 :
-	prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL :
-	MIN_POLLING_INTERVAL;
-
-window.setInterval(() => {
-	const ids = [...pollingQueue.entries()]
-		.filter(([k, v]) => Date.now() - v.lastAddedAt < 1000 * 60 * 5) // 追加されてから一定時間経過したものは省く
-		.map(([k, v]) => k)
-		.sort((a, b) => (a > b ? -1 : 1)) // 新しいものを優先するためにIDで降順ソート
-		.slice(0, CAPTURE_MAX);
-
-	if (ids.length === 0) return;
-	if (window.document.hidden) return;
-
-	// まとめてリクエストするのではなく、個別にHTTPリクエスト投げてCDNにキャッシュさせた方がサーバーの負荷低減には良いかもしれない？
-	misskeyApi('notes/show-partial-bulk', {
-		noteIds: ids,
-	}).then((items) => {
-		for (const item of items) {
-			fetchEvent.emit(item.id, {
-				reactions: item.reactions,
-				reactionEmojis: item.reactionEmojis,
-			});
-		}
-	});
-}, POLLING_INTERVAL);
-
-function pollingSubscribe(props: {
-	note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>;
-	$note: ReactiveNoteData;
-}) {
-	const { note, $note } = props;
-
-	function onFetched(data: Pick<Misskey.entities.Note, 'reactions' | 'reactionEmojis'>): void {
-		$note.reactions = data.reactions;
-		$note.reactionCount = Object.values(data.reactions).reduce((a, b) => a + b, 0);
-		$note.reactionEmojis = data.reactionEmojis;
-	}
-
-	pollingEnqueue(note);
-	fetchEvent.on(note.id, onFetched);
-
-	onUnmounted(() => {
-		pollingDequeue(note);
-		fetchEvent.off(note.id, onFetched);
-	});
-}
-
-function realtimeSubscribe(props: {
-	note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>;
-}): void {
-	const note = props.note;
-	const connection = useStream();
-
-	function onStreamNoteUpdated(noteData): void {
-		const { type, id, body } = noteData;
-
-		if (id !== note.id) return;
-
-		switch (type) {
-			case 'reacted': {
-				noteEvents.emit(`reacted:${id}`, {
-					userId: body.userId,
-					reaction: body.reaction,
-					emoji: body.emoji,
-				});
-				break;
-			}
-
-			case 'unreacted': {
-				noteEvents.emit(`unreacted:${id}`, {
-					userId: body.userId,
-					reaction: body.reaction,
-					emoji: body.emoji,
-				});
-				break;
-			}
-
-			case 'pollVoted': {
-				noteEvents.emit(`pollVoted:${id}`, {
-					userId: body.userId,
-					choice: body.choice,
-				});
-				break;
-			}
-
-			case 'deleted': {
-				globalEvents.emit('noteDeleted', id);
-				break;
-			}
-		}
-	}
-
-	function capture(withHandler = false): void {
-		connection.send('sr', { id: note.id });
-		if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
-	}
-
-	function decapture(withHandler = false): void {
-		connection.send('un', { id: note.id });
-		if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
-	}
-
-	function onStreamConnected() {
-		capture(false);
-	}
-
-	capture(true);
-	connection.on('_connected_', onStreamConnected);
-
-	onUnmounted(() => {
-		decapture(true);
-		connection.off('_connected_', onStreamConnected);
-	});
-}
-
-type ReactiveNoteData = Reactive<{
-	reactions: Misskey.entities.Note['reactions'];
-	reactionCount: Misskey.entities.Note['reactionCount'];
-	reactionEmojis: Misskey.entities.Note['reactionEmojis'];
-	myReaction: Misskey.entities.Note['myReaction'];
-	pollChoices: NonNullable<Misskey.entities.Note['poll']>['choices'];
-}>;
-
-export function useNoteCapture(props: {
-	note: Pick<Misskey.entities.Note, 'id' | 'createdAt'>;
-	parentNote: Misskey.entities.Note | null;
-	$note: ReactiveNoteData;
-}) {
-	const { note, parentNote, $note } = props;
-
-	noteEvents.on(`reacted:${note.id}`, onReacted);
-	noteEvents.on(`unreacted:${note.id}`, onUnreacted);
-	noteEvents.on(`pollVoted:${note.id}`, onPollVoted);
-
-	let latestReactedKey: string | null = null;
-	let latestUnreactedKey: string | null = null;
-	let latestPollVotedKey: string | null = null;
-
-	function onReacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void {
-		const newReactedKey = `${ctx.userId}:${ctx.reaction}`;
-		if (newReactedKey === latestReactedKey) return;
-		latestReactedKey = newReactedKey;
-
-		if (ctx.emoji && !(ctx.emoji.name in $note.reactionEmojis)) {
-			$note.reactionEmojis[ctx.emoji.name] = ctx.emoji.url;
-		}
-
-		const currentCount = $note.reactions[ctx.reaction] || 0;
-
-		$note.reactions[ctx.reaction] = currentCount + 1;
-		$note.reactionCount += 1;
-
-		if ($i && (ctx.userId === $i.id)) {
-			$note.myReaction = ctx.reaction;
-		}
-	}
-
-	function onUnreacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void {
-		const newUnreactedKey = `${ctx.userId}:${ctx.reaction}`;
-		if (newUnreactedKey === latestUnreactedKey) return;
-		latestUnreactedKey = newUnreactedKey;
-
-		const currentCount = $note.reactions[ctx.reaction] || 0;
-
-		$note.reactions[ctx.reaction] = Math.max(0, currentCount - 1);
-		$note.reactionCount = Math.max(0, $note.reactionCount - 1);
-		if ($note.reactions[ctx.reaction] === 0) delete $note.reactions[ctx.reaction];
-
-		if ($i && (ctx.userId === $i.id)) {
-			$note.myReaction = null;
-		}
-	}
-
-	function onPollVoted(ctx: { userId: Misskey.entities.User['id']; choice: string; }): void {
-		const newPollVotedKey = `${ctx.userId}:${ctx.choice}`;
-		if (newPollVotedKey === latestPollVotedKey) return;
-		latestPollVotedKey = newPollVotedKey;
-
-		const choices = [...$note.pollChoices];
-		choices[ctx.choice] = {
-			...choices[ctx.choice],
-			votes: choices[ctx.choice].votes + 1,
-			...($i && (ctx.userId === $i.id) ? {
-				isVoted: true,
-			} : {}),
-		};
-
-		$note.pollChoices = choices;
-	}
-
-	onUnmounted(() => {
-		noteEvents.off(`reacted:${note.id}`, onReacted);
-		noteEvents.off(`unreacted:${note.id}`, onUnreacted);
-		noteEvents.off(`pollVoted:${note.id}`, onPollVoted);
-	});
-
-	// 投稿からある程度経過している(=タイムラインを遡って表示した)ノートは、イベントが発生する可能性が低いためそもそも購読しない
-	// ただし「リノートされたばかりの過去のノート」(= parentNoteが存在し、かつparentNoteの投稿日時が最近)はイベント発生が考えられるため購読する
-	// TODO: デバイスとサーバーの時計がズレていると不具合の元になるため、ズレを検知して警告を表示するなどのケアが必要かもしれない
-	if (parentNote == null) {
-		if ((Date.now() - new Date(note.createdAt).getTime()) > 1000 * 60 * 5) { // 5min
-			// リノートで表示されているノートでもないし、投稿からある程度経過しているので購読しない
-			return;
-		}
-	} else {
-		if ((Date.now() - new Date(parentNote.createdAt).getTime()) > 1000 * 60 * 5) { // 5min
-			// リノートで表示されているノートだが、リノートされてからある程度経過しているので購読しない
-			return;
-		}
-	}
-
-	if ($i && store.s.realtimeMode) {
-		realtimeSubscribe(props);
-	} else {
-		pollingSubscribe(props);
-	}
-}
diff --git a/packages/frontend/src/use/use-pagination.ts b/packages/frontend/src/use/use-pagination.ts
deleted file mode 100644
index f1042985bf..0000000000
--- a/packages/frontend/src/use/use-pagination.ts
+++ /dev/null
@@ -1,258 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { computed, isRef, onMounted, ref, shallowRef, triggerRef, watch } from 'vue';
-import * as Misskey from 'misskey-js';
-import type { ComputedRef, DeepReadonly, Ref, ShallowRef } from 'vue';
-import { misskeyApi } from '@/utility/misskey-api.js';
-
-const MAX_ITEMS = 30;
-const MAX_QUEUE_ITEMS = 100;
-const FIRST_FETCH_LIMIT = 15;
-const SECOND_FETCH_LIMIT = 30;
-
-export type MisskeyEntity = {
-	id: string;
-	createdAt: string;
-	_shouldInsertAd_?: boolean;
-	[x: string]: any;
-};
-
-export type PagingCtx<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;
-
-	offsetMode?: boolean;
-
-	baseId?: MisskeyEntity['id'];
-	direction?: 'newer' | 'older';
-};
-
-export function usePagination<T extends MisskeyEntity>(props: {
-	ctx: PagingCtx;
-	useShallowRef?: boolean;
-}) {
-	const items = props.useShallowRef ? shallowRef<T[]>([]) : ref<T[]>([]);
-	let aheadQueue: T[] = [];
-	const queuedAheadItemsCount = ref(0);
-	const fetching = ref(true);
-	const fetchingOlder = ref(false);
-	const canFetchOlder = ref(false);
-	const error = ref(false);
-
-	// パラメータに何らかの変更があった際、再読込したい（チャンネル等のIDが変わったなど）
-	watch(() => [props.ctx.endpoint, props.ctx.params], init, { deep: true });
-
-	function getNewestId(): string | null | undefined {
-		// 様々な要因により並び順は保証されないのでソートが必要
-		if (aheadQueue.length > 0) {
-			return aheadQueue.map(x => x.id).sort().at(-1);
-		}
-		return items.value.map(x => x.id).sort().at(-1);
-	}
-
-	function getOldestId(): string | null | undefined {
-		// 様々な要因により並び順は保証されないのでソートが必要
-		return items.value.map(x => x.id).sort().at(0);
-	}
-
-	async function init(): Promise<void> {
-		items.value = [];
-		aheadQueue = [];
-		queuedAheadItemsCount.value = 0;
-		fetching.value = true;
-		const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {};
-
-		await misskeyApi<T[]>(props.ctx.endpoint, {
-			...params,
-			limit: props.ctx.limit ?? FIRST_FETCH_LIMIT,
-			allowPartial: true,
-			...(props.ctx.baseId && props.ctx.direction === 'newer' ? {
-				sinceId: props.ctx.baseId,
-			} : props.ctx.baseId && props.ctx.direction === 'older' ? {
-				untilId: props.ctx.baseId,
-			} : {}),
-		}).then(res => {
-			// 逆順で返ってくるので
-			if (props.ctx.baseId && props.ctx.direction === 'newer') {
-				res.reverse();
-			}
-
-			for (let i = 0; i < res.length; i++) {
-				const item = res[i];
-				if (i === 3) item._shouldInsertAd_ = true;
-			}
-
-			if (res.length === 0 || props.ctx.noPaging) {
-				pushItems(res);
-				canFetchOlder.value = false;
-			} else {
-				pushItems(res);
-				canFetchOlder.value = true;
-			}
-
-			error.value = false;
-			fetching.value = false;
-		}, err => {
-			error.value = true;
-			fetching.value = false;
-		});
-	}
-
-	function reload(): Promise<void> {
-		return init();
-	}
-
-	async function fetchOlder(): Promise<void> {
-		if (!canFetchOlder.value || fetching.value || fetchingOlder.value || items.value.length === 0) return;
-		fetchingOlder.value = true;
-		const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {};
-		await misskeyApi<T[]>(props.ctx.endpoint, {
-			...params,
-			limit: SECOND_FETCH_LIMIT,
-			...(props.ctx.offsetMode ? {
-				offset: items.value.length,
-			} : {
-				untilId: getOldestId(),
-			}),
-		}).then(res => {
-			for (let i = 0; i < res.length; i++) {
-				const item = res[i];
-				if (i === 10) item._shouldInsertAd_ = true;
-			}
-
-			if (res.length === 0) {
-				canFetchOlder.value = false;
-				fetchingOlder.value = false;
-			} else {
-				pushItems(res);
-				canFetchOlder.value = true;
-				fetchingOlder.value = false;
-			}
-		}, err => {
-			fetchingOlder.value = false;
-		});
-	}
-
-	async function fetchNewer(options: {
-		toQueue?: boolean;
-	} = {}): Promise<void> {
-		const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {};
-		await misskeyApi<T[]>(props.ctx.endpoint, {
-			...params,
-			limit: SECOND_FETCH_LIMIT,
-			...(props.ctx.offsetMode ? {
-				offset: items.value.length,
-			} : {
-				sinceId: getNewestId(),
-			}),
-		}).then(res => {
-			if (res.length === 0) return; // これやらないと余計なre-renderが走る
-
-			if (options.toQueue) {
-				aheadQueue.unshift(...res.toReversed());
-				if (aheadQueue.length > MAX_QUEUE_ITEMS) {
-					aheadQueue = aheadQueue.slice(0, MAX_QUEUE_ITEMS);
-				}
-				queuedAheadItemsCount.value = aheadQueue.length;
-			} else {
-				unshiftItems(res.toReversed());
-			}
-		});
-	}
-
-	function trim(trigger = true) {
-		if (items.value.length >= MAX_ITEMS) canFetchOlder.value = true;
-		items.value = items.value.slice(0, MAX_ITEMS);
-		if (props.useShallowRef && trigger) triggerRef(items);
-	}
-
-	function unshiftItems(newItems: T[]) {
-		if (newItems.length === 0) return; // これやらないと余計なre-renderが走る
-		items.value.unshift(...newItems.filter(x => !items.value.some(y => y.id === x.id))); // ストリーミングやポーリングのタイミングによっては重複することがあるため
-		trim(false);
-		if (props.useShallowRef) triggerRef(items);
-	}
-
-	function pushItems(oldItems: T[]) {
-		if (oldItems.length === 0) return; // これやらないと余計なre-renderが走る
-		items.value.push(...oldItems);
-		if (props.useShallowRef) triggerRef(items);
-	}
-
-	function prepend(item: T) {
-		if (items.value.some(x => x.id === item.id)) return;
-		items.value.unshift(item);
-		trim(false);
-		if (props.useShallowRef) triggerRef(items);
-	}
-
-	function enqueue(item: T) {
-		aheadQueue.unshift(item);
-		if (aheadQueue.length > MAX_QUEUE_ITEMS) {
-			aheadQueue.pop();
-		}
-		queuedAheadItemsCount.value = aheadQueue.length;
-	}
-
-	function releaseQueue() {
-		if (aheadQueue.length === 0) return; // これやらないと余計なre-renderが走る
-		unshiftItems(aheadQueue);
-		aheadQueue = [];
-		queuedAheadItemsCount.value = 0;
-	}
-
-	function removeItem(id: string) {
-		// TODO: queueからも消す
-
-		const index = items.value.findIndex(x => x.id === id);
-		if (index !== -1) {
-			items.value.splice(index, 1);
-			if (props.useShallowRef) triggerRef(items);
-		}
-	}
-
-	function updateItem(id: string, updator: (item: T) => T) {
-		// TODO: queueのも更新
-
-		const index = items.value.findIndex(x => x.id === id);
-		if (index !== -1) {
-			const item = items.value[index]!;
-			items.value[index] = updator(item);
-			if (props.useShallowRef) triggerRef(items);
-		}
-	}
-
-	onMounted(() => {
-		init();
-	});
-
-	return {
-		items: items as DeepReadonly<ShallowRef<T[]>>,
-		queuedAheadItemsCount,
-		fetching,
-		fetchingOlder,
-		canFetchOlder,
-		init,
-		reload,
-		fetchOlder,
-		fetchNewer,
-		unshiftItems,
-		prepend,
-		trim,
-		removeItem,
-		updateItem,
-		enqueue,
-		releaseQueue,
-		error,
-	};
-}
diff --git a/packages/frontend/src/use/use-scroll-position-keeper.ts b/packages/frontend/src/use/use-scroll-position-keeper.ts
deleted file mode 100644
index b584171cbe..0000000000
--- a/packages/frontend/src/use/use-scroll-position-keeper.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { throttle } from 'throttle-debounce';
-import { nextTick, onActivated, onDeactivated, onUnmounted, watch } from 'vue';
-import type { Ref } from 'vue';
-
-// note render skippingがオンだとズレるため、遷移直前にスクロール範囲に表示されているdata-scroll-anchor要素を特定して、復元時に当該要素までスクロールするようにする
-
-// TODO: data-scroll-anchor がひとつも存在しない場合、または手動で useAnchor みたいなフラグをfalseで呼ばれた場合、単純にスクロール位置を使用する処理にフォールバックするようにする
-
-export function useScrollPositionKeeper(scrollContainerRef: Ref<HTMLElement | null | undefined>): void {
-	let anchorId: string | null = null;
-	let ready = true;
-
-	watch(scrollContainerRef, (el) => {
-		if (!el) return;
-
-		const onScroll = () => {
-			if (!el) return;
-			if (!ready) return;
-
-			const scrollContainerRect = el.getBoundingClientRect();
-			const viewPosition = scrollContainerRect.height / 2;
-
-			const anchorEls = el.querySelectorAll('[data-scroll-anchor]');
-			for (let i = anchorEls.length - 1; i > -1; i--) { // 下から見た方が速い
-				const anchorEl = anchorEls[i] as HTMLElement;
-				const anchorRect = anchorEl.getBoundingClientRect();
-				const anchorTop = anchorRect.top;
-				const anchorBottom = anchorRect.bottom;
-				if (anchorTop <= viewPosition && anchorBottom >= viewPosition) {
-					anchorId = anchorEl.getAttribute('data-scroll-anchor');
-					break;
-				}
-			}
-		};
-
-		// ほんとはscrollイベントじゃなくてonBeforeDeactivatedでやりたい
-		// https://github.com/vuejs/vue/issues/9454
-		// https://github.com/vuejs/rfcs/pull/284
-		el.addEventListener('scroll', throttle(1000, onScroll), { passive: true });
-	}, {
-		immediate: true,
-	});
-
-	const restore = () => {
-		if (!anchorId) return;
-		const scrollContainer = scrollContainerRef.value;
-		if (!scrollContainer) return;
-		const scrollAnchorEl = scrollContainer.querySelector(`[data-scroll-anchor="${anchorId}"]`);
-		if (!scrollAnchorEl) return;
-		scrollAnchorEl.scrollIntoView({
-			behavior: 'instant',
-			block: 'center',
-			inline: 'center',
-		});
-	};
-
-	onDeactivated(() => {
-		ready = false;
-	});
-
-	onActivated(() => {
-		restore();
-		nextTick(() => {
-			restore();
-			window.setTimeout(() => {
-				restore();
-
-				ready = true;
-			}, 100);
-		});
-	});
-}
diff --git a/packages/frontend/src/use/use-tooltip.ts b/packages/frontend/src/use/use-tooltip.ts
deleted file mode 100644
index af76a3a1e8..0000000000
--- a/packages/frontend/src/use/use-tooltip.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { ref, watch, onUnmounted } from 'vue';
-import type { Ref } from 'vue';
-
-export function useTooltip(
-	elRef: Ref<HTMLElement | { $el: HTMLElement } | null | undefined>,
-	onShow: (showing: Ref<boolean>) => void,
-	delay = 300,
-): void {
-	let isHovering = false;
-
-	// iOS(Androidも？)では、要素をタップした直後に(おせっかいで)mouseoverイベントを発火させたりするため、それを無視するためのフラグ
-	// 無視しないと、画面に触れてないのにツールチップが出たりし、ユーザビリティが損なわれる
-	// TODO: 一度でもタップすると二度とマウスでツールチップ出せなくなるのをどうにかする 定期的にfalseに戻すとか...？
-	let shouldIgnoreMouseover = false;
-
-	let timeoutId: number;
-
-	let changeShowingState: (() => void) | null;
-
-	let autoHidingTimer;
-
-	const open = () => {
-		close();
-		if (!isHovering) return;
-		if (elRef.value == null) return;
-		const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
-		if (!window.document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため
-
-		const showing = ref(true);
-		onShow(showing);
-		changeShowingState = () => {
-			showing.value = false;
-		};
-
-		autoHidingTimer = window.setInterval(() => {
-			if (elRef.value == null || !window.document.body.contains(elRef.value instanceof Element ? elRef.value : elRef.value.$el)) {
-				if (!isHovering) return;
-				isHovering = false;
-				window.clearTimeout(timeoutId);
-				close();
-				window.clearInterval(autoHidingTimer);
-			}
-		}, 1000);
-	};
-
-	const close = () => {
-		if (changeShowingState != null) {
-			changeShowingState();
-			changeShowingState = null;
-		}
-	};
-
-	const onMouseover = () => {
-		if (isHovering) return;
-		if (shouldIgnoreMouseover) return;
-		isHovering = true;
-		timeoutId = window.setTimeout(open, delay);
-	};
-
-	const onMouseleave = () => {
-		if (!isHovering) return;
-		isHovering = false;
-		window.clearTimeout(timeoutId);
-		window.clearInterval(autoHidingTimer);
-		close();
-	};
-
-	const onTouchstart = () => {
-		shouldIgnoreMouseover = true;
-		if (isHovering) return;
-		isHovering = true;
-		timeoutId = window.setTimeout(open, delay);
-	};
-
-	const onTouchend = () => {
-		if (!isHovering) return;
-		isHovering = false;
-		window.clearTimeout(timeoutId);
-		window.clearInterval(autoHidingTimer);
-		close();
-	};
-
-	const stop = watch(elRef, () => {
-		if (elRef.value) {
-			stop();
-			const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
-			el.addEventListener('mouseover', onMouseover, { passive: true });
-			el.addEventListener('mouseleave', onMouseleave, { passive: true });
-			el.addEventListener('touchstart', onTouchstart, { passive: true });
-			el.addEventListener('touchend', onTouchend, { passive: true });
-			el.addEventListener('click', close, { passive: true });
-		}
-	}, {
-		immediate: true,
-		flush: 'post',
-	});
-
-	onUnmounted(() => {
-		close();
-	});
-}
-- 
cgit v1.2.3-freya

