diff --git a/biome.json b/biome.json
index 21b711f457..487165266a 100644
--- a/biome.json
+++ b/biome.json
@@ -14,7 +14,7 @@
 	},
 	"overrides": [
 		{
-			"include": ["*.vue"],
+			"include": ["*.vue", "packages/client/*.ts"],
 			"linter": {
 				"rules": {
 					"style": {
diff --git a/docs/api-change.md b/docs/api-change.md
index dcd4329a27..89cf8a9d38 100644
--- a/docs/api-change.md
+++ b/docs/api-change.md
@@ -5,6 +5,13 @@ Breaking changes are indicated by the :warning: icon.
 ## Unreleased
 
 - Added `antennaLimit` field to the response of `meta` and `admin/meta`, and the request of `admin/update-meta` (optional).
+- Added `filter` optional parameter to `notes/renotes` endpoint to filter the types of renotes. It can take the following values:
+	- `all` (default)
+  - `renote`
+  - `quote`
+- :warning: Removed the following optional parameters in `notes/reactions`, as they were never taken into account due to a bug:
+	- `sinceId`
+	- `untilId`
 
 ## v20240413
 
diff --git a/packages/backend-rs/index.d.ts b/packages/backend-rs/index.d.ts
index 69de833dcc..dec480d79c 100644
--- a/packages/backend-rs/index.d.ts
+++ b/packages/backend-rs/index.d.ts
@@ -193,6 +193,7 @@ export interface Acct {
 }
 export function stringToAcct(acct: string): Acct
 export function acctToString(acct: Acct): string
+export function addNoteToAntenna(antennaId: string, note: Note): void
 /**
  * @param host punycoded instance host
  * @returns whether the given host should be blocked
@@ -1119,7 +1120,6 @@ export interface Webhook {
   latestSentAt: Date | null
   latestStatus: number | null
 }
-export function addNoteToAntenna(antennaId: string, note: Note): void
 /** Initializes Cuid2 generator. Must be called before any [create_id]. */
 export function initIdGenerator(length: number, fingerprint: string): void
 export function getTimestamp(id: string): number
diff --git a/packages/backend-rs/index.js b/packages/backend-rs/index.js
index b4d86dccdf..d801e28fce 100644
--- a/packages/backend-rs/index.js
+++ b/packages/backend-rs/index.js
@@ -310,12 +310,13 @@ if (!nativeBinding) {
   throw new Error(`Failed to load native binding`)
 }
 
-const { loadEnv, loadConfig, stringToAcct, acctToString, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getNoteSummary, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, addNoteToAntenna, initIdGenerator, getTimestamp, genId, secureRndstr } = nativeBinding
+const { loadEnv, loadConfig, stringToAcct, acctToString, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getNoteSummary, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initIdGenerator, getTimestamp, genId, secureRndstr } = nativeBinding
 
 module.exports.loadEnv = loadEnv
 module.exports.loadConfig = loadConfig
 module.exports.stringToAcct = stringToAcct
 module.exports.acctToString = acctToString
+module.exports.addNoteToAntenna = addNoteToAntenna
 module.exports.isBlockedServer = isBlockedServer
 module.exports.isSilencedServer = isSilencedServer
 module.exports.isAllowedServer = isAllowedServer
@@ -352,7 +353,6 @@ module.exports.RelayStatusEnum = RelayStatusEnum
 module.exports.UserEmojimodpermEnum = UserEmojimodpermEnum
 module.exports.UserProfileFfvisibilityEnum = UserProfileFfvisibilityEnum
 module.exports.UserProfileMutingnotificationtypesEnum = UserProfileMutingnotificationtypesEnum
-module.exports.addNoteToAntenna = addNoteToAntenna
 module.exports.initIdGenerator = initIdGenerator
 module.exports.getTimestamp = getTimestamp
 module.exports.genId = genId
diff --git a/packages/backend-rs/src/service/add_note_to_antenna.rs b/packages/backend-rs/src/misc/add_note_to_antenna.rs
similarity index 69%
rename from packages/backend-rs/src/service/add_note_to_antenna.rs
rename to packages/backend-rs/src/misc/add_note_to_antenna.rs
index 4f294cc881..2ed698d7e6 100644
--- a/packages/backend-rs/src/service/add_note_to_antenna.rs
+++ b/packages/backend-rs/src/misc/add_note_to_antenna.rs
@@ -1,13 +1,14 @@
 use crate::database::{redis_conn, redis_key};
 use crate::model::entity::note;
-use crate::service::stream::{publish_to_stream, Error, Stream};
+use crate::service::stream;
 use crate::util::id::get_timestamp;
 use redis::{streams::StreamMaxlen, Commands};
 
 type Note = note::Model;
 
 #[crate::export]
-pub fn add_note_to_antenna(antenna_id: String, note: &Note) -> Result<(), Error> {
+pub fn add_note_to_antenna(antenna_id: String, note: &Note) -> Result<(), stream::Error> {
+    // for timeline API
     redis_conn()?.xadd_maxlen(
         redis_key(format!("antennaTimeline:{}", antenna_id)),
         StreamMaxlen::Approx(200),
@@ -15,9 +16,6 @@ pub fn add_note_to_antenna(antenna_id: String, note: &Note) -> Result<(), Error>
         &[("note", &note.id)],
     )?;
 
-    publish_to_stream(
-        &Stream::Antenna { antenna_id },
-        Some("note"),
-        Some(serde_json::to_string(note)?),
-    )
+    // for streaming API
+    stream::antenna::publish(antenna_id, note)
 }
diff --git a/packages/backend-rs/src/misc/mod.rs b/packages/backend-rs/src/misc/mod.rs
index 24cec14969..8246b6a31a 100644
--- a/packages/backend-rs/src/misc/mod.rs
+++ b/packages/backend-rs/src/misc/mod.rs
@@ -1,4 +1,5 @@
 pub mod acct;
+pub mod add_note_to_antenna;
 pub mod check_server_block;
 pub mod check_word_mute;
 pub mod convert_host;
diff --git a/packages/backend-rs/src/service/mod.rs b/packages/backend-rs/src/service/mod.rs
index cc239e3f9e..baf29e06ad 100644
--- a/packages/backend-rs/src/service/mod.rs
+++ b/packages/backend-rs/src/service/mod.rs
@@ -1,2 +1 @@
-pub mod add_note_to_antenna;
 pub mod stream;
diff --git a/packages/backend-rs/src/service/stream.rs b/packages/backend-rs/src/service/stream.rs
index 6c5e6be4dd..1e84d5f866 100644
--- a/packages/backend-rs/src/service/stream.rs
+++ b/packages/backend-rs/src/service/stream.rs
@@ -1,3 +1,5 @@
+pub mod antenna;
+
 use crate::config::CONFIG;
 use crate::database::redis_conn;
 use redis::{Commands, RedisError};
diff --git a/packages/backend-rs/src/service/stream/antenna.rs b/packages/backend-rs/src/service/stream/antenna.rs
new file mode 100644
index 0000000000..08ae391caf
--- /dev/null
+++ b/packages/backend-rs/src/service/stream/antenna.rs
@@ -0,0 +1,10 @@
+use crate::model::entity::note;
+use crate::service::stream::{publish_to_stream, Error, Stream};
+
+pub fn publish(antenna_id: String, note: &note::Model) -> Result<(), Error> {
+    publish_to_stream(
+        &Stream::Antenna { antenna_id },
+        Some("note"),
+        Some(serde_json::to_string(note)?),
+    )
+}
diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts
index c877048709..7fa26373b8 100644
--- a/packages/backend/src/models/repositories/note.ts
+++ b/packages/backend/src/models/repositories/note.ts
@@ -1,4 +1,4 @@
-import { In } from "typeorm";
+import { In, IsNull, Not } from "typeorm";
 import * as mfm from "mfm-js";
 import { Note } from "@/models/entities/note.js";
 import type { User } from "@/models/entities/user.js";
@@ -10,6 +10,7 @@ import {
 	Followings,
 	Polls,
 	Channels,
+	Notes,
 } from "../index.js";
 import type { Packed } from "@/misc/schema.js";
 import { countReactions, decodeReaction, nyaify } from "backend-rs";
@@ -101,7 +102,7 @@ export const NoteRepository = db.getRepository(Note).extend({
 				return true;
 			} else {
 				// 指定されているかどうか
-				return note.visibleUserIds.some((id: any) => meId === id);
+				return note.visibleUserIds.some((id) => meId === id);
 			}
 		}
 
@@ -211,8 +212,25 @@ export const NoteRepository = db.getRepository(Note).extend({
 			localOnly: note.localOnly || undefined,
 			visibleUserIds:
 				note.visibility === "specified" ? note.visibleUserIds : undefined,
+			// FIXME: Deleting a post does not decrease these two numbers, causing the number to be wrong
 			renoteCount: note.renoteCount,
 			repliesCount: note.repliesCount,
+			// TODO: add it to database and use note.quoteCount
+			quoteCount: Notes.count({
+				where: {
+					renoteId: note.id,
+					text: Not(IsNull()),
+				},
+			}),
+			myRenoteCount: me
+				? Notes.count({
+						where: {
+							renoteId: note.id,
+							text: IsNull(),
+							userId: me.id,
+						},
+					})
+				: undefined,
 			reactions: countReactions(note.reactions),
 			reactionEmojis: reactionEmoji,
 			emojis: noteEmoji,
diff --git a/packages/backend/src/models/schema/note.ts b/packages/backend/src/models/schema/note.ts
index fff872b69f..6064919960 100644
--- a/packages/backend/src/models/schema/note.ts
+++ b/packages/backend/src/models/schema/note.ts
@@ -208,5 +208,15 @@ export const packedNoteSchema = {
 			optional: true,
 			nullable: true,
 		},
+		myRenoteCount: {
+			type: "number",
+			optional: true,
+			nullable: false,
+		},
+		quoteCount: {
+			type: "number",
+			optional: false,
+			nullable: false,
+		},
 	},
 } as const;
diff --git a/packages/backend/src/server/api/common/make-pagination-query.ts b/packages/backend/src/server/api/common/make-pagination-query.ts
index a2c3275693..83827b3df1 100644
--- a/packages/backend/src/server/api/common/make-pagination-query.ts
+++ b/packages/backend/src/server/api/common/make-pagination-query.ts
@@ -1,6 +1,6 @@
-import type { SelectQueryBuilder } from "typeorm";
+import type { ObjectLiteral, SelectQueryBuilder } from "typeorm";
 
-export function makePaginationQuery<T>(
+export function makePaginationQuery<T extends ObjectLiteral>(
 	q: SelectQueryBuilder<T>,
 	sinceId?: string,
 	untilId?: string,
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index 4f65c59a9e..9a2b49cb3d 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -13,7 +13,7 @@ import { normalizeForSearch } from "@/misc/normalize-for-search.js";
 import { verifyLink } from "@/services/fetch-rel-me.js";
 import { ApiError } from "@/server/api/error.js";
 import define from "@/server/api/define.js";
-import { DriveFile } from "@/models/entities/drive-file";
+import type { DriveFile } from "@/models/entities/drive-file";
 
 export const meta = {
 	tags: ["account"],
diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts
index 386a3a08df..d6ff8888af 100644
--- a/packages/backend/src/server/api/endpoints/notes/reactions.ts
+++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts
@@ -42,8 +42,6 @@ export const paramDef = {
 		type: { type: "string", nullable: true },
 		limit: { type: "integer", minimum: 1, maximum: 100, default: 10 },
 		offset: { type: "integer", default: 0 },
-		sinceId: { type: "string", format: "misskey:id" },
-		untilId: { type: "string", format: "misskey:id" },
 	},
 	required: ["noteId"],
 } as const;
diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts
index 683004ebe8..16304dd269 100644
--- a/packages/backend/src/server/api/endpoints/notes/renotes.ts
+++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts
@@ -42,6 +42,12 @@ export const paramDef = {
 		limit: { type: "integer", minimum: 1, maximum: 100, default: 10 },
 		sinceId: { type: "string", format: "misskey:id" },
 		untilId: { type: "string", format: "misskey:id" },
+		filter: {
+			type: "string",
+			enum: ["all", "renote", "quote"],
+			nullable: true,
+			default: null,
+		},
 	},
 	required: ["noteId"],
 } as const;
@@ -53,7 +59,7 @@ export default define(meta, paramDef, async (ps, user) => {
 		throw err;
 	});
 
-	let query = makePaginationQuery(
+	const query = makePaginationQuery(
 		Notes.createQueryBuilder("note"),
 		ps.sinceId,
 		ps.untilId,
@@ -61,6 +67,16 @@ export default define(meta, paramDef, async (ps, user) => {
 		.andWhere("note.renoteId = :renoteId", { renoteId: note.id })
 		.innerJoinAndSelect("note.user", "user");
 
+	// "all" doesn't filter out anything, it's just there for
+	// those who prefer to set the parameter explicitly
+
+	if (ps.filter === "renote") {
+		query.andWhere("note.text IS NULL");
+	}
+	if (ps.filter === "quote") {
+		query.andWhere("note.text IS NOT NULL");
+	}
+
 	if (ps.userId) {
 		query.andWhere("user.id = :userId", { userId: ps.userId });
 	}
diff --git a/packages/client/src/components/MkNoteDetailed.vue b/packages/client/src/components/MkNoteDetailed.vue
index b6c0d784ba..2c242645b4 100644
--- a/packages/client/src/components/MkNoteDetailed.vue
+++ b/packages/client/src/components/MkNoteDetailed.vue
@@ -33,7 +33,7 @@
 			@contextmenu.stop="onContextmenu"
 		></MkNote>
 
-		<MkTab v-model="tab" :style="'underline'" @update:modelValue="loadTab">
+		<MkTab v-model="tab" :style="'underline'">
 			<option value="replies">
 				<!-- <i :class="icon('ph-arrow-u-up-left')"></i> -->
 				{{
@@ -64,11 +64,11 @@
 					)
 				}}
 			</option>
-			<option v-if="directQuotes && directQuotes.length > 0" value="quotes">
+			<option v-if="note.quoteCount > 0" value="quotes">
 				<!-- <i :class="icon('ph-quotes')"></i> -->
 				{{
 					wordWithCount(
-						directQuotes.length,
+						note.quoteCount,
 						i18n.ts.quote,
 						i18n.ts.quotes,
 					)
@@ -80,44 +80,52 @@
 			</option>
 		</MkTab>
 
-		<MkNoteSub
-			v-for="note in directReplies"
-			v-if="directReplies && tab === 'replies'"
-			:key="note.id"
-			:note="note"
-			class="reply"
-			:conversation="replies"
-			:detailed-view="true"
-			:parent-id="note.id"
-		/>
-		<MkLoading v-else-if="tab === 'replies' && note.repliesCount > 0" />
+		<MkPagination
+			ref="repliesPagingComponent"
+			v-if="tab === 'replies' && note.repliesCount > 0"
+			v-slot="{ items }"
+			:pagination="repliesPagination"
+		>
+			<MkNoteSub
+				v-for="note in items"
+				:key="note.id"
+				:note="note"
+				class="reply"
+				:auto-conversation="true"
+				:detailed-view="true"
+				:parent-id="note.id"
+				:auto-add-replies="true"
+			/>
+		</MkPagination>
 
-		<MkNoteSub
-			v-for="note in directQuotes"
-			v-if="directQuotes && tab === 'quotes'"
-			:key="note.id"
-			:note="note"
-			class="reply"
-			:conversation="replies"
-			:detailed-view="true"
-			:parent-id="note.id"
-		/>
-		<MkLoading v-else-if="tab === 'quotes' && directQuotes && directQuotes.length > 0" />
+		<MkPagination
+			v-if="tab === 'quotes'"
+			v-slot="{ items }"
+			:pagination="quotePagination"
+		>
+			<MkNoteSub
+				v-for="note in items"
+				:key="note.id"
+				:note="note"
+				class="reply"
+				:auto-conversation="true"
+				:detailed-view="true"
+				:parent-id="note.id"
+				:auto-add-replies="true"
+			/>
+		</MkPagination>
 
-		<!-- <MkPagination
+		<MkPagination
 			v-if="tab === 'renotes'"
 			v-slot="{ items }"
-			ref="pagingComponent"
-			:pagination="pagination"
-		> -->
-		<MkUserCardMini
-			v-for="item in renotes"
-			v-if="tab === 'renotes' && renotes"
-			:key="item.user.id"
-			:user="item.user"
-		/>
-		<!-- </MkPagination> -->
-		<MkLoading v-else-if="tab === 'renotes' && note.renoteCount > 0" />
+			:pagination="renotePagination"
+		>
+			<MkUserCardMini
+				v-for="item in items"
+				:key="item.user.id"
+				:user="item.user"
+			/>
+		</MkPagination>
 
 		<div v-if="tab === 'clips' && clips.length > 0" class="_content clips">
 			<MkA
@@ -166,8 +174,8 @@
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, onUpdated, ref } from "vue";
-import type { StreamTypes, entities } from "firefish-js";
+import { onMounted, onUpdated, ref } from "vue";
+import type { entities } from "firefish-js";
 import MkTab from "@/components/MkTab.vue";
 import MkNote from "@/components/MkNote.vue";
 import MkNoteSub from "@/components/MkNoteSub.vue";
@@ -185,7 +193,9 @@ import { i18n } from "@/i18n";
 import { getNoteMenu } from "@/scripts/get-note-menu";
 import { useNoteCapture } from "@/scripts/use-note-capture";
 import { deepClone } from "@/scripts/clone";
-import { useStream } from "@/stream";
+import MkPagination, {
+	type MkPaginationType,
+} from "@/components/MkPagination.vue";
 // import icon from "@/scripts/icon";
 
 const props = defineProps<{
@@ -193,8 +203,6 @@ const props = defineProps<{
 	pinned?: boolean;
 }>();
 
-const stream = useStream();
-
 const tab = ref("replies");
 
 const note = ref(deepClone(props.note));
@@ -225,6 +233,10 @@ if (noteViewInterruptors.length > 0) {
 	});
 }
 
+const repliesPagingComponent = ref<MkPaginationType<"notes/replies"> | null>(
+	null,
+);
+
 const el = ref<HTMLElement | null>(null);
 const noteEl = ref();
 const menuButton = ref<HTMLElement>();
@@ -243,11 +255,7 @@ const muted = ref(
 const translation = ref(null);
 const translating = ref(false);
 const conversation = ref<null | entities.Note[]>([]);
-const replies = ref<entities.Note[]>([]);
-const directReplies = ref<null | entities.Note[]>([]);
-const directQuotes = ref<null | entities.Note[]>([]);
 const clips = ref();
-const renotes = ref();
 const isRenote = ref(note.value.renoteId != null);
 let isScrolling: boolean;
 
@@ -269,6 +277,10 @@ useNoteCapture({
 	rootEl: el,
 	note,
 	isDeletedRef: isDeleted,
+	onReplied: (replyNote) => {
+		note.value.repliesCount += 1;
+		repliesPagingComponent.value?.append(replyNote);
+	},
 });
 
 function reply(_viaKeyboard = false): void {
@@ -357,32 +369,6 @@ function blur() {
 	noteEl.value.blur();
 }
 
-directReplies.value = null;
-os.api("notes/children", {
-	noteId: note.value.id,
-	limit: 30,
-	depth: 12,
-}).then((res) => {
-	// biome-ignore lint/style/noParameterAssign: assign it intentially
-	res = res
-		.filter((n) => n.userId !== note.value.userId)
-		.reverse()
-		.concat(res.filter((n) => n.userId === note.value.userId));
-	// res = res.reduce((acc: entities.Note[], resNote) => {
-	// 	if (resNote.userId === note.value.userId) {
-	// 		return [...acc, resNote];
-	// 	}
-	// 	return [resNote, ...acc];
-	// }, []);
-	replies.value = res;
-	directReplies.value = res
-		.filter((resNote) => resNote.replyId === note.value.id)
-		.reverse();
-	directQuotes.value = res.filter(
-		(resNote) => resNote.renoteId === note.value.id,
-	);
-});
-
 conversation.value = null;
 if (note.value.replyId) {
 	os.api("notes/conversation", {
@@ -401,77 +387,37 @@ os.api("notes/clips", {
 	clips.value = res;
 });
 
-// const pagination = {
-// 	endpoint: "notes/renotes",
-// 	noteId: note.id,
-// 	limit: 10,
-// };
+const repliesPagination = {
+	endpoint: "notes/replies" as const,
+	limit: 10,
+	params: {
+		noteId: note.value.id,
+	},
+	ascending: true,
+};
 
-// const pagingComponent = $ref<InstanceType<typeof MkPagination>>();
-
-renotes.value = null;
-function loadTab() {
-	if (tab.value === "renotes" && !renotes.value) {
-		os.api("notes/renotes", {
-			noteId: note.value.id,
-			limit: 100,
-		}).then((res) => {
-			renotes.value = res;
-		});
-	}
-}
-
-async function onNoteUpdated(
-	noteData: StreamTypes.NoteUpdatedEvent,
-): Promise<void> {
-	const { type, id, body } = noteData;
-
-	let found = -1;
-	if (id === note.value.id) {
-		found = 0;
-	} else {
-		for (let i = 0; i < replies.value.length; i++) {
-			const reply = replies.value[i];
-			if (reply.id === id) {
-				found = i + 1;
-				break;
-			}
-		}
-	}
-
-	if (found === -1) {
-		return;
-	}
-
-	switch (type) {
-		case "replied": {
-			const { id: createdId } = body;
-			const replyNote = await os.api("notes/show", {
-				noteId: createdId,
-			});
-
-			replies.value.splice(found, 0, replyNote);
-			if (found === 0) {
-				directReplies.value!.push(replyNote);
-			}
-			break;
-		}
-		case "deleted":
-			if (found === 0) {
-				isDeleted.value = true;
-			} else {
-				replies.value.splice(found - 1, 1);
-			}
-			break;
-	}
-}
+const renotePagination = {
+	endpoint: "notes/renotes" as const,
+	limit: 30,
+	params: {
+		noteId: note.value.id,
+		filter: "renote" as const,
+	},
+};
+const quotePagination = {
+	endpoint: "notes/renotes" as const,
+	limit: 30,
+	params: {
+		noteId: note.value.id,
+		filter: "quote" as const,
+	},
+};
 
 document.addEventListener("wheel", () => {
 	isScrolling = true;
 });
 
 onMounted(() => {
-	stream.on("noteUpdated", onNoteUpdated);
 	isScrolling = false;
 	noteEl.value.scrollIntoView();
 });
@@ -484,10 +430,6 @@ onUpdated(() => {
 		}
 	}
 });
-
-onUnmounted(() => {
-	stream.off("noteUpdated", onNoteUpdated);
-});
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/components/MkNoteSub.vue b/packages/client/src/components/MkNoteSub.vue
index aed6de486d..b063a7dd15 100644
--- a/packages/client/src/components/MkNoteSub.vue
+++ b/packages/client/src/components/MkNoteSub.vue
@@ -1,6 +1,7 @@
 <template>
+	<MkLoading v-if="conversationLoading" />
 	<article
-		v-if="!muted.muted || muted.what === 'reply'"
+		v-else-if="!muted.muted || muted.what === 'reply'"
 		:id="detailedView ? appearNote.id : undefined"
 		ref="el"
 		v-size="{ max: [450, 500] }"
@@ -22,7 +23,7 @@
 			<div class="avatar-container">
 				<MkAvatar class="avatar" :user="appearNote.user" />
 				<div
-					v-if="!conversation || replies.length > 0"
+					v-if="conversation == null || replies.length > 0"
 					class="line"
 				></div>
 			</div>
@@ -148,20 +149,32 @@
 				</footer>
 			</div>
 		</div>
-		<template v-if="conversation">
-			<MkNoteSub
-				v-for="reply in replies"
-				v-if="replyLevel < 11 && depth < 5"
-				:key="reply.id"
-				:note="reply"
-				class="reply"
-				:class="{ single: replies.length == 1 }"
-				:conversation="conversation"
-				:depth="replies.length == 1 ? depth : depth + 1"
-				:reply-level="replyLevel + 1"
-				:parent-id="appearNote.id"
-				:detailed-view="detailedView"
-			/>
+		<MkLoading v-if="conversationLoading" />
+		<template v-else-if="conversation">
+			<template
+				v-if="replyLevel < REPLY_LEVEL_UPPERBOUND && depth < DEPTH_UPPERBOUND"
+			>
+				<MkNoteSub
+					v-for="reply in replies.slice(0, REPLIES_LIMIT)"
+					:key="reply.id"
+					:note="reply"
+					class="reply"
+					:class="{ single: replies.length == 1 }"
+					:conversation="conversation"
+					:depth="replies.length == 1 ? depth : depth + 1"
+					:reply-level="replyLevel + 1"
+					:parent-id="appearNote.id"
+					:detailed-view="detailedView"
+					:auto-add-replies="true"
+				/>
+				<div v-if="hasMoreReplies" class="more">
+					<div class="line"></div>
+					<MkA class="text _link" :to="notePage(note)"
+						>{{ i18n.ts.continueThread }}
+						<i :class="icon('ph-caret-double-right')"></i
+					></MkA>
+				</div>
+			</template>
 			<div v-else-if="replies.length > 0" class="more">
 				<div class="line"></div>
 				<MkA class="text _link" :to="notePage(note)"
@@ -190,7 +203,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, inject, ref } from "vue";
+import { computed, inject, ref, watch } from "vue";
 import type { Ref } from "vue";
 import type { entities } from "firefish-js";
 import XNoteHeader from "@/components/MkNoteHeader.vue";
@@ -221,21 +234,27 @@ import type { NoteTranslation } from "@/types/note";
 
 const router = useRouter();
 
+const REPLIES_LIMIT = 10;
+const REPLY_LEVEL_UPPERBOUND = 11;
+const DEPTH_UPPERBOUND = 5;
+
 const props = withDefaults(
 	defineProps<{
 		note: entities.Note;
 		conversation?: entities.Note[];
-		parentId?;
-		detailedView?;
-
+		autoConversation?: boolean;
+		parentId?: string;
+		detailedView?: boolean;
 		// how many notes are in between this one and the note being viewed in detail
 		depth?: number;
 		// the actual reply level of this note within the conversation thread
 		replyLevel?: number;
+		autoAddReplies?: boolean;
 	}>(),
 	{
 		depth: 1,
 		replyLevel: 1,
+		autoAddReplies: false,
 	},
 );
 
@@ -251,6 +270,43 @@ const softMuteReasonI18nSrc = (what?: string) => {
 	return i18n.ts.userSaysSomething;
 };
 
+const conversation = ref(props.conversation);
+const conversationLoading = ref(false);
+const replies = ref<entities.Note[]>([]);
+const hasMoreReplies = ref(false);
+
+function updateReplies() {
+	replies.value = (conversation.value ?? [])
+		.filter(
+			(item) =>
+				item.replyId === props.note.id || item.renoteId === props.note.id,
+		)
+		.reverse();
+	hasMoreReplies.value = replies.value.length >= REPLIES_LIMIT + 1;
+}
+
+watch(conversation, updateReplies, {
+	immediate: true,
+});
+
+if (props.autoConversation) {
+	conversation.value = [];
+	if (note.value.repliesCount > 0 || note.value.renoteCount > 0) {
+		conversationLoading.value = true;
+		os.api("notes/children", {
+			noteId: note.value.id,
+			limit: REPLIES_LIMIT + 1,
+			depth: REPLY_LEVEL_UPPERBOUND + 1,
+		}).then((res) => {
+			conversation.value = res
+				.filter((n) => n.userId !== note.value.userId)
+				.reverse()
+				.concat(res.filter((n) => n.userId === note.value.userId));
+			conversationLoading.value = false;
+		});
+	}
+}
+
 const isRenote =
 	note.value.renote != null &&
 	note.value.text == null &&
@@ -277,13 +333,6 @@ const muted = ref(
 );
 const translation = ref<NoteTranslation | null>(null);
 const translating = ref(false);
-const replies: entities.Note[] =
-	props.conversation
-		?.filter(
-			(item) =>
-				item.replyId === props.note.id || item.renoteId === props.note.id,
-		)
-		.reverse() ?? [];
 const enableEmojiReactions = defaultStore.state.enableEmojiReactions;
 const expandOnNoteClick = defaultStore.state.expandOnNoteClick;
 const lang = localStorage.getItem("lang");
@@ -329,6 +378,14 @@ useNoteCapture({
 	rootEl: el,
 	note: appearNote,
 	isDeletedRef: isDeleted,
+	onReplied: (note) => {
+		if (props.autoAddReplies !== true) {
+			return;
+		}
+		if (hasMoreReplies.value === false) {
+			conversation.value = (conversation.value ?? []).concat([note]);
+		}
+	},
 });
 
 function reply(_viaKeyboard = false): void {
diff --git a/packages/client/src/components/MkPagination.vue b/packages/client/src/components/MkPagination.vue
index 03a1f0e35f..2f092cb8ad 100644
--- a/packages/client/src/components/MkPagination.vue
+++ b/packages/client/src/components/MkPagination.vue
@@ -68,7 +68,15 @@
 
 <script lang="ts" setup generic="E extends PagingKey">
 import type { ComponentPublicInstance, ComputedRef } from "vue";
-import { computed, isRef, onActivated, onDeactivated, ref, watch } from "vue";
+import {
+	computed,
+	isRef,
+	onActivated,
+	onDeactivated,
+	ref,
+	unref,
+	watch,
+} from "vue";
 import type { Endpoints, TypeUtils } from "firefish-js";
 import * as os from "@/os";
 import {
@@ -122,6 +130,12 @@ export interface Paging<E extends PagingKey = PagingKey> {
 	 */
 	reversed?: boolean;
 
+	/**
+	 * For not-reversed, not-offsetMode,
+	 * Sort by id in ascending order
+	 */
+	ascending?: boolean;
+
 	offsetMode?: boolean;
 }
 
@@ -169,17 +183,19 @@ const init = async (): Promise<void> => {
 	queue.value = [];
 	fetching.value = true;
 
-	const params = props.pagination.params
-		? isRef<Param>(props.pagination.params)
-			? props.pagination.params.value
-			: props.pagination.params
-		: {};
+	const params = props.pagination.params ? unref(props.pagination.params) : {};
 	await os
 		.api(props.pagination.endpoint, {
 			...params,
 			limit: props.pagination.noPaging
 				? props.pagination.limit || 10
 				: (props.pagination.limit || 10) + 1,
+			...(props.pagination.ascending
+				? {
+						// An initial value smaller than all possible ids must be filled in here.
+						sinceId: "0",
+					}
+				: {}),
 		})
 		.then(
 			(res: Item[]) => {
@@ -196,10 +212,10 @@ const init = async (): Promise<void> => {
 					res.length > (props.pagination.limit || 10)
 				) {
 					res.pop();
-					items.value = props.pagination.reversed ? [...res].reverse() : res;
+					items.value = props.pagination.reversed ? res.toReversed() : res;
 					more.value = true;
 				} else {
-					items.value = props.pagination.reversed ? [...res].reverse() : res;
+					items.value = props.pagination.reversed ? res.toReversed() : res;
 					more.value = false;
 				}
 				offset.value = res.length;
@@ -219,11 +235,7 @@ const reload = (): Promise<void> => {
 };
 
 const refresh = async (): Promise<void> => {
-	const params = props.pagination.params
-		? isRef<Param>(props.pagination.params)
-			? props.pagination.params.value
-			: props.pagination.params
-		: {};
+	const params = props.pagination.params ? unref(props.pagination.params) : {};
 	await os
 		.api(props.pagination.endpoint, {
 			...params,
@@ -269,11 +281,7 @@ const fetchMore = async (): Promise<void> => {
 		return;
 	moreFetching.value = true;
 	backed.value = true;
-	const params = props.pagination.params
-		? isRef<Param>(props.pagination.params)
-			? props.pagination.params.value
-			: props.pagination.params
-		: {};
+	const params = props.pagination.params ? unref(props.pagination.params) : {};
 	await os
 		.api(props.pagination.endpoint, {
 			...params,
@@ -286,9 +294,13 @@ const fetchMore = async (): Promise<void> => {
 					? {
 							sinceId: items.value[0].id,
 						}
-					: {
-							untilId: items.value[items.value.length - 1].id,
-						}),
+					: props.pagination.ascending
+						? {
+								sinceId: items.value[items.value.length - 1].id,
+							}
+						: {
+								untilId: items.value[items.value.length - 1].id,
+							}),
 		})
 		.then(
 			(res: Item[]) => {
@@ -303,12 +315,12 @@ const fetchMore = async (): Promise<void> => {
 				if (res.length > SECOND_FETCH_LIMIT) {
 					res.pop();
 					items.value = props.pagination.reversed
-						? [...res].reverse().concat(items.value)
+						? res.toReversed().concat(items.value)
 						: items.value.concat(res);
 					more.value = true;
 				} else {
 					items.value = props.pagination.reversed
-						? [...res].reverse().concat(items.value)
+						? res.toReversed().concat(items.value)
 						: items.value.concat(res);
 					more.value = false;
 				}
@@ -330,11 +342,7 @@ const fetchMoreAhead = async (): Promise<void> => {
 	)
 		return;
 	moreFetching.value = true;
-	const params = props.pagination.params
-		? isRef<Param>(props.pagination.params)
-			? props.pagination.params.value
-			: props.pagination.params
-		: {};
+	const params = props.pagination.params ? unref(props.pagination.params) : {};
 	await os
 		.api(props.pagination.endpoint, {
 			...params,
@@ -356,12 +364,12 @@ const fetchMoreAhead = async (): Promise<void> => {
 				if (res.length > SECOND_FETCH_LIMIT) {
 					res.pop();
 					items.value = props.pagination.reversed
-						? [...res].reverse().concat(items.value)
+						? res.toReversed().concat(items.value)
 						: items.value.concat(res);
 					more.value = true;
 				} else {
 					items.value = props.pagination.reversed
-						? [...res].reverse().concat(items.value)
+						? res.toReversed().concat(items.value)
 						: items.value.concat(res);
 					more.value = false;
 				}
diff --git a/packages/client/src/components/MkReactedUsers.vue b/packages/client/src/components/MkReactedUsers.vue
index ae7a1d6d7d..76b86ed46f 100644
--- a/packages/client/src/components/MkReactedUsers.vue
+++ b/packages/client/src/components/MkReactedUsers.vue
@@ -23,7 +23,13 @@
 				}}</span>
 			</button>
 		</div>
-		<MkUserCardMini v-for="user in users" :key="user.id" :user="user" />
+		<MkPagination
+			ref="pagingComponent"
+			:pagination="pagination"
+			v-slot="{ items }"
+		>
+			<MkUserCardMini v-for="{ user: user } in items" :key="user.id" :user="user" />
+		</MkPagination>
 	</div>
 	<div v-else>
 		<MkLoading />
@@ -36,6 +42,9 @@ import type { entities } from "firefish-js";
 import MkReactionIcon from "@/components/MkReactionIcon.vue";
 import MkUserCardMini from "@/components/MkUserCardMini.vue";
 import * as os from "@/os";
+import MkPagination, {
+	type MkPaginationType,
+} from "@/components/MkPagination.vue";
 
 const props = defineProps<{
 	noteId: entities.Note["id"];
@@ -44,16 +53,22 @@ const props = defineProps<{
 const note = ref<entities.Note>();
 const tab = ref<string | null>(null);
 const reactions = ref<string[]>();
-const users = ref();
 
-async function updateUsers(): void {
-	const res = await os.api("notes/reactions", {
+const pagingComponent = ref<MkPaginationType<"notes/reactions"> | null>(null);
+
+const pagination = {
+	endpoint: "notes/reactions" as const,
+	params: {
 		noteId: props.noteId,
 		type: tab.value,
-		limit: 30,
-	});
+	},
+	offsetMode: true,
+	limit: 30,
+};
 
-	users.value = res.map((x) => x.user);
+function updateUsers(): void {
+	pagination.params.type = tab.value;
+	pagingComponent.value?.reload();
 }
 
 watch(tab, updateUsers);
@@ -64,7 +79,7 @@ onMounted(() => {
 	}).then(async (res) => {
 		reactions.value = Object.keys(res.reactions);
 		note.value = res;
-		await updateUsers();
+		// updateUsers();
 	});
 });
 </script>
diff --git a/packages/client/src/components/MkRenoteButton.vue b/packages/client/src/components/MkRenoteButton.vue
index 4f0a669c11..3925dc8c5a 100644
--- a/packages/client/src/components/MkRenoteButton.vue
+++ b/packages/client/src/components/MkRenoteButton.vue
@@ -27,7 +27,7 @@ import Ripple from "@/components/MkRipple.vue";
 import XDetails from "@/components/MkUsersTooltip.vue";
 import { pleaseLogin } from "@/scripts/please-login";
 import * as os from "@/os";
-import { isSignedIn, me } from "@/me";
+import { me } from "@/me";
 import { useTooltip } from "@/scripts/use-tooltip";
 import { i18n } from "@/i18n";
 import { defaultStore } from "@/store";
@@ -72,17 +72,9 @@ useTooltip(buttonRef, async (showing) => {
 	);
 });
 
-const hasRenotedBefore = ref(false);
-
-if (isSignedIn(me)) {
-	os.api("notes/renotes", {
-		noteId: props.note.id,
-		userId: me.id,
-		limit: 1,
-	}).then((res) => {
-		hasRenotedBefore.value = res.length > 0;
-	});
-}
+const hasRenotedBefore = ref(
+	props.note.myRenoteCount && props.note.myRenoteCount > 0,
+);
 
 const renote = (viaKeyboard = false, ev?: MouseEvent) => {
 	pleaseLogin();
diff --git a/packages/client/src/components/MkSubNoteContent.vue b/packages/client/src/components/MkSubNoteContent.vue
index 7d350085bb..a0664c28e9 100644
--- a/packages/client/src/components/MkSubNoteContent.vue
+++ b/packages/client/src/components/MkSubNoteContent.vue
@@ -1,7 +1,7 @@
 <template>
 	<p v-if="note.cw != null" class="cw">
 		<MkA
-			v-if="conversation && note.renoteId == parentId"
+			v-if="conversation && note.renoteId == parentId && parentId != null"
 			:to="
 				detailedView ? `#${parentId}` : `${notePage(note)}#${parentId}`
 			"
@@ -198,8 +198,8 @@ import icon from "@/scripts/icon";
 
 const props = defineProps<{
 	note: entities.Note;
-	parentId?;
-	conversation?;
+	parentId?: string;
+	conversation?: entities.Note[];
 	detailed?: boolean;
 	detailedView?: boolean;
 }>();
diff --git a/packages/client/src/pages/follow.vue b/packages/client/src/pages/follow.vue
index a8708978ef..3be549537f 100644
--- a/packages/client/src/pages/follow.vue
+++ b/packages/client/src/pages/follow.vue
@@ -24,16 +24,16 @@ async function follow(user): Promise<void> {
 	});
 }
 
-const acct = new URL(location.href).searchParams.get("acct");
-if (acct == null) {
+const acctUri = new URL(location.href).searchParams.get("acct");
+if (acctUri == null) {
 	throw new Error("acct required");
 }
 
 let promise;
 
-if (acct.startsWith("https://")) {
+if (acctUri.startsWith("https://")) {
 	promise = os.api("ap/show", {
-		uri: acct,
+		uri: acctUri,
 	});
 	promise.then((res) => {
 		if (res.type === "User") {
@@ -50,7 +50,7 @@ if (acct.startsWith("https://")) {
 		}
 	});
 } else {
-	promise = os.api("users/show", acct.parse(acct));
+	promise = os.api("users/show", acct.parse(acctUri));
 	promise.then((user) => {
 		follow(user);
 	});
diff --git a/packages/client/src/scripts/use-note-capture.ts b/packages/client/src/scripts/use-note-capture.ts
index 09043044cd..dac3e140a3 100644
--- a/packages/client/src/scripts/use-note-capture.ts
+++ b/packages/client/src/scripts/use-note-capture.ts
@@ -9,6 +9,7 @@ export function useNoteCapture(props: {
 	rootEl: Ref<HTMLElement | null>;
 	note: Ref<entities.Note>;
 	isDeletedRef: Ref<boolean>;
+	onReplied?: (note: entities.Note) => void;
 }) {
 	const note = props.note;
 	const connection = isSignedIn(me) ? useStream() : null;
@@ -19,6 +20,16 @@ export function useNoteCapture(props: {
 		if (id !== note.value.id) return;
 
 		switch (type) {
+			case "replied": {
+				if (props.onReplied) {
+					const { id: createdId } = body;
+					const replyNote = await os.api("notes/show", {
+						noteId: createdId,
+					});
+					props.onReplied(replyNote);
+				}
+				break;
+			}
 			case "reacted": {
 				const reaction = body.reaction;
 
diff --git a/packages/firefish-js/src/api.types.ts b/packages/firefish-js/src/api.types.ts
index fc0407b906..065de49025 100644
--- a/packages/firefish-js/src/api.types.ts
+++ b/packages/firefish-js/src/api.types.ts
@@ -801,7 +801,12 @@ export type Endpoints = {
 		res: null;
 	};
 	"notes/reactions": {
-		req: { noteId: Note["id"]; type?: string | null; limit?: number };
+		req: {
+			noteId: Note["id"];
+			type?: string | null;
+			limit?: number;
+			offset?: number;
+		};
 		res: NoteReaction[];
 	};
 	"notes/reactions/create": {
@@ -815,6 +820,7 @@ export type Endpoints = {
 			sinceId?: Note["id"];
 			untilId?: Note["id"];
 			noteId: Note["id"];
+			filter?: "all" | "renote" | "quote";
 		};
 		res: Note[];
 	};
diff --git a/packages/firefish-js/src/entities.ts b/packages/firefish-js/src/entities.ts
index 9cc9d915b8..db9e18a741 100644
--- a/packages/firefish-js/src/entities.ts
+++ b/packages/firefish-js/src/entities.ts
@@ -175,9 +175,11 @@ export type Note = {
 	channelId?: Channel["id"];
 	channel?: Channel;
 	myReaction?: string;
+	myRenoteCount?: number;
 	reactions: Record<string, number>;
 	renoteCount: number;
 	repliesCount: number;
+	quoteCount: number;
 	poll?: {
 		expiresAt: DateString | null;
 		multiple: boolean;