From 7f3b9b171c37ce37b36ccb3b5324f3eb97e95fbc Mon Sep 17 00:00:00 2001
From: ThatOneCalculator <kainoa@t1c.dev>
Date: Wed, 14 Jun 2023 20:17:56 -0700
Subject: [PATCH] =?UTF-8?q?fix:=20=F0=9F=9A=B8=20make=20"show=20replies=20?=
 =?UTF-8?q?in=20timeline"=20work=20as=20expected?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-authored-by: Syuilo <syuilotan@yahoo.co.jp>
---
 ...684206886988-remove-showTimelineReplies.js | 15 ++++++++
 .../native-utils/src/model/entity/user.rs     |  2 -
 packages/backend/src/models/entities/user.ts  |  6 ---
 .../backend/src/models/repositories/user.ts   |  1 -
 .../src/remote/activitypub/models/person.ts   |  1 -
 .../api/common/generate-replies-query.ts      | 17 ++++-----
 .../src/server/api/endpoints/i/update.ts      |  3 --
 .../api/endpoints/notes/global-timeline.ts    |  7 +++-
 .../api/endpoints/notes/hybrid-timeline.ts    |  7 +++-
 .../api/endpoints/notes/local-timeline.ts     |  7 +++-
 .../endpoints/notes/recommended-timeline.ts   |  7 +++-
 .../server/api/endpoints/notes/timeline.ts    |  7 +++-
 .../api/stream/channels/global-timeline.ts    |  5 ++-
 .../api/stream/channels/home-timeline.ts      |  5 ++-
 .../api/stream/channels/hybrid-timeline.ts    |  5 ++-
 .../api/stream/channels/local-timeline.ts     |  5 ++-
 .../stream/channels/recommended-timeline.ts   |  5 ++-
 packages/backend/test/e2e/users.ts            | 19 +---------
 .../client/src/components/MkFollowButton.vue  |  7 +++-
 packages/client/src/components/MkTimeline.vue | 35 +++++++++++++++---
 .../client/src/pages/settings/general.vue     | 23 ++----------
 .../pages/settings/preferences-backups.vue    |  1 +
 packages/client/src/store.ts                  |  4 ++
 packages/client/src/stream.ts                 | 37 ++++++++++++++-----
 24 files changed, 145 insertions(+), 86 deletions(-)
 create mode 100644 packages/backend/migration/1684206886988-remove-showTimelineReplies.js

diff --git a/packages/backend/migration/1684206886988-remove-showTimelineReplies.js b/packages/backend/migration/1684206886988-remove-showTimelineReplies.js
new file mode 100644
index 000000000..e5f8483c7
--- /dev/null
+++ b/packages/backend/migration/1684206886988-remove-showTimelineReplies.js
@@ -0,0 +1,15 @@
+export class RemoveShowTimelineReplies1684206886988 {
+	name = "RemoveShowTimelineReplies1684206886988";
+
+	async up(queryRunner) {
+		await queryRunner.query(
+			`ALTER TABLE "user" DROP COLUMN "showTimelineReplies"`,
+		);
+	}
+
+	async down(queryRunner) {
+		await queryRunner.query(
+			`ALTER TABLE "user" ADD "showTimelineReplies" boolean NOT NULL DEFAULT false`,
+		);
+	}
+}
diff --git a/packages/backend/native-utils/src/model/entity/user.rs b/packages/backend/native-utils/src/model/entity/user.rs
index f30fd8ace..e76ae08c7 100644
--- a/packages/backend/native-utils/src/model/entity/user.rs
+++ b/packages/backend/native-utils/src/model/entity/user.rs
@@ -63,8 +63,6 @@ pub struct Model {
     pub hide_online_status: bool,
     #[sea_orm(column_name = "isDeleted")]
     pub is_deleted: bool,
-    #[sea_orm(column_name = "showTimelineReplies")]
-    pub show_timeline_replies: bool,
     #[sea_orm(column_name = "driveCapacityOverrideMb")]
     pub drive_capacity_override_mb: Option<i32>,
     #[sea_orm(column_name = "movedToUri")]
diff --git a/packages/backend/src/models/entities/user.ts b/packages/backend/src/models/entities/user.ts
index 53dc7e60b..ddad9f3b2 100644
--- a/packages/backend/src/models/entities/user.ts
+++ b/packages/backend/src/models/entities/user.ts
@@ -249,12 +249,6 @@ export class User {
 	})
 	public followersUri: string | null;
 
-	@Column("boolean", {
-		default: false,
-		comment: "Whether to show users replying to other users in the timeline.",
-	})
-	public showTimelineReplies: boolean;
-
 	@Index({ unique: true })
 	@Column("char", {
 		length: 16,
diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts
index 1ca9b3289..48c8d75b3 100644
--- a/packages/backend/src/models/repositories/user.ts
+++ b/packages/backend/src/models/repositories/user.ts
@@ -567,7 +567,6 @@ export const UserRepository = db.getRepository(User).extend({
 						mutedInstances: profile!.mutedInstances,
 						mutingNotificationTypes: profile!.mutingNotificationTypes,
 						emailNotificationTypes: profile!.emailNotificationTypes,
-						showTimelineReplies: user.showTimelineReplies || falsy,
 				  }
 				: {}),
 
diff --git a/packages/backend/src/remote/activitypub/models/person.ts b/packages/backend/src/remote/activitypub/models/person.ts
index 9e21fa9ff..f8208e6d7 100644
--- a/packages/backend/src/remote/activitypub/models/person.ts
+++ b/packages/backend/src/remote/activitypub/models/person.ts
@@ -279,7 +279,6 @@ export async function createPerson(
 					tags,
 					isBot,
 					isCat: (person as any).isCat === true,
-					showTimelineReplies: false,
 				}),
 			)) as IRemoteUser;
 
diff --git a/packages/backend/src/server/api/common/generate-replies-query.ts b/packages/backend/src/server/api/common/generate-replies-query.ts
index 140c1d74a..845fef130 100644
--- a/packages/backend/src/server/api/common/generate-replies-query.ts
+++ b/packages/backend/src/server/api/common/generate-replies-query.ts
@@ -4,7 +4,8 @@ import { Brackets } from "typeorm";
 
 export function generateRepliesQuery(
 	q: SelectQueryBuilder<any>,
-	me?: Pick<User, "id" | "showTimelineReplies"> | null,
+	withReplies: boolean,
+	me?: Pick<User, "id"> | null,
 ) {
 	if (me == null) {
 		q.andWhere(
@@ -20,25 +21,21 @@ export function generateRepliesQuery(
 					);
 			}),
 		);
-	} else if (!me.showTimelineReplies) {
+	} else if (!withReplies) {
 		q.andWhere(
 			new Brackets((qb) => {
 				qb.where("note.replyId IS NULL") // 返信ではない
 					.orWhere("note.replyUserId = :meId", { meId: me.id }) // 返信だけど自分のノートへの返信
 					.orWhere(
 						new Brackets((qb) => {
-							qb.where(
-								// 返信だけど自分の行った返信
-								"note.replyId IS NOT NULL",
-							).andWhere("note.userId = :meId", { meId: me.id });
+							qb.where("note.replyId IS NOT NULL") // 返信だけど自分の行った返信
+								.andWhere("note.userId = :meId", { meId: me.id });
 						}),
 					)
 					.orWhere(
 						new Brackets((qb) => {
-							qb.where(
-								// 返信だけど投稿者自身への返信
-								"note.replyId IS NOT NULL",
-							).andWhere("note.replyUserId = note.userId");
+							qb.where("note.replyId IS NOT NULL") // 返信だけど投稿者自身への返信
+								.andWhere("note.replyUserId = note.userId");
 						}),
 					);
 			}),
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index f3ff704ce..0637251a6 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -106,7 +106,6 @@ export const paramDef = {
 		isBot: { type: "boolean" },
 		isCat: { type: "boolean" },
 		speakAsCat: { type: "boolean" },
-		showTimelineReplies: { type: "boolean" },
 		injectFeaturedNote: { type: "boolean" },
 		receiveAnnouncementEmail: { type: "boolean" },
 		alwaysMarkNsfw: { type: "boolean" },
@@ -185,8 +184,6 @@ export default define(meta, paramDef, async (ps, _user, token) => {
 	if (typeof ps.publicReactions === "boolean")
 		profileUpdates.publicReactions = ps.publicReactions;
 	if (typeof ps.isBot === "boolean") updates.isBot = ps.isBot;
-	if (typeof ps.showTimelineReplies === "boolean")
-		updates.showTimelineReplies = ps.showTimelineReplies;
 	if (typeof ps.carefulBot === "boolean")
 		profileUpdates.carefulBot = ps.carefulBot;
 	if (typeof ps.autoAcceptFollowed === "boolean")
diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
index 78a193283..0a365a6df 100644
--- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
@@ -53,6 +53,11 @@ export const paramDef = {
 		untilId: { type: "string", format: "misskey:id" },
 		sinceDate: { type: "integer" },
 		untilDate: { type: "integer" },
+		withReplies: {
+			type: "boolean",
+			default: false,
+			description: "Show replies in the timeline",
+		},
 	},
 	required: [],
 } as const;
@@ -87,7 +92,7 @@ export default define(meta, paramDef, async (ps, user) => {
 		.leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar")
 		.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner");
 
-	generateRepliesQuery(query, user);
+	generateRepliesQuery(query, ps.withReplies, user);
 	if (user) {
 		generateMutedUserQuery(query, user);
 		generateMutedNoteQuery(query, user);
diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
index 508b268cc..4e32b0ab2 100644
--- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
@@ -60,6 +60,11 @@ export const paramDef = {
 			default: false,
 			description: "Only show notes that have attached files.",
 		},
+		withReplies: {
+			type: "boolean",
+			default: false,
+			description: "Show replies in the timeline",
+		},
 	},
 	required: [],
 } as const;
@@ -104,7 +109,7 @@ export default define(meta, paramDef, async (ps, user) => {
 		.setParameters(followingQuery.getParameters());
 
 	generateChannelQuery(query, user);
-	generateRepliesQuery(query, user);
+	generateRepliesQuery(query, ps.withReplies, user);
 	generateVisibilityQuery(query, user);
 	generateMutedUserQuery(query, user);
 	generateMutedNoteQuery(query, user);
diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
index 797c6d77c..82e93e371 100644
--- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
@@ -63,6 +63,11 @@ export const paramDef = {
 		untilId: { type: "string", format: "misskey:id" },
 		sinceDate: { type: "integer" },
 		untilDate: { type: "integer" },
+		withReplies: {
+			type: "boolean",
+			default: false,
+			description: "Show replies in the timeline",
+		},
 	},
 	required: [],
 } as const;
@@ -97,7 +102,7 @@ export default define(meta, paramDef, async (ps, user) => {
 		.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner");
 
 	generateChannelQuery(query, user);
-	generateRepliesQuery(query, user);
+	generateRepliesQuery(query, ps.withReplies, user);
 	generateVisibilityQuery(query, user);
 	if (user) generateMutedUserQuery(query, user);
 	if (user) generateMutedNoteQuery(query, user);
diff --git a/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts b/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts
index 321ab4ad7..d3b5cbff5 100644
--- a/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts
@@ -63,6 +63,11 @@ export const paramDef = {
 		untilId: { type: "string", format: "misskey:id" },
 		sinceDate: { type: "integer" },
 		untilDate: { type: "integer" },
+		withReplies: {
+			type: "boolean",
+			default: false,
+			description: "Show replies in the timeline",
+		},
 	},
 	required: [],
 } as const;
@@ -100,7 +105,7 @@ export default define(meta, paramDef, async (ps, user) => {
 		.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner");
 
 	generateChannelQuery(query, user);
-	generateRepliesQuery(query, user);
+	generateRepliesQuery(query, ps.withReplies, user);
 	generateVisibilityQuery(query, user);
 	if (user) generateMutedUserQuery(query, user);
 	if (user) generateMutedNoteQuery(query, user);
diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts
index 62996efdd..d629deebb 100644
--- a/packages/backend/src/server/api/endpoints/notes/timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts
@@ -54,6 +54,11 @@ export const paramDef = {
 			default: false,
 			description: "Only show notes that have attached files.",
 		},
+		withReplies: {
+			type: "boolean",
+			default: false,
+			description: "Show replies in the timeline",
+		},
 	},
 	required: [],
 } as const;
@@ -100,7 +105,7 @@ export default define(meta, paramDef, async (ps, user) => {
 		.setParameters(followingQuery.getParameters());
 
 	generateChannelQuery(query, user);
-	generateRepliesQuery(query, user);
+	generateRepliesQuery(query, ps.withReplies, user);
 	generateVisibilityQuery(query, user);
 	generateMutedUserQuery(query, user);
 	generateMutedNoteQuery(query, user);
diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts
index 4e8263bbe..2257be2b8 100644
--- a/packages/backend/src/server/api/stream/channels/global-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts
@@ -9,6 +9,7 @@ export default class extends Channel {
 	public readonly chName = "globalTimeline";
 	public static shouldShare = true;
 	public static requireCredential = false;
+	private withReplies: boolean;
 
 	constructor(id: string, connection: Channel["connection"]) {
 		super(id, connection);
@@ -22,6 +23,8 @@ export default class extends Channel {
 				return;
 		}
 
+		this.withReplies = params.withReplies as boolean;
+
 		// Subscribe events
 		this.subscriber.on("notesStream", this.onNote);
 	}
@@ -31,7 +34,7 @@ export default class extends Channel {
 		if (note.channelId != null) return;
 
 		// 関係ない返信は除外
-		if (note.reply && !this.user!.showTimelineReplies) {
+		if (note.reply && !this.withReplies) {
 			const reply = note.reply;
 			// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
 			if (
diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts
index bdcd8a283..47875aeda 100644
--- a/packages/backend/src/server/api/stream/channels/home-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts
@@ -8,6 +8,7 @@ export default class extends Channel {
 	public readonly chName = "homeTimeline";
 	public static shouldShare = true;
 	public static requireCredential = true;
+	private withReplies: boolean;
 
 	constructor(id: string, connection: Channel["connection"]) {
 		super(id, connection);
@@ -15,6 +16,8 @@ export default class extends Channel {
 	}
 
 	public async init(params: any) {
+		this.withReplies = params.withReplies as boolean;
+
 		// Subscribe events
 		this.subscriber.on("notesStream", this.onNote);
 	}
@@ -39,7 +42,7 @@ export default class extends Channel {
 			return;
 
 		// 関係ない返信は除外
-		if (note.reply && !this.user!.showTimelineReplies) {
+		if (note.reply && !this.withReplies) {
 			const reply = note.reply;
 			// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
 			if (
diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
index 9205d609d..1f1a9b831 100644
--- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
@@ -9,6 +9,7 @@ export default class extends Channel {
 	public readonly chName = "hybridTimeline";
 	public static shouldShare = true;
 	public static requireCredential = true;
+	private withReplies: boolean;
 
 	constructor(id: string, connection: Channel["connection"]) {
 		super(id, connection);
@@ -24,6 +25,8 @@ export default class extends Channel {
 		)
 			return;
 
+		this.withReplies = params.withReplies as boolean;
+
 		// Subscribe events
 		this.subscriber.on("notesStream", this.onNote);
 	}
@@ -56,7 +59,7 @@ export default class extends Channel {
 			return;
 
 		// 関係ない返信は除外
-		if (note.reply && !this.user!.showTimelineReplies) {
+		if (note.reply && !this.withReplies) {
 			const reply = note.reply;
 			// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
 			if (
diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts
index df0845b5b..bd488bdd7 100644
--- a/packages/backend/src/server/api/stream/channels/local-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts
@@ -8,6 +8,7 @@ export default class extends Channel {
 	public readonly chName = "localTimeline";
 	public static shouldShare = true;
 	public static requireCredential = false;
+	private withReplies: boolean;
 
 	constructor(id: string, connection: Channel["connection"]) {
 		super(id, connection);
@@ -21,6 +22,8 @@ export default class extends Channel {
 				return;
 		}
 
+		this.withReplies = params.withReplies as boolean;
+
 		// Subscribe events
 		this.subscriber.on("notesStream", this.onNote);
 	}
@@ -32,7 +35,7 @@ export default class extends Channel {
 			return;
 
 		// 関係ない返信は除外
-		if (note.reply && !this.user!.showTimelineReplies) {
+		if (note.reply && !this.withReplies) {
 			const reply = note.reply;
 			// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
 			if (
diff --git a/packages/backend/src/server/api/stream/channels/recommended-timeline.ts b/packages/backend/src/server/api/stream/channels/recommended-timeline.ts
index d030c1e7e..0b78d8b66 100644
--- a/packages/backend/src/server/api/stream/channels/recommended-timeline.ts
+++ b/packages/backend/src/server/api/stream/channels/recommended-timeline.ts
@@ -9,6 +9,7 @@ export default class extends Channel {
 	public readonly chName = "recommendedTimeline";
 	public static shouldShare = true;
 	public static requireCredential = true;
+	private withReplies: boolean;
 
 	constructor(id: string, connection: Channel["connection"]) {
 		super(id, connection);
@@ -24,6 +25,8 @@ export default class extends Channel {
 		)
 			return;
 
+		this.withReplies = params.withReplies as boolean;
+
 		// Subscribe events
 		this.subscriber.on("notesStream", this.onNote);
 	}
@@ -54,7 +57,7 @@ export default class extends Channel {
 			return;
 
 		// 関係ない返信は除外
-		if (note.reply && !this.user!.showTimelineReplies) {
+		if (note.reply && !this.withReplies) {
 			const reply = note.reply;
 			// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
 			if (
diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts
index 1eb304df6..672080d9a 100644
--- a/packages/backend/test/e2e/users.ts
+++ b/packages/backend/test/e2e/users.ts
@@ -44,12 +44,7 @@ describe("ユーザー", () => {
 		};
 
 	type MeDetailed = UserDetailedNotMe &
-		misskey.entities.MeDetailed & {
-			showTimelineReplies: boolean;
-			achievements: object[];
-			loggedInDays: number;
-			policies: object;
-		};
+		misskey.entities.MeDetailed
 
 	type User = MeDetailed & { token: string };
 
@@ -172,9 +167,6 @@ describe("ユーザー", () => {
 			mutedInstances: user.mutedInstances,
 			mutingNotificationTypes: user.mutingNotificationTypes,
 			emailNotificationTypes: user.emailNotificationTypes,
-			showTimelineReplies: user.showTimelineReplies,
-			achievements: user.achievements,
-			loggedInDays: user.loggedInDays,
 			policies: user.policies,
 			...(security
 				? {
@@ -479,13 +471,6 @@ describe("ユーザー", () => {
 			"follow",
 			"receiveFollowRequest",
 		]);
-		assert.strictEqual(response.showTimelineReplies, false);
-		assert.deepStrictEqual(response.achievements, []);
-		assert.deepStrictEqual(response.loggedInDays, 0);
-		assert.deepStrictEqual(response.policies, DEFAULT_POLICIES);
-		assert.notStrictEqual(response.email, undefined);
-		assert.strictEqual(response.emailVerified, false);
-		assert.deepStrictEqual(response.securityKeysList, []);
 	});
 
 	//#endregion
@@ -551,8 +536,6 @@ describe("ユーザー", () => {
 		{ parameters: (): object => ({ isBot: false }) },
 		{ parameters: (): object => ({ isCat: true }) },
 		{ parameters: (): object => ({ isCat: false }) },
-		{ parameters: (): object => ({ showTimelineReplies: true }) },
-		{ parameters: (): object => ({ showTimelineReplies: false }) },
 		{ parameters: (): object => ({ injectFeaturedNote: true }) },
 		{ parameters: (): object => ({ injectFeaturedNote: false }) },
 		{ parameters: (): object => ({ receiveAnnouncementEmail: true }) },
diff --git a/packages/client/src/components/MkFollowButton.vue b/packages/client/src/components/MkFollowButton.vue
index 04f5e3311..bff393fdd 100644
--- a/packages/client/src/components/MkFollowButton.vue
+++ b/packages/client/src/components/MkFollowButton.vue
@@ -1,5 +1,10 @@
 <template>
-	<button v-if="!hideMenu" class="menu _button" @click.stop="menu" v-tooltip="i18n.ts.menu">
+	<button
+		v-if="!hideMenu"
+		class="menu _button"
+		@click.stop="menu"
+		v-tooltip="i18n.ts.menu"
+	>
 		<i class="ph-dots-three-outline ph-bold ph-lg"></i>
 	</button>
 	<button
diff --git a/packages/client/src/components/MkTimeline.vue b/packages/client/src/components/MkTimeline.vue
index 11fe175d9..c3366c17a 100644
--- a/packages/client/src/components/MkTimeline.vue
+++ b/packages/client/src/components/MkTimeline.vue
@@ -91,7 +91,12 @@ if (props.src === "antenna") {
 	connection.on("note", prepend);
 } else if (props.src === "home") {
 	endpoint = "notes/timeline";
-	connection = stream.useChannel("homeTimeline");
+	query = {
+		withReplies: defaultStore.state.showTimelineReplies,
+	};
+	connection = stream.useChannel("homeTimeline", {
+		withReplies: defaultStore.state.showTimelineReplies,
+	});
 	connection.on("note", prepend);
 
 	connection2 = stream.useChannel("main");
@@ -102,28 +107,48 @@ if (props.src === "antenna") {
 	tlHintClosed = defaultStore.state.tlHomeHintClosed;
 } else if (props.src === "local") {
 	endpoint = "notes/local-timeline";
-	connection = stream.useChannel("localTimeline");
+	query = {
+		withReplies: defaultStore.state.showTimelineReplies,
+	};
+	connection = stream.useChannel("localTimeline", {
+		withReplies: defaultStore.state.showTimelineReplies,
+	});
 	connection.on("note", prepend);
 
 	tlHint = i18n.ts._tutorial.step5_4;
 	tlHintClosed = defaultStore.state.tlLocalHintClosed;
 } else if (props.src === "recommended") {
 	endpoint = "notes/recommended-timeline";
-	connection = stream.useChannel("recommendedTimeline");
+	query = {
+		withReplies: defaultStore.state.showTimelineReplies,
+	};
+	connection = stream.useChannel("recommendedTimeline", {
+		withReplies: defaultStore.state.showTimelineReplies,
+	});
 	connection.on("note", prepend);
 
 	tlHint = i18n.ts._tutorial.step5_6;
 	tlHintClosed = defaultStore.state.tlRecommendedHintClosed;
 } else if (props.src === "social") {
 	endpoint = "notes/hybrid-timeline";
-	connection = stream.useChannel("hybridTimeline");
+	query = {
+		withReplies: defaultStore.state.showTimelineReplies,
+	};
+	connection = stream.useChannel("hybridTimeline", {
+		withReplies: defaultStore.state.showTimelineReplies,
+	});
 	connection.on("note", prepend);
 
 	tlHint = i18n.ts._tutorial.step5_5;
 	tlHintClosed = defaultStore.state.tlSocialHintClosed;
 } else if (props.src === "global") {
 	endpoint = "notes/global-timeline";
-	connection = stream.useChannel("globalTimeline");
+	query = {
+		withReplies: defaultStore.state.showTimelineReplies,
+	};
+	connection = stream.useChannel("globalTimeline", {
+		withReplies: defaultStore.state.showTimelineReplies,
+	});
 	connection.on("note", prepend);
 
 	tlHint = i18n.ts._tutorial.step5_7;
diff --git a/packages/client/src/pages/settings/general.vue b/packages/client/src/pages/settings/general.vue
index cf75d4fe6..36a4e2a13 100644
--- a/packages/client/src/pages/settings/general.vue
+++ b/packages/client/src/pages/settings/general.vue
@@ -54,7 +54,7 @@
 			<FormSwitch v-model="disablePagesScript" class="_formBlock">{{
 				i18n.ts.disablePagesScript
 			}}</FormSwitch>
-			<FormSwitch v-model="profile.showTimelineReplies" class="_formBlock"
+			<FormSwitch v-model="showTimelineReplies" class="_formBlock"
 				>{{ i18n.ts.flagShowTimelineReplies
 				}}<template #caption
 					>{{ i18n.ts.flagShowTimelineRepliesDescription }}
@@ -258,24 +258,6 @@ const lang = ref(localStorage.getItem("lang"));
 const fontSize = ref(localStorage.getItem("fontSize"));
 const useSystemFont = ref(localStorage.getItem("useSystemFont") != null);
 
-const profile = reactive({
-	showTimelineReplies: $i?.showTimelineReplies,
-});
-watch(
-	() => profile,
-	() => {
-		save();
-	},
-	{
-		deep: true,
-	}
-);
-function save() {
-	os.apiWithDialog("i/update", {
-		showTimelineReplies: !!profile.showTimelineReplies,
-	});
-}
-
 async function reloadAsk() {
 	const { canceled } = await os.confirm({
 		type: "info",
@@ -360,6 +342,9 @@ const swipeOnDesktop = computed(
 const showAdminUpdates = computed(
 	defaultStore.makeGetterSetter("showAdminUpdates")
 );
+const showTimelineReplies = computed(
+	defaultStore.makeGetterSetter("showTimelineReplies")
+);
 
 watch(lang, () => {
 	localStorage.setItem("lang", lang.value as string);
diff --git a/packages/client/src/pages/settings/preferences-backups.vue b/packages/client/src/pages/settings/preferences-backups.vue
index 14bb27f91..313024d86 100644
--- a/packages/client/src/pages/settings/preferences-backups.vue
+++ b/packages/client/src/pages/settings/preferences-backups.vue
@@ -115,6 +115,7 @@ const defaultStoreSaveKeys: (keyof (typeof defaultStore)["state"])[] = [
 	"enableCustomKaTeXMacro",
 	"enableEmojiReactions",
 	"showEmojisInReactionNotifications",
+	"showTimelineReplies",
 ];
 const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [
 	"lightTheme",
diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts
index 5199c336a..c8ce96b0e 100644
--- a/packages/client/src/store.ts
+++ b/packages/client/src/store.ts
@@ -330,6 +330,10 @@ export const defaultStore = markRaw(
 			where: "account",
 			default: true,
 		},
+		showTimelineReplies: {
+			where: "device",
+			default: true,
+		}
 	}),
 );
 
diff --git a/packages/client/src/stream.ts b/packages/client/src/stream.ts
index e96b04565..0bc6198da 100644
--- a/packages/client/src/stream.ts
+++ b/packages/client/src/stream.ts
@@ -3,13 +3,30 @@ import { markRaw } from "vue";
 import { $i } from "@/account";
 import { url } from "@/config";
 
-export const stream = markRaw(
-	new Misskey.Stream(
-		url,
-		$i
-			? {
-					token: $i.token,
-			  }
-			: null,
-	),
-);
+let stream: Misskey.Stream | null = null;
+
+export function useStream(): Misskey.Stream {
+	if (stream) return stream;
+
+	stream = markRaw(
+		new Misskey.Stream(
+			url,
+			$i
+				? {
+						token: $i.token,
+				  }
+				: null
+		)
+	);
+
+	window.setTimeout(heartbeat, 1000 * 60);
+
+	return stream;
+}
+
+function heartbeat(): void {
+	if (stream != null && document.visibilityState === "visible") {
+		stream.send("ping");
+	}
+	window.setTimeout(heartbeat, 1000 * 60);
+}