diff --git a/UPGRADE_NOTES.md b/UPGRADE_NOTES.md
new file mode 100644
index 0000000000000000000000000000000000000000..8bebd4eb34790dffea739fde66b1daa7864116c8
--- /dev/null
+++ b/UPGRADE_NOTES.md
@@ -0,0 +1,41 @@
+# Upgrade Notes
+
+## 2024.9.0
+
+### Following Feed
+
+When upgrading an existing instance to version 2024.9.0, the Following Feed will initially be empty.
+The feed will gradually fill as new posts federate, but it may be desirable to back-fill the feed with existing data.
+This database script will populate the feed with the latest post of each type for all users, ensuring that data is fully populated after the update.
+Run this after migrations but before starting the instance.
+Warning: the script may take a long time to execute!
+
+```postgresql
+INSERT INTO latest_note (user_id, note_id, is_public, is_reply, is_quote)
+SELECT
+	"userId" as user_id,
+	id as note_id,
+	visibility = 'public' AS is_public,
+	"replyId" IS NOT NULL AS is_reply,
+	(
+		"renoteId" IS NOT NULL
+			AND (
+			text IS NOT NULL
+				OR cw IS NOT NULL
+				OR "replyId" IS NOT NULL
+				OR "hasPoll"
+				OR "fileIds" != '{}'
+			)
+		) AS is_quote
+FROM note
+WHERE ( -- Exclude pure renotes (boosts)
+				"renoteId" IS NULL
+					OR text IS NOT NULL
+					OR cw IS NOT NULL
+					OR "replyId" IS NOT NULL
+					OR "hasPoll"
+					OR "fileIds" != '{}'
+				)
+ORDER BY id DESC -- This part is very important: it ensures that we only load the *latest* notes of each type. Do not remove it!
+ON CONFLICT DO NOTHING; -- Any conflicts are guaranteed to be older notes that we can ignore.
+```
diff --git a/locales/en-US.yml b/locales/en-US.yml
index 7eed1db2de4172dcee28215e5017ab33e7c5ac64..0e74ae5529c17a8be650485e86f68fa21b01dc91 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -1262,7 +1262,10 @@ unnotifyNotes: "Stop notifying about new notes"
 authentication: "Authentication"
 authenticationRequiredToContinue: "Please authenticate to continue"
 dateAndTime: "Timestamp"
-showRenotes: "Show renotes"
+showRenotes: "Show boosts"
+showQuotes: "Show quotes"
+showReplies: "Show replies"
+showNonPublicNotes: "Show non-public"
 edited: "Edited"
 notificationRecieveConfig: "Notification Settings"
 mutualFollow: "Mutual follow"
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 2b08296f02441cb5aaefb726eb4b6545a87490ed..44ee3835e31e84886d3428046e78feb6432cb24a 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -5065,6 +5065,18 @@ export interface Locale extends ILocale {
      * リノートを表示
      */
     "showRenotes": string;
+    /**
+     * Show quotes
+     */
+    "showQuotes": string;
+    /**
+     * Show replies
+     */
+    "showReplies": string;
+    /**
+     * Show non-public
+     */
+    "showNonPublicNotes": string;
     /**
      * 編集済み
      */
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 4875a1bbf48debd18c62e3b0d943766fdf0f4e67..ab7304226201d17fad5fcbc50fb1f2673fba4468 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1261,7 +1261,10 @@ unnotifyNotes: "投稿の通知を解除"
 authentication: "認証"
 authenticationRequiredToContinue: "続けるには認証を行ってください"
 dateAndTime: "日時"
-showRenotes: "リノートを表示"
+showRenotes: "ブーストを表示"
+showQuotes: "Show quotes"
+showReplies: "Show replies"
+showNonPublicNotes: "Show non-public"
 edited: "編集済み"
 notificationRecieveConfig: "通知の受信設定"
 mutualFollow: "相互フォロー"
diff --git a/locales/version.d.ts b/locales/version.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..54ceec744371f00ddaa5a89cc91d2dc68ce5d8f1
--- /dev/null
+++ b/locales/version.d.ts
@@ -0,0 +1 @@
+export const localesVersion: string;
diff --git a/locales/version.js b/locales/version.js
new file mode 100644
index 0000000000000000000000000000000000000000..e84414b74d85a06e717657fedae272cab7bed77e
--- /dev/null
+++ b/locales/version.js
@@ -0,0 +1,14 @@
+import { createHash } from 'crypto';
+import locales from './index.js';
+
+// MD5 is acceptable because we don't need cryptographic security.
+const hash = createHash('md5');
+
+// Derive the version hash from locale content exclusively.
+// This avoids the problem of "stuck" translations after modifying locale files.
+const localesText = JSON.stringify(locales);
+hash.update(localesText, 'utf8');
+
+// We can't use regular base64 since this becomes part of a filename.
+// Base64URL avoids special characters that would cause an issue.
+export const localesVersion = hash.digest().toString('base64url');
diff --git a/packages/backend/migration/1728420772835-track-latest-note-type.js b/packages/backend/migration/1728420772835-track-latest-note-type.js
new file mode 100644
index 0000000000000000000000000000000000000000..4c9b4ca5949e8708eb5342cdff014145dc7c9adc
--- /dev/null
+++ b/packages/backend/migration/1728420772835-track-latest-note-type.js
@@ -0,0 +1,24 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class TrackLatestNoteType1728420772835 {
+    name = 'TrackLatestNoteType1728420772835'
+
+    async up(queryRunner) {
+				await queryRunner.query(`ALTER TABLE "latest_note" DROP CONSTRAINT "PK_f619b62bfaafabe68f52fb50c9a"`);
+				await queryRunner.query(`ALTER TABLE "latest_note" ADD "is_public" boolean NOT NULL DEFAULT false`);
+				await queryRunner.query(`ALTER TABLE "latest_note" ADD "is_reply" boolean NOT NULL DEFAULT false`);
+				await queryRunner.query(`ALTER TABLE "latest_note" ADD "is_quote" boolean NOT NULL DEFAULT false`);
+				await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd" PRIMARY KEY ("user_id", is_public, is_reply, is_quote)`);
+		}
+
+    async down(queryRunner) {
+				await queryRunner.query(`ALTER TABLE "latest_note" DROP CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd"`);
+				await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_quote`);
+				await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_reply`);
+				await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_public`);
+				await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_f619b62bfaafabe68f52fb50c9a" PRIMARY KEY ("user_id")`);
+		}
+}
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index 049d85818972ed0b44ac4c7b1351748e04326892..1d807d2aa023ef2f07c897c4723cfbceeaef5926 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -42,6 +42,7 @@ import { ModerationLogService } from './ModerationLogService.js';
 import { NoteCreateService } from './NoteCreateService.js';
 import { NoteEditService } from './NoteEditService.js';
 import { NoteDeleteService } from './NoteDeleteService.js';
+import { LatestNoteService } from './LatestNoteService.js';
 import { NotePiningService } from './NotePiningService.js';
 import { NoteReadService } from './NoteReadService.js';
 import { NotificationService } from './NotificationService.js';
@@ -185,6 +186,7 @@ const $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx
 const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService };
 const $NoteEditService: Provider = { provide: 'NoteEditService', useExisting: NoteEditService };
 const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
+const $LatestNoteService: Provider = { provide: 'LatestNoteService', useExisting: LatestNoteService };
 const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
 const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
 const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
@@ -335,6 +337,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
 		NoteCreateService,
 		NoteEditService,
 		NoteDeleteService,
+		LatestNoteService,
 		NotePiningService,
 		NoteReadService,
 		NotificationService,
@@ -481,6 +484,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
 		$NoteCreateService,
 		$NoteEditService,
 		$NoteDeleteService,
+		$LatestNoteService,
 		$NotePiningService,
 		$NoteReadService,
 		$NotificationService,
@@ -628,6 +632,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
 		NoteCreateService,
 		NoteEditService,
 		NoteDeleteService,
+		LatestNoteService,
 		NotePiningService,
 		NoteReadService,
 		NotificationService,
@@ -773,6 +778,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
 		$NoteCreateService,
 		$NoteEditService,
 		$NoteDeleteService,
+		$LatestNoteService,
 		$NotePiningService,
 		$NoteReadService,
 		$NotificationService,
diff --git a/packages/backend/src/core/LatestNoteService.ts b/packages/backend/src/core/LatestNoteService.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c3798055066fd14ae5245eb85c1d27c8f0fd025c
--- /dev/null
+++ b/packages/backend/src/core/LatestNoteService.ts
@@ -0,0 +1,139 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Not } from 'typeorm';
+import { MiNote } from '@/models/Note.js';
+import { isPureRenote } from '@/misc/is-renote.js';
+import { SkLatestNote } from '@/models/LatestNote.js';
+import { DI } from '@/di-symbols.js';
+import type { LatestNotesRepository, NotesRepository } from '@/models/_.js';
+import { LoggerService } from '@/core/LoggerService.js';
+import Logger from '@/logger.js';
+
+@Injectable()
+export class LatestNoteService {
+	private readonly logger: Logger;
+
+	constructor(
+		@Inject(DI.notesRepository)
+		private notesRepository: NotesRepository,
+
+		@Inject(DI.latestNotesRepository)
+		private latestNotesRepository: LatestNotesRepository,
+
+		loggerService: LoggerService,
+	) {
+		this.logger = loggerService.getLogger('LatestNoteService');
+	}
+
+	handleUpdatedNoteBG(before: MiNote, after: MiNote): void {
+		this
+			.handleUpdatedNote(before, after)
+			.catch(err => this.logger.error('Unhandled exception while updating latest_note (after update):', err));
+	}
+
+	async handleUpdatedNote(before: MiNote, after: MiNote): Promise<void> {
+		// If the key didn't change, then there's nothing to update
+		if (SkLatestNote.areEquivalent(before, after)) return;
+
+		// Simulate update as delete + create
+		await this.handleDeletedNote(before);
+		await this.handleCreatedNote(after);
+	}
+
+	handleCreatedNoteBG(note: MiNote): void {
+		this
+			.handleCreatedNote(note)
+			.catch(err => this.logger.error('Unhandled exception while updating latest_note (after create):', err));
+	}
+
+	async handleCreatedNote(note: MiNote): Promise<void> {
+		// Ignore DMs.
+		// Followers-only posts are *included*, as this table is used to back the "following" feed.
+		if (note.visibility === 'specified') return;
+
+		// Ignore pure renotes
+		if (isPureRenote(note)) return;
+
+		// Compute the compound key of the entry to check
+		const key = SkLatestNote.keyFor(note);
+
+		// Make sure that this isn't an *older* post.
+		// We can get older posts through replies, lookups, updates, etc.
+		const currentLatest = await this.latestNotesRepository.findOneBy(key);
+		if (currentLatest != null && currentLatest.noteId >= note.id) return;
+
+		// Record this as the latest note for the given user
+		const latestNote = new SkLatestNote({
+			...key,
+			noteId: note.id,
+		});
+		await this.latestNotesRepository.upsert(latestNote, ['userId', 'isPublic', 'isReply', 'isQuote']);
+	}
+
+	handleDeletedNoteBG(note: MiNote): void {
+		this
+			.handleDeletedNote(note)
+			.catch(err => this.logger.error('Unhandled exception while updating latest_note (after delete):', err));
+	}
+
+	async handleDeletedNote(note: MiNote): Promise<void> {
+		// If it's a DM, then it can't possibly be the latest note so we can safely skip this.
+		if (note.visibility === 'specified') return;
+
+		// If it's a pure renote, then it can't possibly be the latest note so we can safely skip this.
+		if (isPureRenote(note)) return;
+
+		// Compute the compound key of the entry to check
+		const key = SkLatestNote.keyFor(note);
+
+		// Check if the deleted note was possibly the latest for the user
+		const existingLatest = await this.latestNotesRepository.findOneBy(key);
+		if (existingLatest == null || existingLatest.noteId !== note.id) return;
+
+		// Find the newest remaining note for the user.
+		// We exclude DMs and pure renotes.
+		const nextLatest = await this.notesRepository
+			.createQueryBuilder('note')
+			.select()
+			.where({
+				userId: key.userId,
+				visibility: key.isPublic
+					? 'public'
+					: Not('specified'),
+				replyId: key.isReply
+					? Not(null)
+					: null,
+				renoteId: key.isQuote
+					? Not(null)
+					: null,
+			})
+			.andWhere(`
+				(
+					note."renoteId" IS NULL
+					OR note.text IS NOT NULL
+					OR note.cw IS NOT NULL
+					OR note."replyId" IS NOT NULL
+					OR note."hasPoll"
+					OR note."fileIds" != '{}'
+				)
+			`)
+			.orderBy({ id: 'DESC' })
+			.getOne();
+		if (!nextLatest) return;
+
+		// Record it as the latest
+		const latestNote = new SkLatestNote({
+			...key,
+			noteId: nextLatest.id,
+		});
+
+		// When inserting the latest note, it's possible that another worker has "raced" the insert and already added a newer note.
+		// We must use orIgnore() to ensure that the query ignores conflicts, otherwise an exception may be thrown.
+		await this.latestNotesRepository
+			.createQueryBuilder('latest')
+			.insert()
+			.into(SkLatestNote)
+			.values(latestNote)
+			.orIgnore()
+			.execute();
+	}
+}
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index ef0047ca907473827cbadb3402b9b76cc913244c..cd497a7d87d47805d195cfa4871d274854c6fc9a 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -14,8 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
 import { extractHashtags } from '@/misc/extract-hashtags.js';
 import type { IMentionedRemoteUsers } from '@/models/Note.js';
 import { MiNote } from '@/models/Note.js';
-import { LatestNote } from '@/models/LatestNote.js';
-import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, LatestNotesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
 import type { MiDriveFile } from '@/models/DriveFile.js';
 import type { MiApp } from '@/models/App.js';
 import { concat } from '@/misc/prelude/array.js';
@@ -63,7 +62,7 @@ import { isReply } from '@/misc/is-reply.js';
 import { trackPromise } from '@/misc/promise-tracker.js';
 import { isUserRelated } from '@/misc/is-user-related.js';
 import { IdentifiableError } from '@/misc/identifiable-error.js';
-import { isQuote, isRenote } from '@/misc/is-renote.js';
+import { LatestNoteService } from '@/core/LatestNoteService.js';
 
 type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
 
@@ -172,9 +171,6 @@ export class NoteCreateService implements OnApplicationShutdown {
 		@Inject(DI.notesRepository)
 		private notesRepository: NotesRepository,
 
-		@Inject(DI.latestNotesRepository)
-		private latestNotesRepository: LatestNotesRepository,
-
 		@Inject(DI.mutingsRepository)
 		private mutingsRepository: MutingsRepository,
 
@@ -226,6 +222,7 @@ export class NoteCreateService implements OnApplicationShutdown {
 		private utilityService: UtilityService,
 		private userBlockingService: UserBlockingService,
 		private cacheService: CacheService,
+		private latestNoteService: LatestNoteService,
 	) { }
 
 	@bindThis
@@ -531,8 +528,6 @@ export class NoteCreateService implements OnApplicationShutdown {
 				await this.notesRepository.insert(insert);
 			}
 
-			await this.updateLatestNote(insert);
-
 			return insert;
 		} catch (e) {
 			// duplicate key error
@@ -815,6 +810,9 @@ export class NoteCreateService implements OnApplicationShutdown {
 			});
 		}
 
+		// Update the Latest Note index / following feed
+		this.latestNoteService.handleCreatedNoteBG(note);
+
 		// Register to search database
 		if (!user.noindex) this.index(note);
 	}
@@ -1144,25 +1142,4 @@ export class NoteCreateService implements OnApplicationShutdown {
 	public onApplicationShutdown(signal?: string | undefined): void {
 		this.dispose();
 	}
-
-	private async updateLatestNote(note: MiNote) {
-		// Ignore DMs.
-		// Followers-only posts are *included*, as this table is used to back the "following" feed.
-		if (note.visibility === 'specified') return;
-
-		// Ignore pure renotes
-		if (isRenote(note) && !isQuote(note)) return;
-
-		// Make sure that this isn't an *older* post.
-		// We can get older posts through replies, lookups, etc.
-		const currentLatest = await this.latestNotesRepository.findOneBy({ userId: note.userId });
-		if (currentLatest != null && currentLatest.noteId >= note.id) return;
-
-		// Record this as the latest note for the given user
-		const latestNote = new LatestNote({
-			userId: note.userId,
-			noteId: note.id,
-		});
-		await this.latestNotesRepository.upsert(latestNote, ['userId']);
-	}
 }
diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts
index 3f86f41942af231f1422b49ad0618cb4c2261890..bdafad3f0244ab9070e9dc279fc6f3b8f21ea314 100644
--- a/packages/backend/src/core/NoteDeleteService.ts
+++ b/packages/backend/src/core/NoteDeleteService.ts
@@ -3,12 +3,11 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { Brackets, In, Not } from 'typeorm';
+import { Brackets, In } from 'typeorm';
 import { Injectable, Inject } from '@nestjs/common';
 import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
-import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
-import { LatestNote } from '@/models/LatestNote.js';
-import type { InstancesRepository, LatestNotesRepository, NotesRepository, UsersRepository } from '@/models/_.js';
+import { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
+import type { InstancesRepository, NotesRepository, UsersRepository } from '@/models/_.js';
 import { RelayService } from '@/core/RelayService.js';
 import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
 import { DI } from '@/di-symbols.js';
@@ -20,12 +19,12 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
 import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
 import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
 import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
 import { bindThis } from '@/decorators.js';
 import { MetaService } from '@/core/MetaService.js';
 import { SearchService } from '@/core/SearchService.js';
 import { ModerationLogService } from '@/core/ModerationLogService.js';
 import { isQuote, isRenote } from '@/misc/is-renote.js';
+import { LatestNoteService } from '@/core/LatestNoteService.js';
 
 @Injectable()
 export class NoteDeleteService {
@@ -39,14 +38,10 @@ export class NoteDeleteService {
 		@Inject(DI.notesRepository)
 		private notesRepository: NotesRepository,
 
-		@Inject(DI.latestNotesRepository)
-		private latestNotesRepository: LatestNotesRepository,
-
 		@Inject(DI.instancesRepository)
 		private instancesRepository: InstancesRepository,
 
 		private userEntityService: UserEntityService,
-		private noteEntityService: NoteEntityService,
 		private globalEventService: GlobalEventService,
 		private relayService: RelayService,
 		private federatedInstanceService: FederatedInstanceService,
@@ -58,6 +53,7 @@ export class NoteDeleteService {
 		private notesChart: NotesChart,
 		private perUserNotesChart: PerUserNotesChart,
 		private instanceChart: InstanceChart,
+		private latestNoteService: LatestNoteService,
 	) {}
 
 	/**
@@ -152,7 +148,7 @@ export class NoteDeleteService {
 			userId: user.id,
 		});
 
-		await this.updateLatestNote(note);
+		this.latestNoteService.handleDeletedNoteBG(note);
 
 		if (deleter && (note.userId !== deleter.id)) {
 			const user = await this.usersRepository.findOneByOrFail({ id: note.userId });
@@ -235,52 +231,4 @@ export class NoteDeleteService {
 			this.apDeliverManagerService.deliverToUser(user, content, remoteUser);
 		}
 	}
-
-	private async updateLatestNote(note: MiNote) {
-		// If it's a DM, then it can't possibly be the latest note so we can safely skip this.
-		if (note.visibility === 'specified') return;
-
-		// Check if the deleted note was possibly the latest for the user
-		const hasLatestNote = await this.latestNotesRepository.existsBy({ userId: note.userId });
-		if (hasLatestNote) return;
-
-		// Find the newest remaining note for the user.
-		// We exclude DMs and pure renotes.
-		const nextLatest = await this.notesRepository
-			.createQueryBuilder('note')
-			.select()
-			.where({
-				userId: note.userId,
-				visibility: Not('specified'),
-			})
-			.andWhere(`
-				(
-					note."renoteId" IS NULL
-					OR note.text IS NOT NULL
-					OR note.cw IS NOT NULL
-					OR note."replyId" IS NOT NULL
-					OR note."hasPoll"
-					OR note."fileIds" != '{}'
-				)
-			`)
-			.orderBy({ id: 'DESC' })
-			.getOne();
-		if (!nextLatest) return;
-
-		// Record it as the latest
-		const latestNote = new LatestNote({
-			userId: note.userId,
-			noteId: nextLatest.id,
-		});
-
-		// When inserting the latest note, it's possible that another worker has "raced" the insert and already added a newer note.
-		// We must use orIgnore() to ensure that the query ignores conflicts, otherwise an exception may be thrown.
-		await this.latestNotesRepository
-			.createQueryBuilder('latest')
-			.insert()
-			.into(LatestNote)
-			.values(latestNote)
-			.orIgnore()
-			.execute();
-	}
 }
diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts
index aecd37aeb9c684d8fad4ff9626198fd75259c288..58114a0e0cbbfbd0cc17cf698f45e503b65782c6 100644
--- a/packages/backend/src/core/NoteEditService.ts
+++ b/packages/backend/src/core/NoteEditService.ts
@@ -52,6 +52,7 @@ import { isReply } from '@/misc/is-reply.js';
 import { trackPromise } from '@/misc/promise-tracker.js';
 import { isUserRelated } from '@/misc/is-user-related.js';
 import { IdentifiableError } from '@/misc/identifiable-error.js';
+import { LatestNoteService } from '@/core/LatestNoteService.js';
 
 type NotificationType = 'reply' | 'renote' | 'quote' | 'mention' | 'edited';
 
@@ -214,6 +215,7 @@ export class NoteEditService implements OnApplicationShutdown {
 		private utilityService: UtilityService,
 		private userBlockingService: UserBlockingService,
 		private cacheService: CacheService,
+		private latestNoteService: LatestNoteService,
 	) { }
 
 	@bindThis
@@ -558,7 +560,7 @@ export class NoteEditService implements OnApplicationShutdown {
 			}
 
 			setImmediate('post edited', { signal: this.#shutdownController.signal }).then(
-				() => this.postNoteEdited(note, user, data, silent, tags!, mentionedUsers!),
+				() => this.postNoteEdited(note, oldnote, user, data, silent, tags!, mentionedUsers!),
 				() => { /* aborted, ignore this */ },
 			);
 
@@ -569,7 +571,7 @@ export class NoteEditService implements OnApplicationShutdown {
 	}
 
 	@bindThis
-	private async postNoteEdited(note: MiNote, user: {
+	private async postNoteEdited(note: MiNote, oldNote: MiNote, user: {
 		id: MiUser['id'];
 		username: MiUser['username'];
 		host: MiUser['host'];
@@ -766,6 +768,9 @@ export class NoteEditService implements OnApplicationShutdown {
 			});
 		}
 
+		// Update the Latest Note index / following feed
+		this.latestNoteService.handleUpdatedNoteBG(oldNote, note);
+
 		// Register to search database
 		if (!user.noindex) this.index(note);
 	}
diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts
index 062af397321e55e5eafd389250165627f087991d..2cb558dbff8792ce8d8c4528fb5d1d662a029d50 100644
--- a/packages/backend/src/core/activitypub/ApDbResolverService.ts
+++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts
@@ -59,7 +59,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
 	}
 
 	@bindThis
-	public parseUri(value: string | IObject): UriParseResult {
+	public parseUri(value: string | IObject | [string | IObject]): UriParseResult {
 		const separator = '/';
 
 		const uri = new URL(getApId(value));
@@ -78,7 +78,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
 	 * AP Note => Misskey Note in DB
 	 */
 	@bindThis
-	public async getNoteFromApId(value: string | IObject): Promise<MiNote | null> {
+	public async getNoteFromApId(value: string | IObject | [string | IObject]): Promise<MiNote | null> {
 		const parsed = this.parseUri(value);
 
 		if (parsed.local) {
@@ -98,7 +98,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
 	 * AP Person => Misskey User in DB
 	 */
 	@bindThis
-	public async getUserFromApId(value: string | IObject): Promise<MiLocalUser | MiRemoteUser | null> {
+	public async getUserFromApId(value: string | IObject | [string | IObject]): Promise<MiLocalUser | MiRemoteUser | null> {
 		const parsed = this.parseUri(value);
 
 		if (parsed.local) {
diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts
index bce67a458f21509d98f2fecec40e7f53295cb0ee..fd4b3d8d6f82f6e966cf15bf1244cf0481ab44b5 100644
--- a/packages/backend/src/core/activitypub/ApInboxService.ts
+++ b/packages/backend/src/core/activitypub/ApInboxService.ts
@@ -41,6 +41,7 @@ import { ApPersonService } from './models/ApPersonService.js';
 import { ApQuestionService } from './models/ApQuestionService.js';
 import type { Resolver } from './ApResolverService.js';
 import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost } from './type.js';
+import { fromTuple } from '@/misc/from-tuple.js';
 
 @Injectable()
 export class ApInboxService {
@@ -253,7 +254,8 @@ export class ApInboxService {
 		}
 
 		if (activity.target === actor.featured) {
-			const note = await this.apNoteService.resolveNote(activity.object);
+			const object = fromTuple(activity.object);
+			const note = await this.apNoteService.resolveNote(object);
 			if (note == null) return 'note not found';
 			await this.notePiningService.addPinned(actor, note.id);
 			return;
@@ -270,11 +272,12 @@ export class ApInboxService {
 
 		const resolver = this.apResolverService.createResolver();
 
-		if (!activity.object) return 'skip: activity has no object property';
-		const targetUri = getApId(activity.object);
+		const activityObject = fromTuple(activity.object);
+		if (!activityObject) return 'skip: activity has no object property';
+		const targetUri = getApId(activityObject);
 		if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.';
 
-		const target = await resolver.resolve(activity.object).catch(e => {
+		const target = await resolver.resolve(activityObject).catch(e => {
 			this.logger.error(`Resolution failed: ${e}`);
 			return e;
 		});
@@ -370,29 +373,30 @@ export class ApInboxService {
 
 		this.logger.info(`Create: ${uri}`);
 
-		if (!activity.object) return 'skip: activity has no object property';
-		const targetUri = getApId(activity.object);
+		const activityObject = fromTuple(activity.object);
+		if (!activityObject) return 'skip: activity has no object property';
+		const targetUri = getApId(activityObject);
 		if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.';
 
 		// copy audiences between activity <=> object.
-		if (typeof activity.object === 'object') {
-			const to = unique(concat([toArray(activity.to), toArray(activity.object.to)]));
-			const cc = unique(concat([toArray(activity.cc), toArray(activity.object.cc)]));
+		if (typeof activityObject === 'object') {
+			const to = unique(concat([toArray(activity.to), toArray(activityObject.to)]));
+			const cc = unique(concat([toArray(activity.cc), toArray(activityObject.cc)]));
 
 			activity.to = to;
 			activity.cc = cc;
-			activity.object.to = to;
-			activity.object.cc = cc;
+			activityObject.to = to;
+			activityObject.cc = cc;
 		}
 
 		// If there is no attributedTo, use Activity actor.
-		if (typeof activity.object === 'object' && !activity.object.attributedTo) {
-			activity.object.attributedTo = activity.actor;
+		if (typeof activityObject === 'object' && !activityObject.attributedTo) {
+			activityObject.attributedTo = activity.actor;
 		}
 
 		const resolver = this.apResolverService.createResolver();
 
-		const object = await resolver.resolve(activity.object).catch(e => {
+		const object = await resolver.resolve(activityObject).catch(e => {
 			this.logger.error(`Resolution failed: ${e}`);
 			throw e;
 		});
@@ -448,15 +452,15 @@ export class ApInboxService {
 		// 削除対象objectのtype
 		let formerType: string | undefined;
 
-		if (typeof activity.object === 'string') {
+		const activityObject = fromTuple(activity.object);
+		if (typeof activityObject === 'string') {
 			// typeが不明だけど、どうせ消えてるのでremote resolveしない
 			formerType = undefined;
 		} else {
-			const object = activity.object;
-			if (isTombstone(object)) {
-				formerType = toSingle(object.formerType);
+			if (isTombstone(activityObject)) {
+				formerType = toSingle(activityObject.formerType);
 			} else {
-				formerType = toSingle(object.type);
+				formerType = toSingle(activityObject.type);
 			}
 		}
 
@@ -616,7 +620,8 @@ export class ApInboxService {
 		}
 
 		if (activity.target === actor.featured) {
-			const note = await this.apNoteService.resolveNote(activity.object);
+			const activityObject = fromTuple(activity.object);
+			const note = await this.apNoteService.resolveNote(activityObject);
 			if (note == null) return 'note not found';
 			await this.notePiningService.removePinned(actor, note.id);
 			return;
diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts
index 499a163d6c4392bc7c56bf124bcb66a56306b1b2..ff6f462b4c6526e6952db1bc950e86dc0051706e 100644
--- a/packages/backend/src/core/activitypub/ApRendererService.ts
+++ b/packages/backend/src/core/activitypub/ApRendererService.ts
@@ -199,7 +199,8 @@ export class ApRendererService {
 			type: 'Flag',
 			actor: this.userEntityService.genLocalUserUri(user.id),
 			content,
-			object,
+			// This MUST be an array for Pleroma compatibility: https://activitypub.software/TransFem-org/Sharkey/-/issues/641#note_7301
+			object: [object],
 		};
 	}
 
diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts
index b047a6c59ba581cc22e528f7ac0f6755de2f1e83..fd69c7269beadd0cea83dd1cb42daaefde386bfa 100644
--- a/packages/backend/src/core/activitypub/ApResolverService.ts
+++ b/packages/backend/src/core/activitypub/ApResolverService.ts
@@ -21,6 +21,7 @@ import { ApDbResolverService } from './ApDbResolverService.js';
 import { ApRendererService } from './ApRendererService.js';
 import { ApRequestService } from './ApRequestService.js';
 import type { IObject, ICollection, IOrderedCollection } from './type.js';
+import { fromTuple } from '@/misc/from-tuple.js';
 
 export class Resolver {
 	private history: Set<string>;
@@ -67,7 +68,10 @@ export class Resolver {
 	}
 
 	@bindThis
-	public async resolve(value: string | IObject): Promise<IObject> {
+	public async resolve(value: string | IObject | [string | IObject]): Promise<IObject> {
+		// eslint-disable-next-line no-param-reassign
+		value = fromTuple(value);
+
 		if (typeof value !== 'string') {
 			return value;
 		}
diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts
index 2f58825de1a36819a7de1c3e6e6b4acf40651e08..144793c2146142f75f8fc51a1a758e915454f19e 100644
--- a/packages/backend/src/core/activitypub/type.ts
+++ b/packages/backend/src/core/activitypub/type.ts
@@ -3,6 +3,8 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
+import { fromTuple } from '@/misc/from-tuple.js';
+
 export type Obj = { [x: string]: any };
 export type ApObject = IObject | string | (IObject | string)[];
 
@@ -52,10 +54,13 @@ export function getOneApId(value: ApObject): string {
 /**
  * Get ActivityStreams Object id
  */
-export function getApId(value: string | IObject): string {
+export function getApId(value: string | IObject | [string | IObject]): string {
+	// eslint-disable-next-line no-param-reassign
+	value = fromTuple(value);
+
 	if (typeof value === 'string') return value;
 	if (typeof value.id === 'string') return value.id;
-	throw new Error('cannot detemine id');
+	throw new Error('cannot determine id');
 }
 
 /**
@@ -84,7 +89,9 @@ export function getApHrefNullable(value: string | IObject | undefined): string |
 export interface IActivity extends IObject {
 	//type: 'Activity';
 	actor: IObject | string;
-	object: IObject | string;
+	// ActivityPub spec allows for arrays: https://www.w3.org/TR/activitystreams-vocabulary/#properties
+	// Misskey can only handle one value, so we use a tuple for that case.
+	object: IObject | string | [IObject | string] ;
 	target?: IObject | string;
 	/** LD-Signature */
 	signature?: {
diff --git a/packages/backend/src/misc/from-tuple.ts b/packages/backend/src/misc/from-tuple.ts
new file mode 100644
index 0000000000000000000000000000000000000000..366b1e310fa45f1288a5a4cce5979940af456a1e
--- /dev/null
+++ b/packages/backend/src/misc/from-tuple.ts
@@ -0,0 +1,7 @@
+export function fromTuple<T>(value: T | [T]): T {
+	if (Array.isArray(value)) {
+		return value[0];
+	}
+
+	return value;
+}
diff --git a/packages/backend/src/misc/is-renote.ts b/packages/backend/src/misc/is-renote.ts
index 48f821806c1a55cf4870fbfc2802cfa3d90b1841..c128fded143efeeb703c0c6d75641c68087ec141 100644
--- a/packages/backend/src/misc/is-renote.ts
+++ b/packages/backend/src/misc/is-renote.ts
@@ -23,6 +23,17 @@ type Quote =
 		hasPoll: true
 	});
 
+type PureRenote =
+	Renote & {
+		text: null,
+		cw: null,
+		replyId: null,
+		hasPoll: false,
+		fileIds: {
+			length: 0,
+		},
+	};
+
 export function isRenote(note: MiNote): note is Renote {
 	return note.renoteId != null;
 }
@@ -36,6 +47,10 @@ export function isQuote(note: Renote): note is Quote {
 		note.fileIds.length > 0;
 }
 
+export function isPureRenote(note: MiNote): note is PureRenote {
+	return isRenote(note) && !isQuote(note);
+}
+
 type PackedRenote =
 	Packed<'Note'> & {
 		renoteId: NonNullable<Packed<'Note'>['renoteId']>
diff --git a/packages/backend/src/models/LatestNote.ts b/packages/backend/src/models/LatestNote.ts
index 1163ff3bc0d93f315c8bbc693208438fbf8c2cd0..064fcccc0a469ae3d0ac7099b1a0cb5e2626ef2f 100644
--- a/packages/backend/src/models/LatestNote.ts
+++ b/packages/backend/src/models/LatestNote.ts
@@ -6,6 +6,7 @@
 import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne } from 'typeorm';
 import { MiUser } from '@/models/User.js';
 import { MiNote } from '@/models/Note.js';
+import { isQuote, isRenote } from '@/misc/is-renote.js';
 
 /**
  * Maps a user to the most recent post by that user.
@@ -13,7 +14,7 @@ import { MiNote } from '@/models/Note.js';
  * DMs are not counted.
  */
 @Entity('latest_note')
-export class LatestNote {
+export class SkLatestNote {
 	@PrimaryColumn({
 		name: 'user_id',
 		type: 'varchar' as const,
@@ -21,6 +22,24 @@ export class LatestNote {
 	})
 	public userId: string;
 
+	@PrimaryColumn('boolean', {
+		name: 'is_public',
+		default: false,
+	})
+	public isPublic: boolean;
+
+	@PrimaryColumn('boolean', {
+		name: 'is_reply',
+		default: false,
+	})
+	public isReply: boolean;
+
+	@PrimaryColumn('boolean', {
+		name: 'is_quote',
+		default: false,
+	})
+	public isQuote: boolean;
+
 	@ManyToOne(() => MiUser, {
 		onDelete: 'CASCADE',
 	})
@@ -44,11 +63,38 @@ export class LatestNote {
 	})
 	public note: MiNote | null;
 
-	constructor(data?: Partial<LatestNote>) {
+	constructor(data?: Partial<SkLatestNote>) {
 		if (!data) return;
 
 		for (const [k, v] of Object.entries(data)) {
 			(this as Record<string, unknown>)[k] = v;
 		}
 	}
+
+	/**
+	 * Generates a compound key matching a provided note.
+	 */
+	static keyFor(note: MiNote) {
+		return {
+			userId: note.userId,
+			isPublic: note.visibility === 'public',
+			isReply: note.replyId != null,
+			isQuote: isRenote(note) && isQuote(note),
+		};
+	}
+
+	/**
+	 * Checks if two notes would produce equivalent compound keys.
+	 */
+	static areEquivalent(first: MiNote, second: MiNote): boolean {
+		const firstKey = SkLatestNote.keyFor(first);
+		const secondKey = SkLatestNote.keyFor(second);
+
+		return (
+			firstKey.userId === secondKey.userId &&
+			firstKey.isPublic === secondKey.isPublic &&
+			firstKey.isReply === secondKey.isReply &&
+			firstKey.isQuote === secondKey.isQuote
+		);
+	}
 }
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index f44334d84eb0cee533ddc66b34fb34b192ef09e2..eb45b9a6311e9c1565b724143c79a30384b92df7 100644
--- a/packages/backend/src/models/RepositoryModule.ts
+++ b/packages/backend/src/models/RepositoryModule.ts
@@ -7,7 +7,7 @@ import type { Provider } from '@nestjs/common';
 import { Module } from '@nestjs/common';
 import { DI } from '@/di-symbols.js';
 import {
-	LatestNote,
+	SkLatestNote,
 	MiAbuseReportNotificationRecipient,
 	MiAbuseUserReport,
 	MiAccessToken,
@@ -121,7 +121,7 @@ const $avatarDecorationsRepository: Provider = {
 
 const $latestNotesRepository: Provider = {
 	provide: DI.latestNotesRepository,
-	useFactory: (db: DataSource) => db.getRepository(LatestNote).extend(miRepository as MiRepository<LatestNote>),
+	useFactory: (db: DataSource) => db.getRepository(SkLatestNote).extend(miRepository as MiRepository<SkLatestNote>),
 	inject: [DI.db],
 };
 
diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts
index 9e01f4b6d7238cf1b2745c8916ac874e7f0713c7..ac2dd62aa29ae233bb44b18cf1e4b058f5a2a064 100644
--- a/packages/backend/src/models/_.ts
+++ b/packages/backend/src/models/_.ts
@@ -10,7 +10,7 @@ import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLo
 import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js';
 import { ObjectUtils } from 'typeorm/util/ObjectUtils.js';
 import { OrmUtils } from 'typeorm/util/OrmUtils.js';
-import { LatestNote } from '@/models/LatestNote.js';
+import { SkLatestNote } from '@/models/LatestNote.js';
 import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
 import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
 import { MiAccessToken } from '@/models/AccessToken.js';
@@ -127,7 +127,7 @@ export const miRepository = {
 } satisfies MiRepository<ObjectLiteral>;
 
 export {
-	LatestNote,
+	SkLatestNote,
 	MiAbuseUserReport,
 	MiAbuseReportNotificationRecipient,
 	MiAccessToken,
@@ -226,7 +226,7 @@ export type GalleryPostsRepository = Repository<MiGalleryPost> & MiRepository<Mi
 export type HashtagsRepository = Repository<MiHashtag> & MiRepository<MiHashtag>;
 export type InstancesRepository = Repository<MiInstance> & MiRepository<MiInstance>;
 export type MetasRepository = Repository<MiMeta> & MiRepository<MiMeta>;
-export type LatestNotesRepository = Repository<LatestNote> & MiRepository<LatestNote>;
+export type LatestNotesRepository = Repository<SkLatestNote> & MiRepository<SkLatestNote>;
 export type ModerationLogsRepository = Repository<MiModerationLog> & MiRepository<MiModerationLog>;
 export type MutingsRepository = Repository<MiMuting> & MiRepository<MiMuting>;
 export type RenoteMutingsRepository = Repository<MiRenoteMuting> & MiRepository<MiRenoteMuting>;
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index 0d17b3d0465c9aa12494df9a5f888a911c0bc025..2d66e6e4454cdef2debc54d9138dbb9260893b26 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -83,7 +83,7 @@ import { MiReversiGame } from '@/models/ReversiGame.js';
 import { Config } from '@/config.js';
 import MisskeyLogger from '@/logger.js';
 import { bindThis } from '@/decorators.js';
-import { LatestNote } from '@/models/LatestNote.js';
+import { SkLatestNote } from '@/models/LatestNote.js';
 
 pg.types.setTypeParser(20, Number);
 
@@ -131,7 +131,7 @@ class MyCustomLogger implements Logger {
 }
 
 export const entities = [
-	LatestNote,
+	SkLatestNote,
 	MiAnnouncement,
 	MiAnnouncementRead,
 	MiMeta,
diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts
index 436160f250aec07632a225ee986996778e139ab3..83e8f404e9cc107ff616228bac29e35a1a188344 100644
--- a/packages/backend/src/server/api/endpoints/notes/following.ts
+++ b/packages/backend/src/server/api/endpoints/notes/following.ts
@@ -4,7 +4,7 @@
  */
 
 import { Inject, Injectable } from '@nestjs/common';
-import { LatestNote, MiFollowing } from '@/models/_.js';
+import { SkLatestNote, MiFollowing } from '@/models/_.js';
 import type { NotesRepository } from '@/models/_.js';
 import { Endpoint } from '@/server/api/endpoint-base.js';
 import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
@@ -33,6 +33,12 @@ export const paramDef = {
 	type: 'object',
 	properties: {
 		mutualsOnly: { type: 'boolean', default: false },
+		filesOnly: { type: 'boolean', default: false },
+		includeNonPublic: { type: 'boolean', default: false },
+		includeReplies: { type: 'boolean', default: false },
+		includeQuotes: { type: 'boolean', default: false },
+		includeBots: { type: 'boolean', default: true },
+
 		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
 		sinceId: { type: 'string', format: 'misskey:id' },
 		untilId: { type: 'string', format: 'misskey:id' },
@@ -52,12 +58,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 		private queryService: QueryService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
-			let query = this.notesRepository
+			const query = this.notesRepository
 				.createQueryBuilder('note')
 				.setParameter('me', me.id)
 
 				// Limit to latest notes
-				.innerJoin(LatestNote, 'latest', 'note.id = latest.note_id')
+				.innerJoin(SkLatestNote, 'latest', 'note.id = latest.note_id')
 
 				// Avoid N+1 queries from the "pack" method
 				.innerJoinAndSelect('note.user', 'user')
@@ -73,8 +79,28 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 
 			// Limit to mutuals, if requested
 			if (ps.mutualsOnly) {
-				query = query
-					.innerJoin(MiFollowing, 'mutuals', 'latest.user_id = mutuals."followerId" AND mutuals."followeeId" = :me');
+				query.innerJoin(MiFollowing, 'mutuals', 'latest.user_id = mutuals."followerId" AND mutuals."followeeId" = :me');
+			}
+
+			// Limit to files, if requested
+			if (ps.filesOnly) {
+				query.andWhere('note."fileIds" != \'{}\'');
+			}
+
+			// Match selected note types.
+			if (!ps.includeNonPublic) {
+				query.andWhere('latest.is_public');
+			}
+			if (!ps.includeReplies) {
+				query.andWhere('latest.is_reply = false');
+			}
+			if (!ps.includeQuotes) {
+				query.andWhere('latest.is_quote = false');
+			}
+
+			// Match selected user types.
+			if (!ps.includeBots) {
+				query.andWhere('"user"."isBot" = false');
 			}
 
 			// Respect blocks and mutes
@@ -82,7 +108,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			this.queryService.generateMutedUserQuery(query, me);
 
 			// Support pagination
-			query = this.queryService
+			this.queryService
 				.makePaginationQuery(query, ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
 				.orderBy('note.id', 'DESC')
 				.take(ps.limit);
diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts
index cc76c12f1d897e3eb3ab3aa7188f6aee2af90474..efea15ca80829649d9b6b72341758eaa3ae87c7d 100644
--- a/packages/backend/src/server/api/endpoints/users/notes.ts
+++ b/packages/backend/src/server/api/endpoints/users/notes.ts
@@ -17,6 +17,7 @@ import { MiLocalUser } from '@/models/User.js';
 import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
 import { FanoutTimelineName } from '@/core/FanoutTimelineService.js';
 import { ApiError } from '@/server/api/error.js';
+import { isQuote, isRenote } from '@/misc/is-renote.js';
 
 export const meta = {
 	tags: ['users', 'notes'],
@@ -51,7 +52,11 @@ export const paramDef = {
 	properties: {
 		userId: { type: 'string', format: 'misskey:id' },
 		withReplies: { type: 'boolean', default: false },
+		withRepliesToSelf: { type: 'boolean', default: true },
+		withQuotes: { type: 'boolean', default: true },
 		withRenotes: { type: 'boolean', default: true },
+		withBots: { type: 'boolean', default: true },
+		withNonPublic: { type: 'boolean', default: true },
 		withChannelNotes: { type: 'boolean', default: false },
 		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
 		sinceId: { type: 'string', format: 'misskey:id' },
@@ -103,6 +108,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 					withChannelNotes: ps.withChannelNotes,
 					withFiles: ps.withFiles,
 					withRenotes: ps.withRenotes,
+					withQuotes: ps.withQuotes,
+					withBots: ps.withBots,
+					withNonPublic: ps.withNonPublic,
+					withRepliesToOthers: ps.withReplies,
+					withRepliesToSelf: ps.withRepliesToSelf,
 				}, me);
 
 				return await this.noteEntityService.packMany(timeline, me);
@@ -127,11 +137,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies
 				excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files
 				excludePureRenotes: !ps.withRenotes,
+				excludeBots: !ps.withBots,
 				noteFilter: note => {
 					if (note.channel?.isSensitive && !isSelf) return false;
 					if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false;
 					if (note.visibility === 'followers' && !isFollowing && !isSelf) return false;
 
+					// These are handled by DB fallback, but we duplicate them here in case a timeline was already populated with notes
+					if (!ps.withRepliesToSelf && note.reply?.userId === note.userId) return false;
+					if (!ps.withQuotes && isRenote(note) && isQuote(note)) return false;
+					if (!ps.withNonPublic && note.visibility !== 'public') return false;
+
 					return true;
 				},
 				dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
@@ -142,6 +158,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 					withChannelNotes: ps.withChannelNotes,
 					withFiles: ps.withFiles,
 					withRenotes: ps.withRenotes,
+					withQuotes: ps.withQuotes,
+					withBots: ps.withBots,
+					withNonPublic: ps.withNonPublic,
+					withRepliesToOthers: ps.withReplies,
+					withRepliesToSelf: ps.withRepliesToSelf,
 				}, me),
 			});
 
@@ -157,6 +178,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 		withChannelNotes: boolean,
 		withFiles: boolean,
 		withRenotes: boolean,
+		withQuotes: boolean,
+		withBots: boolean,
+		withNonPublic: boolean,
+		withRepliesToOthers: boolean,
+		withRepliesToSelf: boolean,
 	}, me: MiLocalUser | null) {
 		const isSelf = me && (me.id === ps.userId);
 
@@ -188,7 +214,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			query.andWhere('note.fileIds != \'{}\'');
 		}
 
-		if (ps.withRenotes === false) {
+		if (!ps.withRenotes && !ps.withQuotes) {
+			query.andWhere('note.renoteId IS NULL');
+		} else if (!ps.withRenotes) {
 			query.andWhere(new Brackets(qb => {
 				qb.orWhere('note.userId != :userId', { userId: ps.userId });
 				qb.orWhere('note.renoteId IS NULL');
@@ -196,6 +224,35 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				qb.orWhere('note.fileIds != \'{}\'');
 				qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
 			}));
+		} else if (!ps.withQuotes) {
+			query.andWhere(`
+				(
+					note."renoteId" IS NULL
+					OR (
+						note.text IS NULL
+						AND note.cw IS NULL
+						AND note."replyId" IS NULL
+						AND note."hasPoll" IS FALSE
+						AND note."fileIds" = '{}'
+					)
+				)
+			`);
+		}
+
+		if (!ps.withRepliesToOthers && !ps.withRepliesToSelf) {
+			query.andWhere('reply.id IS NULL');
+		} else if (!ps.withRepliesToOthers) {
+			query.andWhere('(reply.id IS NULL OR reply."userId" = note."userId")');
+		} else if (!ps.withRepliesToSelf) {
+			query.andWhere('(reply.id IS NULL OR reply."userId" != note."userId")');
+		}
+
+		if (!ps.withNonPublic) {
+			query.andWhere('note.visibility = \'public\'');
+		}
+
+		if (!ps.withBots) {
+			query.andWhere('"user"."isBot" = false');
 		}
 
 		return await query.limit(ps.limit).getMany();
diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js
index 38e37ce093f46978a32ad656adf388bffdd06722..193bfa9585c61d669ab10e9b92ebfd4268ed81e7 100644
--- a/packages/backend/src/server/web/boot.js
+++ b/packages/backend/src/server/web/boot.js
@@ -33,8 +33,17 @@
 		return;
 	}
 
+	// Force update when locales change
+	const langsVersion = LANGS_VERSION;
+	const localeVersion = localStorage.getItem('localeVersion');
+	if (localeVersion !== langsVersion) {
+		console.info(`Updating locales from version ${localeVersion ?? 'N/A'} to ${langsVersion}`);
+		localStorage.removeItem('localeVersion');
+		localStorage.removeItem('locale');
+	}
+
 	//#region Detect language & fetch translations
-	if (!localStorage.hasOwnProperty('locale')) {
+	if (!localStorage.getItem('locale')) {
 		const supportedLangs = LANGS;
 		let lang = localStorage.getItem('lang');
 		if (lang == null || !supportedLangs.includes(lang)) {
@@ -48,37 +57,17 @@
 			}
 		}
 
-		const metaRes = await window.fetch('/api/meta', {
-			method: 'POST',
-			body: JSON.stringify({}),
-			credentials: 'omit',
-			cache: 'no-cache',
-			headers: {
-				'Content-Type': 'application/json',
-			},
-		});
-		if (metaRes.status !== 200) {
-			renderError('META_FETCH');
-			return;
-		}
-		const meta = await metaRes.json();
-		const v = meta.version;
-		if (v == null) {
-			renderError('META_FETCH_V');
-			return;
-		}
-
 		// for https://github.com/misskey-dev/misskey/issues/10202
 		if (lang == null || lang.toString == null || lang.toString() === 'null') {
 			console.error('invalid lang value detected!!!', typeof lang, lang);
 			lang = 'en-US';
 		}
 
-		const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
+		const localRes = await window.fetch(`/assets/locales/${lang}.${langsVersion}.json`);
 		if (localRes.status === 200) {
 			localStorage.setItem('lang', lang);
 			localStorage.setItem('locale', await localRes.text());
-			localStorage.setItem('localeVersion', v);
+			localStorage.setItem('localeVersion', langsVersion);
 		} else {
 			renderError('LOCALE_FETCH');
 			return;
diff --git a/packages/backend/test/unit/misc/from-tuple.ts b/packages/backend/test/unit/misc/from-tuple.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b523cb5782979a03e09d80f7d4205896617470ab
--- /dev/null
+++ b/packages/backend/test/unit/misc/from-tuple.ts
@@ -0,0 +1,13 @@
+import { fromTuple } from '@/misc/from-tuple.js';
+
+describe(fromTuple, () => {
+	it('should return value when value is not an array', () => {
+		const value = fromTuple('abc');
+		expect(value).toBe('abc');
+	});
+
+	it('should return first element when value is an array', () => {
+		const value = fromTuple(['abc']);
+		expect(value).toBe('abc');
+	});
+});
diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts
index 080271e404164b1a74b047ee8a573b6e9aa13b04..1baa995f592b747999624ef091908cf489eddd4d 100644
--- a/packages/backend/test/unit/misc/is-renote.ts
+++ b/packages/backend/test/unit/misc/is-renote.ts
@@ -3,7 +3,7 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { isQuote, isRenote } from '@/misc/is-renote.js';
+import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js';
 import { MiNote } from '@/models/Note.js';
 
 const base: MiNote = {
@@ -86,4 +86,24 @@ describe('misc:is-renote', () => {
 		expect(isRenote(note)).toBe(true);
 		expect(isQuote(note as any)).toBe(true);
 	});
+
+	describe('isPureRenote', () => {
+		it('should return true when note is pure renote', () => {
+			const note = new MiNote({ renoteId: 'abc123', fileIds: [] });
+			const result = isPureRenote(note);
+			expect(result).toBeTruthy();
+		});
+
+		it('should return false when note is quote', () => {
+			const note = new MiNote({ renoteId: 'abc123', text: 'text', fileIds: [] });
+			const result = isPureRenote(note);
+			expect(result).toBeFalsy();
+		});
+
+		it('should return false when note is not renote', () => {
+			const note = new MiNote({ renoteId: null, fileIds: [] });
+			const result = isPureRenote(note);
+			expect(result).toBeFalsy();
+		});
+	});
 });
diff --git a/packages/backend/test/unit/models/LatestNote.ts b/packages/backend/test/unit/models/LatestNote.ts
new file mode 100644
index 0000000000000000000000000000000000000000..129094ceff3255fc6ed9e67b466eec7ba5c94e4d
--- /dev/null
+++ b/packages/backend/test/unit/models/LatestNote.ts
@@ -0,0 +1,149 @@
+import { SkLatestNote } from '@/models/LatestNote.js';
+import { MiNote } from '@/models/Note.js';
+
+describe(SkLatestNote, () => {
+	describe('keyFor', () => {
+		it('should include userId', () => {
+			const note = new MiNote({ userId: 'abc123', fileIds: [] });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.userId).toBe(note.userId);
+		});
+
+		it('should include isPublic when is public', () => {
+			const note = new MiNote({ visibility: 'public', fileIds: [] });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isPublic).toBeTruthy();
+		});
+
+		it('should include isPublic when is home-only', () => {
+			const note = new MiNote({ visibility: 'home', fileIds: [] });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isPublic).toBeFalsy();
+		});
+
+		it('should include isPublic when is followers-only', () => {
+			const note = new MiNote({ visibility: 'followers', fileIds: [] });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isPublic).toBeFalsy();
+		});
+
+		it('should include isPublic when is specified', () => {
+			const note = new MiNote({ visibility: 'specified', fileIds: [] });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isPublic).toBeFalsy();
+		});
+
+		it('should include isReply when is reply', () => {
+			const note = new MiNote({ replyId: 'abc123', fileIds: [] });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isReply).toBeTruthy();
+		});
+
+		it('should include isReply when is not reply', () => {
+			const note = new MiNote({ replyId: null, fileIds: [] });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isReply).toBeFalsy();
+		});
+
+		it('should include isQuote when is quote', () => {
+			const note = new MiNote({ renoteId: 'abc123', text: 'text', fileIds: [] });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isQuote).toBeTruthy();
+		});
+
+		it('should include isQuote when is reblog', () => {
+			const note = new MiNote({ renoteId: 'abc123', fileIds: [] });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isQuote).toBeFalsy();
+		});
+
+		it('should include isQuote when is neither quote nor reblog', () => {
+			const note = new MiNote({ renoteId: null, fileIds: [] });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isQuote).toBeFalsy();
+		});
+	});
+
+	describe('areEquivalent', () => {
+		it('should return true when keys match', () => {
+			const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'public', replyId: null, renoteId: null, fileIds: [] });
+			const second = new MiNote({ id: '2', userId: 'abc123', visibility: 'public', replyId: null, renoteId: null, fileIds: [] });
+
+			const result = SkLatestNote.areEquivalent(first, second);
+
+			expect(result).toBeTruthy();
+		});
+
+		it('should return true when keys match with different reply IDs', () => {
+			const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'public', replyId: '3', renoteId: null, fileIds: [] });
+			const second = new MiNote({ id: '2', userId: 'abc123', visibility: 'public', replyId: '4', renoteId: null, fileIds: [] });
+
+			const result = SkLatestNote.areEquivalent(first, second);
+
+			expect(result).toBeTruthy();
+		});
+
+		it('should return true when keys match with different renote IDs', () => {
+			const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'public', replyId: null, renoteId: '3', fileIds: ['1'] });
+			const second = new MiNote({ id: '2', userId: 'abc123', visibility: 'public', replyId: null, renoteId: '4', fileIds: ['1'] });
+
+			const result = SkLatestNote.areEquivalent(first, second);
+
+			expect(result).toBeTruthy();
+		});
+
+		it('should return true when keys match with different file counts', () => {
+			const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'public', replyId: null, renoteId: null, fileIds: ['1'] });
+			const second = new MiNote({ id: '2', userId: 'abc123', visibility: 'public', replyId: null, renoteId: null, fileIds: ['1','2'] });
+
+			const result = SkLatestNote.areEquivalent(first, second);
+
+			expect(result).toBeTruthy();
+		});
+
+		it('should return true when keys match with different private visibilities', () => {
+			const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'home', replyId: null, renoteId: null, fileIds: [] });
+			const second = new MiNote({ id: '2', userId: 'abc123', visibility: 'followers', replyId: null, renoteId: null, fileIds: [] });
+
+			const result = SkLatestNote.areEquivalent(first, second);
+
+			expect(result).toBeTruthy();
+		});
+
+		it('should return false when user ID differs', () => {
+			const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'public', replyId: null, renoteId: null, fileIds: [] });
+			const second = new MiNote({ id: '2', userId: 'def456', visibility: 'public', replyId: null, renoteId: null, fileIds: [] });
+
+			const result = SkLatestNote.areEquivalent(first, second);
+
+			expect(result).toBeFalsy();
+		});
+
+		it('should return false when visibility differs', () => {
+			const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'public', replyId: null, renoteId: null, fileIds: [] });
+			const second = new MiNote({ id: '2', userId: 'abc123', visibility: 'home', replyId: null, renoteId: null, fileIds: [] });
+
+			const result = SkLatestNote.areEquivalent(first, second);
+
+			expect(result).toBeFalsy();
+		});
+
+		it('should return false when reply differs', () => {
+			const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'public', replyId: '1', renoteId: null, fileIds: [] });
+			const second = new MiNote({ id: '2', userId: 'abc123', visibility: 'public', replyId: null, renoteId: null, fileIds: [] });
+
+			const result = SkLatestNote.areEquivalent(first, second);
+
+			expect(result).toBeFalsy();
+		});
+
+		it('should return false when quote differs', () => {
+			const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'public', replyId: null, renoteId: '3', fileIds: ['1'] });
+			const second = new MiNote({ id: '2', userId: 'abc123', visibility: 'public', replyId: null, renoteId: null, fileIds: [] });
+
+			const result = SkLatestNote.areEquivalent(first, second);
+
+			expect(result).toBeFalsy();
+		});
+	});
+});
diff --git a/packages/frontend/@types/global.d.ts b/packages/frontend/@types/global.d.ts
index 1025d1bedbb8a6f69fef263191c43bbd677548be..15373cbd2dd35eeb7f41f066f1bd25e165f16e35 100644
--- a/packages/frontend/@types/global.d.ts
+++ b/packages/frontend/@types/global.d.ts
@@ -6,6 +6,7 @@
 type FIXME = any;
 
 declare const _LANGS_: string[][];
+declare const _LANGS_VERSION_: string;
 declare const _VERSION_: string;
 declare const _ENV_: string;
 declare const _DEV_: boolean;
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts
index 94040c64132f69d676b7413198a35375f9a89ebf..f8ad123ecafa6977bf2fe151e9400d6dc2020660 100644
--- a/packages/frontend/src/boot/common.ts
+++ b/packages/frontend/src/boot/common.ts
@@ -8,7 +8,7 @@ import { compareVersions } from 'compare-versions';
 import widgets from '@/widgets/index.js';
 import directives from '@/directives/index.js';
 import components from '@/components/index.js';
-import { version, lang, updateLocale, locale } from '@/config.js';
+import { version, lang, langsVersion, updateLocale, locale } from '@/config.js';
 import { applyTheme } from '@/scripts/theme.js';
 import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js';
 import { updateI18n } from '@/i18n.js';
@@ -80,14 +80,15 @@ export async function common(createVue: () => App<Element>) {
 
 	//#region Detect language & fetch translations
 	const localeVersion = miLocalStorage.getItem('localeVersion');
-	const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null);
+	const localeOutdated = (localeVersion == null || localeVersion !== langsVersion || locale == null);
 	if (localeOutdated) {
-		const res = await window.fetch(`/assets/locales/${lang}.${version}.json`);
+		console.info(`Updating locales from version ${localeVersion ?? 'N/A'} to ${langsVersion}`);
+		const res = await window.fetch(`/assets/locales/${lang}.${langsVersion}.json`);
 		if (res.status === 200) {
 			const newLocale = await res.text();
 			const parsedNewLocale = JSON.parse(newLocale);
 			miLocalStorage.setItem('locale', newLocale);
-			miLocalStorage.setItem('localeVersion', version);
+			miLocalStorage.setItem('localeVersion', langsVersion);
 			updateLocale(parsedNewLocale);
 			updateI18n(parsedNewLocale);
 		}
diff --git a/packages/frontend/src/components/SkUserRecentNotes.vue b/packages/frontend/src/components/SkUserRecentNotes.vue
index 1d124b4932eba848a6b9617fca09ac1ab81285ad..2cdb4b6586cf41e2b5a7691d55cffada51ee34f1 100644
--- a/packages/frontend/src/components/SkUserRecentNotes.vue
+++ b/packages/frontend/src/components/SkUserRecentNotes.vue
@@ -24,16 +24,14 @@ import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
 import { Paging } from '@/components/MkPagination.vue';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 
-const props = withDefaults(defineProps<{
+const props = defineProps<{
 	userId: string;
-	withRenotes?: boolean;
-	withReplies?: boolean;
-	onlyFiles?: boolean;
-}>(), {
-	withRenotes: false,
-	withReplies: true,
-	onlyFiles: false,
-});
+	withNonPublic: boolean;
+	withQuotes: boolean;
+	withReplies: boolean;
+	withBots: boolean;
+	onlyFiles: boolean;
+}>();
 
 const loadError: Ref<string | null> = ref(null);
 const user: Ref<Misskey.entities.UserDetailed | null> = ref(null);
@@ -43,9 +41,13 @@ const pagination: Paging<'users/notes'> = {
 	limit: 10,
 	params: computed(() => ({
 		userId: props.userId,
-		withRenotes: props.withRenotes,
+		withNonPublic: props.withNonPublic,
+		withRenotes: false,
+		withQuotes: props.withQuotes,
 		withReplies: props.withReplies,
+		withRepliesToSelf: props.withReplies,
 		withFiles: props.onlyFiles,
+		allowPartial: true,
 	})),
 };
 
diff --git a/packages/frontend/src/config.ts b/packages/frontend/src/config.ts
index e3922a0cd5a7f1f609d572420231aa7950ed726c..0ad356b7ed803189b5310a8ca6c39071ff660686 100644
--- a/packages/frontend/src/config.ts
+++ b/packages/frontend/src/config.ts
@@ -15,6 +15,7 @@ export const apiUrl = location.origin + '/api';
 export const wsOrigin = location.origin;
 export const lang = miLocalStorage.getItem('lang') ?? 'en-US';
 export const langs = _LANGS_;
+export const langsVersion = _LANGS_VERSION_;
 const preParseLocale = miLocalStorage.getItem('locale');
 export let locale = preParseLocale ? JSON.parse(preParseLocale) : null;
 export const version = _VERSION_;
diff --git a/packages/frontend/src/pages/following-feed.vue b/packages/frontend/src/pages/following-feed.vue
index 9050cd93f8f58510db2f95967b79e11e26d30191..7b90b563e63c0e97893dbfbda80f90693eae5878 100644
--- a/packages/frontend/src/pages/following-feed.vue
+++ b/packages/frontend/src/pages/following-feed.vue
@@ -30,18 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 	<div v-if="isWideViewport" ref="userScroll" :class="$style.user">
 		<MkHorizontalSwipe v-if="selectedUserId" v-model:tab="currentTab" :tabs="headerTabs">
-			<SkUserRecentNotes ref="userRecentNotes" :userId="selectedUserId" :withRenotes="withUserRenotes" :withReplies="withUserReplies" :onlyFiles="withOnlyFiles"/>
+			<SkUserRecentNotes ref="userRecentNotes" :userId="selectedUserId" :withNonPublic="withNonPublic" :withQuotes="withQuotes" :withBots="withBots" :withReplies="withReplies" :onlyFiles="onlyFiles"/>
 		</MkHorizontalSwipe>
 	</div>
 </div>
 </template>
 
-<script lang="ts">
-export type FollowingFeedTab = typeof followingTab | typeof mutualsTab;
-export const followingTab = 'following' as const;
-export const mutualsTab = 'mutuals' as const;
-</script>
-
 <script lang="ts" setup>
 import { computed, Ref, ref, shallowRef } from 'vue';
 import * as Misskey from 'misskey-js';
@@ -63,20 +57,49 @@ import { checkWordMute } from '@/scripts/check-word-mute.js';
 import SkUserRecentNotes from '@/components/SkUserRecentNotes.vue';
 import { useScrollPositionManager } from '@/nirax.js';
 import { getScrollContainer } from '@/scripts/scroll.js';
+import { defaultStore } from '@/store.js';
+import { deepMerge } from '@/scripts/merge.js';
 
-const props = withDefaults(defineProps<{
-	initialTab?: FollowingFeedTab,
-}>(), {
-	initialTab: followingTab,
+const withNonPublic = computed({
+	get: () => defaultStore.reactiveState.followingFeed.value.withNonPublic,
+	set: value => saveFollowingFilter('withNonPublic', value),
+});
+const withQuotes = computed({
+	get: () => defaultStore.reactiveState.followingFeed.value.withQuotes,
+	set: value => saveFollowingFilter('withQuotes', value),
+});
+const withBots = computed({
+	get: () => defaultStore.reactiveState.followingFeed.value.withBots,
+	set: value => saveFollowingFilter('withBots', value),
+});
+const withReplies = computed({
+	get: () => defaultStore.reactiveState.followingFeed.value.withReplies,
+	set: value => saveFollowingFilter('withReplies', value),
+});
+const onlyFiles = computed({
+	get: () => defaultStore.reactiveState.followingFeed.value.onlyFiles,
+	set: value => saveFollowingFilter('onlyFiles', value),
+});
+const onlyMutuals = computed({
+	get: () => defaultStore.reactiveState.followingFeed.value.onlyMutuals,
+	set: value => saveFollowingFilter('onlyMutuals', value),
 });
 
+// Based on timeline.saveTlFilter()
+function saveFollowingFilter(key: keyof typeof defaultStore.state.followingFeed, value: boolean) {
+	const out = deepMerge({ [key]: value }, defaultStore.state.followingFeed);
+	defaultStore.set('followingFeed', out);
+}
+
 const router = useRouter();
 
-// Vue complains, but we *want* to lose reactivity here.
-// Otherwise, the user would be unable to change the tab.
-// eslint-disable-next-line vue/no-setup-props-reactivity-loss
-const currentTab: Ref<FollowingFeedTab> = ref(props.initialTab);
-const mutualsOnly: Ref<boolean> = computed(() => currentTab.value === mutualsTab);
+const followingTab = 'following' as const;
+const mutualsTab = 'mutuals' as const;
+const currentTab = computed({
+	get: () => onlyMutuals.value ? mutualsTab : followingTab,
+	set: value => onlyMutuals.value = (value === mutualsTab),
+});
+
 const userRecentNotes = shallowRef<InstanceType<typeof SkUserRecentNotes>>();
 const userScroll = shallowRef<HTMLElement>();
 const noteScroll = shallowRef<HTMLElement>();
@@ -161,55 +184,60 @@ const latestNotesPagination: Paging<'notes/following'> = {
 	endpoint: 'notes/following' as const,
 	limit: 20,
 	params: computed(() => ({
-		mutualsOnly: mutualsOnly.value,
+		mutualsOnly: onlyMutuals.value,
+		filesOnly: onlyFiles.value,
+		includeNonPublic: withNonPublic.value,
+		includeReplies: withReplies.value,
+		includeQuotes: withQuotes.value,
+		includeBots: withBots.value,
 	})),
 };
 
-const withUserRenotes = ref(false);
-const withUserReplies = ref(true);
-const withOnlyFiles = ref(false);
-
-const headerActions = computed(() => {
-	const actions: PageHeaderItem[] = [
-		{
-			icon: 'ti ti-refresh',
-			text: i18n.ts.reload,
-			handler: () => reload(),
+const headerActions: PageHeaderItem[] = [
+	{
+		icon: 'ti ti-refresh',
+		text: i18n.ts.reload,
+		handler: () => reload(),
+	},
+	{
+		icon: 'ti ti-dots',
+		text: i18n.ts.options,
+		handler: (ev) => {
+			os.popupMenu([
+				{
+					type: 'switch',
+					text: i18n.ts.showNonPublicNotes,
+					ref: withNonPublic,
+				},
+				{
+					type: 'switch',
+					text: i18n.ts.showQuotes,
+					ref: withQuotes,
+				},
+				{
+					type: 'switch',
+					text: i18n.ts.showBots,
+					ref: withBots,
+				},
+				{
+					type: 'switch',
+					text: i18n.ts.showReplies,
+					ref: withReplies,
+					disabled: onlyFiles,
+				},
+				{
+					type: 'divider',
+				},
+				{
+					type: 'switch',
+					text: i18n.ts.fileAttachedOnly,
+					ref: onlyFiles,
+					disabled: withReplies,
+				},
+			], ev.currentTarget ?? ev.target);
 		},
-	];
-
-	if (isWideViewport.value) {
-		actions.push({
-			icon: 'ti ti-dots',
-			text: i18n.ts.options,
-			handler: (ev) => {
-				os.popupMenu([
-					{
-						type: 'switch',
-						text: i18n.ts.showRenotes,
-						ref: withUserRenotes,
-					}, {
-						type: 'switch',
-						text: i18n.ts.showRepliesToOthersInTimeline,
-						ref: withUserReplies,
-						disabled: withOnlyFiles,
-					},
-					{
-						type: 'divider',
-					},
-					{
-						type: 'switch',
-						text: i18n.ts.fileAttachedOnly,
-						ref: withOnlyFiles,
-						disabled: withUserReplies,
-					},
-				], ev.currentTarget ?? ev.target);
-			},
-		});
-	}
-
-	return actions;
-});
+	},
+];
 
 const headerTabs = computed(() => [
 	{
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 2d91b9782f5283d601ba9e001cc8132275baa0bd..059c2ebe694e3d8c94aee831d65d29092755bad8 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -239,6 +239,17 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'deviceAccount',
 		default: [] as Misskey.entities.UserList[],
 	},
+	followingFeed: {
+		where: 'account',
+		default: {
+			withNonPublic: false,
+			withQuotes: false,
+			withBots: true,
+			withReplies: false,
+			onlyFiles: false,
+			onlyMutuals: false,
+		},
+	},
 
 	overridedDeviceKind: {
 		where: 'device',
diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts
index 674fdbf6801bc2ba826771f2b4c0190e393f1f5d..513cb7e8bdd62d068c657864120adf0acb941a29 100644
--- a/packages/frontend/vite.config.ts
+++ b/packages/frontend/vite.config.ts
@@ -2,7 +2,7 @@ import path from 'path';
 import pluginReplace from '@rollup/plugin-replace';
 import pluginVue from '@vitejs/plugin-vue';
 import { type UserConfig, defineConfig } from 'vite';
-
+import { localesVersion } from '../../locales/version.js';
 import locales from '../../locales/index.js';
 import meta from '../../package.json';
 import packageInfo from './package.json' with { type: 'json' };
@@ -110,6 +110,7 @@ export function getConfig(): UserConfig {
 		define: {
 			_VERSION_: JSON.stringify(meta.version),
 			_LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])),
+			_LANGS_VERSION_: JSON.stringify(localesVersion),
 			_ENV_: JSON.stringify(process.env.NODE_ENV),
 			_DEV_: process.env.NODE_ENV !== 'production',
 			_PERF_PREFIX_: JSON.stringify('Misskey:'),
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 4bebaf8d9a2362b6e6084141924b231e6fb8c51f..f27e9a9f4cda64570ceb436f7f7a3002c503dba9 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -22296,6 +22296,16 @@ export type operations = {
         'application/json': {
           /** @default false */
           mutualsOnly?: boolean;
+          /** @default false */
+          filesOnly?: boolean;
+          /** @default false */
+          includeNonPublic?: boolean;
+          /** @default false */
+          includeReplies?: boolean;
+          /** @default false */
+          includeQuotes?: boolean;
+          /** @default true */
+          includeBots?: boolean;
           /** @default 10 */
           limit?: number;
           /** Format: misskey:id */
@@ -27228,7 +27238,15 @@ export type operations = {
           /** @default false */
           withReplies?: boolean;
           /** @default true */
+          withRepliesToSelf?: boolean;
+          /** @default true */
+          withQuotes?: boolean;
+          /** @default true */
           withRenotes?: boolean;
+          /** @default true */
+          withBots?: boolean;
+          /** @default true */
+          withNonPublic?: boolean;
           /** @default false */
           withChannelNotes?: boolean;
           /** @default 10 */
diff --git a/scripts/build-assets.mjs b/scripts/build-assets.mjs
index fcf29cef2209bf60d176dceb2be71a1d0cb2d327..4e587aa41dcbb879dda592b59999ca351858b00a 100644
--- a/scripts/build-assets.mjs
+++ b/scripts/build-assets.mjs
@@ -15,6 +15,7 @@ import { build as buildLocales } from '../locales/index.js';
 import generateDTS from '../locales/generateDTS.js';
 import meta from '../package.json' with { type: "json" };
 import buildTarball from './tarball.mjs';
+import { localesVersion } from '../locales/version.js';
 
 const configDir = fileURLToPath(new URL('../.config', import.meta.url));
 const configPath = process.env.MISSKEY_CONFIG_YML
@@ -56,10 +57,10 @@ async function copyFrontendLocales() {
 
   await fs.mkdir('./built/_frontend_dist_/locales', { recursive: true });
 
-  const v = { '_version_': meta.version };
+  const v = { '_version_': localesVersion };
 
   for (const [lang, locale] of Object.entries(locales)) {
-    await fs.writeFile(`./built/_frontend_dist_/locales/${lang}.${meta.version}.json`, JSON.stringify({ ...locale, ...v }), 'utf-8');
+    await fs.writeFile(`./built/_frontend_dist_/locales/${lang}.${localesVersion}.json`, JSON.stringify({ ...locale, ...v }), 'utf-8');
   }
 }
 
@@ -76,7 +77,8 @@ async function buildBackendScript() {
     './packages/backend/src/server/web/cli.js'
   ]) {
     let source = await fs.readFile(file, { encoding: 'utf-8' });
-    source = source.replaceAll('LANGS', JSON.stringify(Object.keys(locales)));
+    source = source.replaceAll(/\bLANGS\b/g, JSON.stringify(Object.keys(locales)));
+    source = source.replaceAll(/\bLANGS_VERSION\b/g, JSON.stringify(localesVersion));
     const { code } = await terser.minify(source, { toplevel: true });
     await fs.writeFile(`./packages/backend/built/server/web/${path.basename(file)}`, code);
   }