From 463b9ac59def86dd1b9065cbe7382325c1e5824e Mon Sep 17 00:00:00 2001
From: Hazel K <acomputerdog@gmail.com>
Date: Wed, 9 Oct 2024 15:09:55 -0400
Subject: [PATCH] add filters for following feed

---
 locales/en-US.yml                             |  3 +
 locales/index.d.ts                            | 12 +++
 locales/ja-JP.yml                             |  3 +
 .../1728420772835-track-latest-note-type.js   |  8 +-
 .../backend/src/core/NoteCreateService.ts     | 13 ++-
 .../backend/src/core/NoteDeleteService.ts     | 26 ++++--
 packages/backend/src/misc/is-renote.ts        | 15 +++
 packages/backend/src/models/LatestNote.ts     | 13 +++
 packages/backend/src/postgres.ts              | 10 +-
 .../server/api/endpoints/notes/following.ts   | 21 +++++
 .../src/server/api/endpoints/users/notes.ts   | 50 +++++++++-
 packages/backend/test/unit/misc/is-renote.ts  | 23 ++++-
 .../backend/test/unit/models/LatestNote.ts    | 66 +++++++++++++
 .../src/components/SkUserRecentNotes.vue      | 21 +++--
 .../frontend/src/pages/following-feed.vue     | 92 ++++++++++---------
 packages/misskey-js/src/autogen/types.ts      | 14 +++
 16 files changed, 318 insertions(+), 72 deletions(-)
 create mode 100644 packages/backend/test/unit/models/LatestNote.ts

diff --git a/locales/en-US.yml b/locales/en-US.yml
index 2fb4700fcf..215519d153 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -1263,6 +1263,9 @@ authentication: "Authentication"
 authenticationRequiredToContinue: "Please authenticate to continue"
 dateAndTime: "Timestamp"
 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 6d6ee68c1c..e89165066a 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 f2c3d67133..957c49f367 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1262,6 +1262,9 @@ authentication: "認証"
 authenticationRequiredToContinue: "続けるには認証を行ってください"
 dateAndTime: "日時"
 showRenotes: "ブーストを表示"
+showQuotes: "Show quotes"
+showReplies: "Show replies"
+showNonPublicNotes: "Show non-public"
 edited: "編集済み"
 notificationRecieveConfig: "通知の受信設定"
 mutualFollow: "相互フォロー"
diff --git a/packages/backend/migration/1728420772835-track-latest-note-type.js b/packages/backend/migration/1728420772835-track-latest-note-type.js
index cef379c7f3..8f8198707e 100644
--- a/packages/backend/migration/1728420772835-track-latest-note-type.js
+++ b/packages/backend/migration/1728420772835-track-latest-note-type.js
@@ -11,14 +11,14 @@ export class TrackLatestNoteType1728420772835 {
 				await queryRunner.query(`ALTER TABLE "latest_note" ADD "isPublic" boolean NOT NULL DEFAULT false`);
 				await queryRunner.query(`ALTER TABLE "latest_note" ADD "isReply" boolean NOT NULL DEFAULT false`);
 				await queryRunner.query(`ALTER TABLE "latest_note" ADD "isQuote" boolean NOT NULL DEFAULT false`);
-				await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd" PRIMARY KEY ("user_id", "isPublic", "isReply", "isQuote")`);
+				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 "isQuote"`);
-				await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN "isReply"`);
-				await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN "isPublic"`);
+				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/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 03701c33e5..cbc9dcaf8f 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -63,7 +63,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 { isPureRenote } from '@/misc/is-renote.js';
 
 type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
 
@@ -1151,18 +1151,21 @@ export class NoteCreateService implements OnApplicationShutdown {
 		if (note.visibility === 'specified') return;
 
 		// Ignore pure renotes
-		if (isRenote(note) && !isQuote(note)) return;
+		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, etc.
-		const currentLatest = await this.latestNotesRepository.findOneBy({ userId: note.userId });
+		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({
-			userId: note.userId,
+			...key,
 			noteId: note.id,
 		});
-		await this.latestNotesRepository.upsert(latestNote, ['userId']);
+		await this.latestNotesRepository.upsert(latestNote, ['userId', 'isPublic', 'isReply', 'isQuote']);
 	}
 }
diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts
index b81e7f6471..fa77caabd1 100644
--- a/packages/backend/src/core/NoteDeleteService.ts
+++ b/packages/backend/src/core/NoteDeleteService.ts
@@ -6,7 +6,7 @@
 import { Brackets, In, Not } 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 { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
 import { SkLatestNote } from '@/models/LatestNote.js';
 import type { InstancesRepository, LatestNotesRepository, NotesRepository, UsersRepository } from '@/models/_.js';
 import { RelayService } from '@/core/RelayService.js';
@@ -25,7 +25,7 @@ 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 { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js';
 
 @Injectable()
 export class NoteDeleteService {
@@ -240,8 +240,14 @@ export class NoteDeleteService {
 		// 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 hasLatestNote = await this.latestNotesRepository.existsBy({ userId: note.userId });
+		const hasLatestNote = await this.latestNotesRepository.existsBy(key);
 		if (hasLatestNote) return;
 
 		// Find the newest remaining note for the user.
@@ -250,8 +256,16 @@ export class NoteDeleteService {
 			.createQueryBuilder('note')
 			.select()
 			.where({
-				userId: note.userId,
-				visibility: Not('specified'),
+				userId: key.userId,
+				visibility: key.isPublic
+					? 'public'
+					: Not('specified'),
+				replyId: key.isReply
+					? Not(null)
+					: null,
+				renoteId: key.isQuote
+					? Not(null)
+					: null,
 			})
 			.andWhere(`
 				(
@@ -269,7 +283,7 @@ export class NoteDeleteService {
 
 		// Record it as the latest
 		const latestNote = new SkLatestNote({
-			userId: note.userId,
+			...key,
 			noteId: nextLatest.id,
 		});
 
diff --git a/packages/backend/src/misc/is-renote.ts b/packages/backend/src/misc/is-renote.ts
index 48f821806c..c128fded14 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 f7b0ca6a23..d36a4d568a 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.
@@ -69,4 +70,16 @@ export class SkLatestNote {
 			(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),
+		};
+	}
 }
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index 2d66e6e445..eaa0eac57c 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -92,6 +92,8 @@ export const dbLogger = new MisskeyLogger('db');
 const sqlLogger = dbLogger.createSubLogger('sql', 'gray');
 
 class MyCustomLogger implements Logger {
+	private readonly isDevelopment = process.env.NODE_ENV === 'development';
+
 	@bindThis
 	private highlight(sql: string) {
 		return highlight.highlight(sql, {
@@ -101,7 +103,13 @@ class MyCustomLogger implements Logger {
 
 	@bindThis
 	public logQuery(query: string, parameters?: any[]) {
-		sqlLogger.info(this.highlight(query).substring(0, 100));
+		let message = this.highlight(query);
+
+		if (!this.isDevelopment) {
+			message = message.substring(0, 100);
+		}
+
+		sqlLogger.info(message);
 	}
 
 	@bindThis
diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts
index 56e0fcd03c..9606c0f19e 100644
--- a/packages/backend/src/server/api/endpoints/notes/following.ts
+++ b/packages/backend/src/server/api/endpoints/notes/following.ts
@@ -33,6 +33,11 @@ export const paramDef = {
 	type: 'object',
 	properties: {
 		mutualsOnly: { type: 'boolean', default: false },
+		filesOnly: { type: 'boolean', default: false },
+		includeNonPublic: { type: 'boolean', default: true },
+		includeReplies: { type: 'boolean', default: false },
+		includeQuotes: { 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' },
@@ -76,6 +81,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				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');
+			}
+
 			// Respect blocks and mutes
 			this.queryService.generateBlockedUserQuery(query, me);
 			this.queryService.generateMutedUserQuery(query, me);
diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts
index cc76c12f1d..884760a88f 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,10 @@ 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 },
+		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 +107,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 					withChannelNotes: ps.withChannelNotes,
 					withFiles: ps.withFiles,
 					withRenotes: ps.withRenotes,
+					withQuotes: ps.withQuotes,
+					withNonPublic: ps.withNonPublic,
+					withRepliesToOthers: ps.withReplies,
+					withRepliesToSelf: ps.withRepliesToSelf,
 				}, me);
 
 				return await this.noteEntityService.packMany(timeline, me);
@@ -132,6 +140,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 					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 +155,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 					withChannelNotes: ps.withChannelNotes,
 					withFiles: ps.withFiles,
 					withRenotes: ps.withRenotes,
+					withQuotes: ps.withQuotes,
+					withNonPublic: ps.withNonPublic,
+					withRepliesToOthers: ps.withReplies,
+					withRepliesToSelf: ps.withRepliesToSelf,
 				}, me),
 			});
 
@@ -157,6 +174,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 		withChannelNotes: boolean,
 		withFiles: boolean,
 		withRenotes: boolean,
+		withQuotes: boolean,
+		withNonPublic: boolean,
+		withRepliesToOthers: boolean,
+		withRepliesToSelf: boolean,
 	}, me: MiLocalUser | null) {
 		const isSelf = me && (me.id === ps.userId);
 
@@ -188,7 +209,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 +219,31 @@ 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\'');
 		}
 
 		return await query.limit(ps.limit).getMany();
diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts
index 080271e404..4da00bcf25 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,25 @@ 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' });
+			const result = isPureRenote(note);
+			expect(result).toBeTruthy();
+		});
+
+		it('should return false when note is quote', () => {
+			const note = new MiNote({ renoteId: 'abc123', text: 'text' });
+			const result = isPureRenote(note);
+			expect(result).toBeFalsy();
+
+		});
+
+		it('should return false when note is not renote', () => {
+			const note = new MiNote({ renoteId: null });
+			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 0000000000..f1ea8c95d2
--- /dev/null
+++ b/packages/backend/test/unit/models/LatestNote.ts
@@ -0,0 +1,66 @@
+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' });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.userId).toBe(note.userId);
+		});
+
+		it('should include isPublic when is public', () => {
+			const note = new MiNote({ visibility: 'public' });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isPublic).toBeTruthy();
+		});
+
+		it('should include isPublic when is home-only', () => {
+			const note = new MiNote({ visibility: 'home' });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isPublic).toBeFalsy();
+		});
+
+		it('should include isPublic when is followers-only', () => {
+			const note = new MiNote({ visibility: 'followers' });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isPublic).toBeFalsy();
+		});
+
+		it('should include isPublic when is specified', () => {
+			const note = new MiNote({ visibility: 'specified' });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isPublic).toBeFalsy();
+		});
+
+		it('should include isReply when is reply', () => {
+			const note = new MiNote({ replyId: 'abc123' });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isReply).toBeTruthy();
+		});
+
+		it('should include isReply when is not reply', () => {
+			const note = new MiNote({ replyId: null });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isReply).toBeFalsy();
+		});
+
+		it('should include isQuote when is quote', () => {
+			const note = new MiNote({ renoteId: 'abc123', text: 'text' });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isQuote).toBeTruthy();
+		});
+
+		it('should include isQuote when is reblog', () => {
+			const note = new MiNote({ renoteId: 'abc123' });
+			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 });
+			const key = SkLatestNote.keyFor(note);
+			expect(key.isQuote).toBeFalsy();
+		});
+	});
+});
diff --git a/packages/frontend/src/components/SkUserRecentNotes.vue b/packages/frontend/src/components/SkUserRecentNotes.vue
index 1d124b4932..31580075ef 100644
--- a/packages/frontend/src/components/SkUserRecentNotes.vue
+++ b/packages/frontend/src/components/SkUserRecentNotes.vue
@@ -24,16 +24,13 @@ 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;
+	onlyFiles: boolean;
+}>();
 
 const loadError: Ref<string | null> = ref(null);
 const user: Ref<Misskey.entities.UserDetailed | null> = ref(null);
@@ -43,9 +40,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/pages/following-feed.vue b/packages/frontend/src/pages/following-feed.vue
index 9050cd93f8..f460086ff0 100644
--- a/packages/frontend/src/pages/following-feed.vue
+++ b/packages/frontend/src/pages/following-feed.vue
@@ -30,7 +30,7 @@ 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" :withReplies="withReplies" :onlyFiles="onlyFiles"/>
 		</MkHorizontalSwipe>
 	</div>
 </div>
@@ -162,54 +162,58 @@ const latestNotesPagination: Paging<'notes/following'> = {
 	limit: 20,
 	params: computed(() => ({
 		mutualsOnly: mutualsOnly.value,
+		filesOnly: onlyFiles.value,
+		includeNonPublic: withNonPublic.value,
+		includeReplies: withReplies.value,
+		includeQuotes: withQuotes.value,
 	})),
 };
 
-const withUserRenotes = ref(false);
-const withUserReplies = ref(true);
-const withOnlyFiles = ref(false);
+const withNonPublic = ref(false);
+const withQuotes = ref(false);
+const withReplies = ref(false);
+const onlyFiles = 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.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/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 4bebaf8d9a..cedf0cad7d 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -22296,6 +22296,14 @@ export type operations = {
         'application/json': {
           /** @default false */
           mutualsOnly?: boolean;
+          /** @default false */
+          filesOnly?: boolean;
+          /** @default true */
+          includeNonPublic?: boolean;
+          /** @default false */
+          includeReplies?: boolean;
+          /** @default true */
+          includeQuotes?: boolean;
           /** @default 10 */
           limit?: number;
           /** Format: misskey:id */
@@ -27228,7 +27236,13 @@ export type operations = {
           /** @default false */
           withReplies?: boolean;
           /** @default true */
+          withRepliesToSelf?: boolean;
+          /** @default true */
+          withQuotes?: boolean;
+          /** @default true */
           withRenotes?: boolean;
+          /** @default true */
+          withNonPublic?: boolean;
           /** @default false */
           withChannelNotes?: boolean;
           /** @default 10 */