From 412cdad209f567d9654686be507ee34927214866 Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Fri, 1 Mar 2024 23:41:55 +0900
Subject: [PATCH] feat: show unlisted posts from following users in antennas

---
 docs/changelog.md                             |  1 +
 .../backend/src/misc/check-hit-antenna.ts     | 31 ++++++++++++++-----
 .../server/api/endpoints/antennas/notes.ts    |  3 +-
 3 files changed, 25 insertions(+), 10 deletions(-)

diff --git a/docs/changelog.md b/docs/changelog.md
index 99ece6a663..865ea86050 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -6,6 +6,7 @@ Critical security updates are indicated by the :warning: icon.
 
 - Introduce new full-text search engine and post search filters
 - Refactoring
+- Show unlisted posts from following users in antennas (similar to [Fedibird](https://github.com/fedibird/mastodon/tree/fedibird) and [kmyblue](https://github.com/kmycode/mastodon), unlisted posts from people you don't follow won't be shown)
 
 ## v20240301
 
diff --git a/packages/backend/src/misc/check-hit-antenna.ts b/packages/backend/src/misc/check-hit-antenna.ts
index 81776ae55e..b93cb459e8 100644
--- a/packages/backend/src/misc/check-hit-antenna.ts
+++ b/packages/backend/src/misc/check-hit-antenna.ts
@@ -2,12 +2,12 @@ import type { Antenna } from "@/models/entities/antenna.js";
 import type { Note } from "@/models/entities/note.js";
 import type { User } from "@/models/entities/user.js";
 import type { UserProfile } from "@/models/entities/user-profile.js";
-import { Blockings, UserProfiles } from "@/models/index.js";
+import { Blockings, Followings, UserProfiles } from "@/models/index.js";
 import { getFullApAccount } from "@/misc/convert-host.js";
 import * as Acct from "@/misc/acct.js";
+import { getWordHardMute } from "@/misc/check-word-mute.js";
 import type { Packed } from "@/misc/schema.js";
 import { Cache } from "@/misc/cache.js";
-import { getWordHardMute } from "@/misc/check-word-mute.js";
 
 const blockingCache = new Cache<User["id"][]>("blocking", 60 * 5);
 const hardMutesCache = new Cache<{
@@ -15,6 +15,7 @@ const hardMutesCache = new Cache<{
 	mutedWords: UserProfile["mutedWords"];
 	mutedPatterns: UserProfile["mutedPatterns"];
 }>("hardMutes", 60 * 5);
+const followingCache = new Cache<User["id"][]>("following", 60 * 5);
 
 export async function checkHitAntenna(
 	antenna: Antenna,
@@ -22,11 +23,10 @@ export async function checkHitAntenna(
 	noteUser: { id: User["id"]; username: string; host: string | null },
 ): Promise<boolean> {
 	if (note.visibility === "specified") return false;
-	if (note.visibility === "home") return false;
-	if (!antenna.withReplies && note.replyId != null) return false;
 	if (antenna.withFile) {
 		if (note.fileIds && note.fileIds.length === 0) return false;
 	}
+	if (!antenna.withReplies && note.replyId != null) return false;
 
 	if (antenna.src === "users") {
 		const accts = antenna.users.map((x) => {
@@ -53,14 +53,19 @@ export async function checkHitAntenna(
 		.map((xs) => xs.filter((x) => x !== ""))
 		.filter((xs) => xs.length > 0);
 
+	let text = `${note.text ?? ""} ${note.cw ?? ""}`;
+	if (note.files != null)
+		text += ` ${note.files.map((f) => f.comment ?? "").join(" ")}`;
+	text = text.trim();
+
 	if (keywords.length > 0) {
 		if (note.text == null) return false;
 
 		const matched = keywords.some((and) =>
 			and.every((keyword) =>
 				antenna.caseSensitive
-					? note.text!.includes(keyword)
-					: note.text!.toLowerCase().includes(keyword.toLowerCase()),
+					? text.includes(keyword)
+					: text.toLowerCase().includes(keyword.toLowerCase()),
 			),
 		);
 
@@ -78,8 +83,8 @@ export async function checkHitAntenna(
 		const matched = excludeKeywords.some((and) =>
 			and.every((keyword) =>
 				antenna.caseSensitive
-					? note.text!.includes(keyword)
-					: note.text!.toLowerCase().includes(keyword.toLowerCase()),
+					? note.text?.includes(keyword)
+					: note.text?.toLowerCase().includes(keyword.toLowerCase()),
 			),
 		);
 
@@ -94,6 +99,16 @@ export async function checkHitAntenna(
 	);
 	if (blockings.includes(antenna.userId)) return false;
 
+	if (note.visibility === "followers" || note.visibility === "home") {
+		const following = await followingCache.fetch(antenna.userId, () =>
+			Followings.find({
+				where: { followerId: antenna.userId },
+				select: ["followeeId"],
+			}).then((relations) => relations.map((relation) => relation.followeeId)),
+		);
+		if (!following.includes(note.userId)) return false;
+	}
+
 	const mutes = await hardMutesCache.fetch(antenna.userId, () =>
 		UserProfiles.findOneByOrFail({
 			userId: antenna.userId,
diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts
index 1512cb9281..cbe0318525 100644
--- a/packages/backend/src/server/api/endpoints/antennas/notes.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts
@@ -111,8 +111,7 @@ export default define(meta, paramDef, async (ps, user) => {
 		.leftJoinAndSelect("replyUser.banner", "replyUserBanner")
 		.leftJoinAndSelect("renote.user", "renoteUser")
 		.leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar")
-		.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner")
-		.andWhere("note.visibility != 'home'");
+		.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner");
 
 	generateVisibilityQuery(query, user);
 	generateMutedUserQuery(query, user);