From 147e1cf50b41dff55bd2a791a1a45c999a73bdad Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 11 Feb 2026 13:00:35 +0400 Subject: [PATCH] add hot feed --- app/(home-pages)/reader/GlobalContent.tsx | 39 ++++- app/(home-pages)/reader/InboxContent.tsx | 81 +--------- .../reader/InteractionDrawers.tsx | 80 ++++++++++ app/api/rpc/[command]/get_hot_feed.ts | 148 ++++++++++++++++++ app/api/rpc/[command]/route.ts | 2 + components/PostListing.tsx | 7 + 6 files changed, 278 insertions(+), 79 deletions(-) create mode 100644 app/(home-pages)/reader/InteractionDrawers.tsx create mode 100644 app/api/rpc/[command]/get_hot_feed.ts diff --git a/app/(home-pages)/reader/GlobalContent.tsx b/app/(home-pages)/reader/GlobalContent.tsx index fba3deab..478cde9a 100644 --- a/app/(home-pages)/reader/GlobalContent.tsx +++ b/app/(home-pages)/reader/GlobalContent.tsx @@ -1,9 +1,44 @@ "use client"; +import useSWR from "swr"; +import { callRPC } from "app/api/rpc/client"; +import { PostListing } from "components/PostListing"; +import type { Post } from "./getReaderFeed"; +import { + DesktopInteractionPreviewDrawer, + MobileInteractionPreviewDrawer, +} from "./InteractionDrawers"; export const GlobalContent = () => { + const { data, isLoading } = useSWR("hot_feed", async () => { + const res = await callRPC("get_hot_feed", {}); + return res as unknown as { posts: Post[] }; + }); + + const posts = data?.posts ?? []; + + if (isLoading) { + return ( +
Loading posts...
+ ); + } + + if (posts.length === 0) { + return ( +
+ Nothing trending right now. Check back soon! +
+ ); + } + return ( -
- Nothing here yet… +
+
+ {posts.map((p) => ( + + ))} +
+ +
); }; diff --git a/app/(home-pages)/reader/InboxContent.tsx b/app/(home-pages)/reader/InboxContent.tsx index 5bf3535c..ed1a2cb7 100644 --- a/app/(home-pages)/reader/InboxContent.tsx +++ b/app/(home-pages)/reader/InboxContent.tsx @@ -4,18 +4,14 @@ import { DiscoverSmall } from "components/Icons/DiscoverSmall"; import type { Cursor, Post } from "./getReaderFeed"; import useSWRInfinite from "swr/infinite"; import { getReaderFeed } from "./getReaderFeed"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import Link from "next/link"; import { PostListing } from "components/PostListing"; import { useHasBackgroundImage } from "components/Pages/useHasBackgroundImage"; import { - SelectedPostListing, - useSelectedPostListing, -} from "src/useSelectedPostState"; -import { CommentsDrawerContent } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments"; -import { CloseTiny } from "components/Icons/CloseTiny"; -import { SpeedyLink } from "components/SpeedyLink"; -import { GoToArrow } from "components/Icons/GoToArrow"; + DesktopInteractionPreviewDrawer, + MobileInteractionPreviewDrawer, +} from "./InteractionDrawers"; export const InboxContent = (props: { posts: Post[]; @@ -109,75 +105,6 @@ export const InboxContent = (props: { ); }; -const MobileInteractionPreviewDrawer = () => { - let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); - - return ( -
- -
- ); -}; -const DesktopInteractionPreviewDrawer = () => { - let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); - - return ( -
- -
- ); -}; - -const PreviewDrawerContent = (props: { - selectedPost: SelectedPostListing | null; -}) => { - if (!props.selectedPost || !props.selectedPost.document) return; - - if (props.selectedPost.drawer === "quotes") { - return ( - <> - {/**/} - - ); - } else - return ( - <> -
-
- Comments for {props.selectedPost.document.title} -
- -
- - - See Full Post - - - - ); -}; - export const ReaderEmpty = () => { return (
diff --git a/app/(home-pages)/reader/InteractionDrawers.tsx b/app/(home-pages)/reader/InteractionDrawers.tsx new file mode 100644 index 00000000..7425cd18 --- /dev/null +++ b/app/(home-pages)/reader/InteractionDrawers.tsx @@ -0,0 +1,80 @@ +"use client"; +import { ButtonPrimary } from "components/Buttons"; +import { + SelectedPostListing, + useSelectedPostListing, +} from "src/useSelectedPostState"; +import { CommentsDrawerContent } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments"; +import { CloseTiny } from "components/Icons/CloseTiny"; +import { SpeedyLink } from "components/SpeedyLink"; +import { GoToArrow } from "components/Icons/GoToArrow"; + +export const MobileInteractionPreviewDrawer = () => { + let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); + + return ( +
+ +
+ ); +}; + +export const DesktopInteractionPreviewDrawer = () => { + let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); + + return ( +
+ +
+ ); +}; + +const PreviewDrawerContent = (props: { + selectedPost: SelectedPostListing | null; +}) => { + if (!props.selectedPost || !props.selectedPost.document) return; + + if (props.selectedPost.drawer === "quotes") { + return ( + <> + {/**/} + + ); + } else + return ( + <> +
+
+ Comments for {props.selectedPost.document.title} +
+ +
+ + + See Full Post + + + + ); +}; diff --git a/app/api/rpc/[command]/get_hot_feed.ts b/app/api/rpc/[command]/get_hot_feed.ts new file mode 100644 index 00000000..1e0219b7 --- /dev/null +++ b/app/api/rpc/[command]/get_hot_feed.ts @@ -0,0 +1,148 @@ +import { z } from "zod"; +import { makeRoute } from "../lib"; +import type { Env } from "./route"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { sql } from "drizzle-orm"; +import { pool } from "supabase/pool"; +import Client from "ioredis"; +import { AtUri } from "@atproto/api"; +import { idResolver } from "app/(home-pages)/reader/idResolver"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; +import { + normalizeDocumentRecord, + normalizePublicationRecord, +} from "src/utils/normalizeRecords"; +import type { Post } from "app/(home-pages)/reader/getReaderFeed"; + +let redisClient: Client | null = null; +if (process.env.REDIS_URL && process.env.NODE_ENV === "production") { + redisClient = new Client(process.env.REDIS_URL); +} + +const CACHE_KEY = "hot_feed_v1"; +const CACHE_TTL = 300; // 5 minutes + +export type GetHotFeedReturnType = Awaited< + ReturnType<(typeof get_hot_feed)["handler"]> +>; + +export const get_hot_feed = makeRoute({ + route: "get_hot_feed", + input: z.object({}), + handler: async ({}, { supabase }: Pick) => { + // Check Redis cache + if (redisClient) { + const cached = await redisClient.get(CACHE_KEY); + if (cached) { + return JSON.parse(cached) as { posts: Post[] }; + } + } + + // Run ranked SQL query to get top 50 URIs + const client = await pool.connect(); + const db = drizzle(client); + + let uris: string[]; + try { + const ranked = await db.execute(sql` + SELECT uri + FROM documents + WHERE indexed = true + AND sort_date > now() - interval '7 days' + ORDER BY + (bsky_like_count + recommend_count * 5)::numeric + / power(extract(epoch from (now() - sort_date)) / 3600 + 2, 1.5) DESC + LIMIT 50 + `); + uris = ranked.rows.map((row: any) => row.uri as string); + } finally { + client.release(); + } + + if (uris.length === 0) { + return { posts: [] as Post[] }; + } + + // Batch-fetch documents with publication joins and interaction counts + const { data: documents } = await supabase + .from("documents") + .select( + `*, + comments_on_documents(count), + document_mentions_in_bsky(count), + recommends_on_documents(count), + documents_in_publications(publications(*))`, + ) + .in("uri", uris); + + // Build lookup map for enrichment + const docMap = new Map( + (documents || []).map((d) => [d.uri, d]), + ); + + // Process in ranked order, deduplicating by identity key (DID/rkey) + const seen = new Set(); + const orderedDocs: (typeof documents extends (infer T)[] | null ? T : never)[] = []; + for (const uri of uris) { + try { + const parsed = new AtUri(uri); + const identityKey = `${parsed.host}/${parsed.rkey}`; + if (seen.has(identityKey)) continue; + seen.add(identityKey); + } catch { + // invalid URI, skip dedup check + } + const doc = docMap.get(uri); + if (doc) orderedDocs.push(doc); + } + + // Enrich into Post[] + const posts = ( + await Promise.all( + orderedDocs.map(async (doc) => { + const pub = doc.documents_in_publications?.[0]?.publications; + const uri = new AtUri(doc.uri); + const handle = await idResolver.did.resolve(uri.host); + + const normalizedData = normalizeDocumentRecord(doc.data, doc.uri); + if (!normalizedData) return null; + + const normalizedPubRecord = pub + ? normalizePublicationRecord(pub.record) + : null; + + const post: Post = { + publication: pub + ? { + href: getPublicationURL(pub), + pubRecord: normalizedPubRecord, + uri: pub.uri || "", + } + : undefined, + author: handle?.alsoKnownAs?.[0] + ? `@${handle.alsoKnownAs[0].slice(5)}` + : null, + documents: { + comments_on_documents: doc.comments_on_documents, + document_mentions_in_bsky: doc.document_mentions_in_bsky, + recommends_on_documents: doc.recommends_on_documents, + data: normalizedData, + uri: doc.uri, + sort_date: doc.sort_date, + }, + }; + return post; + }), + ) + ).filter((post): post is Post => post !== null); + + const response = { posts }; + + // Cache in Redis + if (redisClient) { + await redisClient.setex(CACHE_KEY, CACHE_TTL, JSON.stringify(response)); + } + + return response; + }, +}); diff --git a/app/api/rpc/[command]/route.ts b/app/api/rpc/[command]/route.ts index c6ba128e..af834c9d 100644 --- a/app/api/rpc/[command]/route.ts +++ b/app/api/rpc/[command]/route.ts @@ -15,6 +15,7 @@ import { search_publication_names } from "./search_publication_names"; import { search_publication_documents } from "./search_publication_documents"; import { get_profile_data } from "./get_profile_data"; import { get_user_recommendations } from "./get_user_recommendations"; +import { get_hot_feed } from "./get_hot_feed"; let supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, @@ -43,6 +44,7 @@ let Routes = [ search_publication_documents, get_profile_data, get_user_recommendations, + get_hot_feed, ]; export async function POST( req: Request, diff --git a/components/PostListing.tsx b/components/PostListing.tsx index cd3b40ea..1a431fb9 100644 --- a/components/PostListing.tsx +++ b/components/PostListing.tsx @@ -22,6 +22,7 @@ import { useSelectedPostListing } from "src/useSelectedPostState"; import { mergePreferences } from "src/utils/mergePreferences"; import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny"; import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; +import { RecommendButton } from "./RecommendButton"; export const PostListing = (props: Post) => { let pubRecord = props.publication?.pubRecord as @@ -146,6 +147,7 @@ export const PostListing = (props: Post) => { postUrl={postUrl} quotesCount={quotes} commentsCount={comments} + recommendsCount={recommends} tags={tags} showComments={mergedPrefs.showComments !== false} showMentions={mergedPrefs.showMentions !== false} @@ -205,6 +207,7 @@ const PostDate = (props: { publishedAt: string | undefined }) => { const Interactions = (props: { quotesCount: number; commentsCount: number; + recommendsCount: number; tags?: string[]; postUrl: string; showComments: boolean; @@ -228,6 +231,10 @@ const Interactions = (props: { className={`flex gap-2 text-tertiary text-sm items-center justify-between px-1`} >
+ {!props.showMentions || props.quotesCount === 0 ? null : (