From 07da08580286ecba3bc15374475e044b0f17cf2c Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 11 Feb 2026 21:27:42 +0400 Subject: [PATCH] WIP suspense reader pages --- app/(home-pages)/reader/FeedSkeleton.tsx | 13 ++ app/(home-pages)/reader/GlobalContent.tsx | 21 +++- app/(home-pages)/reader/InboxContent.tsx | 8 +- app/(home-pages)/reader/NewContent.tsx | 93 ++++++++++++++ app/(home-pages)/reader/getHotFeed.ts | 138 +++++++++++++++++++++ app/(home-pages)/reader/getNewFeed.ts | 91 ++++++++++++++ app/(home-pages)/reader/hot/page.tsx | 13 ++ app/(home-pages)/reader/layout.tsx | 74 +++++++++++ app/(home-pages)/reader/new/page.tsx | 13 ++ app/(home-pages)/reader/page.tsx | 35 ++---- components/PageLayouts/DashboardLayout.tsx | 5 +- components/Tab.tsx | 4 + 12 files changed, 472 insertions(+), 36 deletions(-) create mode 100644 app/(home-pages)/reader/FeedSkeleton.tsx create mode 100644 app/(home-pages)/reader/NewContent.tsx create mode 100644 app/(home-pages)/reader/getHotFeed.ts create mode 100644 app/(home-pages)/reader/getNewFeed.ts create mode 100644 app/(home-pages)/reader/hot/page.tsx create mode 100644 app/(home-pages)/reader/layout.tsx create mode 100644 app/(home-pages)/reader/new/page.tsx diff --git a/app/(home-pages)/reader/FeedSkeleton.tsx b/app/(home-pages)/reader/FeedSkeleton.tsx new file mode 100644 index 00000000..2541b869 --- /dev/null +++ b/app/(home-pages)/reader/FeedSkeleton.tsx @@ -0,0 +1,13 @@ +export function FeedSkeleton() { + return ( +
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+
+ ))} +
+ ); +} diff --git a/app/(home-pages)/reader/GlobalContent.tsx b/app/(home-pages)/reader/GlobalContent.tsx index 478cde9a..a31d7daa 100644 --- a/app/(home-pages)/reader/GlobalContent.tsx +++ b/app/(home-pages)/reader/GlobalContent.tsx @@ -1,4 +1,5 @@ "use client"; +import { use } from "react"; import useSWR from "swr"; import { callRPC } from "app/api/rpc/client"; import { PostListing } from "components/PostListing"; @@ -8,11 +9,21 @@ import { 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[] }; - }); +export const GlobalContent = (props: { + promise: Promise<{ posts: Post[] }>; +}) => { + const initialData = use(props.promise); + + const { data, isLoading } = useSWR( + "hot_feed", + async () => { + const res = await callRPC("get_hot_feed", {}); + return res as unknown as { posts: Post[] }; + }, + { + fallbackData: { posts: initialData.posts }, + }, + ); const posts = data?.posts ?? []; diff --git a/app/(home-pages)/reader/InboxContent.tsx b/app/(home-pages)/reader/InboxContent.tsx index ed1a2cb7..09b1daf0 100644 --- a/app/(home-pages)/reader/InboxContent.tsx +++ b/app/(home-pages)/reader/InboxContent.tsx @@ -1,4 +1,5 @@ "use client"; +import { use } from "react"; import { ButtonPrimary } from "components/Buttons"; import { DiscoverSmall } from "components/Icons/DiscoverSmall"; import type { Cursor, Post } from "./getReaderFeed"; @@ -14,9 +15,10 @@ import { } from "./InteractionDrawers"; export const InboxContent = (props: { - posts: Post[]; - nextCursor: Cursor | null; + promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>; }) => { + const { posts, nextCursor } = use(props.promise); + const getKey = ( pageIndex: number, previousPageData: { @@ -38,7 +40,7 @@ export const InboxContent = (props: { getKey, ([_, cursor]) => getReaderFeed(cursor), { - fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], + fallbackData: [{ posts, nextCursor }], revalidateFirstPage: false, }, ); diff --git a/app/(home-pages)/reader/NewContent.tsx b/app/(home-pages)/reader/NewContent.tsx new file mode 100644 index 00000000..972a9313 --- /dev/null +++ b/app/(home-pages)/reader/NewContent.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { use } from "react"; +import type { Cursor, Post } from "./getReaderFeed"; +import useSWRInfinite from "swr/infinite"; +import { getNewFeed } from "./getNewFeed"; +import { useEffect, useRef } from "react"; +import { PostListing } from "components/PostListing"; +import { + DesktopInteractionPreviewDrawer, + MobileInteractionPreviewDrawer, +} from "./InteractionDrawers"; + +export const NewContent = (props: { + promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>; +}) => { + const { posts, nextCursor } = use(props.promise); + + const getKey = ( + pageIndex: number, + previousPageData: { + posts: Post[]; + nextCursor: Cursor | null; + } | null, + ) => { + if (previousPageData && !previousPageData.nextCursor) return null; + if (pageIndex === 0) return ["new-feed", null] as const; + return ["new-feed", previousPageData?.nextCursor] as const; + }; + + const { data, size, setSize, isValidating } = useSWRInfinite( + getKey, + ([_, cursor]) => getNewFeed(cursor), + { + fallbackData: [{ posts, nextCursor }], + revalidateFirstPage: false, + }, + ); + + const loadMoreRef = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isValidating) { + const hasMore = data && data[data.length - 1]?.nextCursor; + if (hasMore) { + setSize(size + 1); + } + } + }, + { threshold: 0.1 }, + ); + + if (loadMoreRef.current) { + observer.observe(loadMoreRef.current); + } + + return () => observer.disconnect(); + }, [data, size, setSize, isValidating]); + + const allPosts = data ? data.flatMap((page) => page.posts) : []; + + if (allPosts.length === 0 && !isValidating) { + return ( +
+ No posts yet. Check back soon! +
+ ); + } + + return ( +
+
+ {allPosts.map((p) => ( + + ))} + + + +
+ ); +}; diff --git a/app/(home-pages)/reader/getHotFeed.ts b/app/(home-pages)/reader/getHotFeed.ts new file mode 100644 index 00000000..071e7510 --- /dev/null +++ b/app/(home-pages)/reader/getHotFeed.ts @@ -0,0 +1,138 @@ +"use server"; + +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 "./idResolver"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; +import { + normalizeDocumentRecord, + normalizePublicationRecord, +} from "src/utils/normalizeRecords"; +import { supabaseServerClient } from "supabase/serverClient"; +import type { Post } from "./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 async function getHotFeed(): Promise<{ posts: Post[] }> { + // 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: [] }; + } + + // Batch-fetch documents with publication joins and interaction counts + const { data: documents } = await supabaseServerClient + .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: NonNullable[number][] = []; + 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/(home-pages)/reader/getNewFeed.ts b/app/(home-pages)/reader/getNewFeed.ts new file mode 100644 index 00000000..41d1497f --- /dev/null +++ b/app/(home-pages)/reader/getNewFeed.ts @@ -0,0 +1,91 @@ +"use server"; + +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; +import { supabaseServerClient } from "supabase/serverClient"; +import { AtUri } from "@atproto/api"; +import { idResolver } from "./idResolver"; +import { + normalizeDocumentRecord, + normalizePublicationRecord, +} from "src/utils/normalizeRecords"; +import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords"; +import type { Cursor, Post } from "./getReaderFeed"; + +export async function getNewFeed( + cursor?: Cursor | null, +): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { + let query = supabaseServerClient + .from("documents") + .select( + `*, + comments_on_documents(count), + document_mentions_in_bsky(count), + recommends_on_documents(count), + documents_in_publications!inner(publications!inner(*))`, + ) + .or( + "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", + { referencedTable: "documents_in_publications.publications" }, + ) + .order("sort_date", { ascending: false }) + .order("uri", { ascending: false }) + .limit(25); + + if (cursor) { + query = query.or( + `sort_date.lt.${cursor.timestamp},and(sort_date.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, + ); + } + + let { data: rawFeed, error } = await query; + + const feed = deduplicateByUriOrdered(rawFeed || []); + + let posts = ( + await Promise.all( + feed.map(async (post) => { + let pub = post.documents_in_publications[0]?.publications!; + let uri = new AtUri(post.uri); + let handle = await idResolver.did.resolve(uri.host); + + const normalizedData = normalizeDocumentRecord(post.data, post.uri); + if (!normalizedData) return null; + + const normalizedPubRecord = normalizePublicationRecord(pub?.record); + + let p: Post = { + publication: { + href: getPublicationURL(pub), + pubRecord: normalizedPubRecord, + uri: pub?.uri || "", + }, + author: handle?.alsoKnownAs?.[0] + ? `@${handle.alsoKnownAs[0].slice(5)}` + : null, + documents: { + comments_on_documents: post.comments_on_documents, + document_mentions_in_bsky: post.document_mentions_in_bsky, + recommends_on_documents: post.recommends_on_documents, + data: normalizedData, + uri: post.uri, + sort_date: post.sort_date, + }, + }; + return p; + }) || [], + ) + ).filter((post): post is Post => post !== null); + + const nextCursor = + posts.length > 0 + ? { + timestamp: posts[posts.length - 1].documents.sort_date, + uri: posts[posts.length - 1].documents.uri, + } + : null; + + return { + posts, + nextCursor, + }; +} diff --git a/app/(home-pages)/reader/hot/page.tsx b/app/(home-pages)/reader/hot/page.tsx new file mode 100644 index 00000000..70da6d98 --- /dev/null +++ b/app/(home-pages)/reader/hot/page.tsx @@ -0,0 +1,13 @@ +import { Suspense } from "react"; +import { getHotFeed } from "../getHotFeed"; +import { GlobalContent } from "../GlobalContent"; +import { FeedSkeleton } from "../FeedSkeleton"; + +export default async function HotPage() { + const feedPromise = getHotFeed(); + return ( + }> + + + ); +} diff --git a/app/(home-pages)/reader/layout.tsx b/app/(home-pages)/reader/layout.tsx new file mode 100644 index 00000000..287bc9b3 --- /dev/null +++ b/app/(home-pages)/reader/layout.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import Link from "next/link"; +import { Header } from "components/PageHeader"; +import { Footer } from "components/ActionBar/Footer"; +import { DesktopNavigation } from "components/ActionBar/DesktopNavigation"; +import { MobileNavigation } from "components/ActionBar/MobileNavigation"; +import { MediaContents } from "components/Media"; +import { DashboardIdContext } from "components/PageLayouts/DashboardLayout"; + +const tabs = [ + { name: "Subs", href: "/reader" }, + { name: "What's Hot", href: "/reader/hot" }, + { name: "New", href: "/reader/new" }, +]; + +export default function ReaderLayout({ + children, +}: { + children: React.ReactNode; +}) { + const pathname = usePathname(); + + const isActive = (href: string) => { + if (href === "/reader") return pathname === "/reader"; + return pathname.startsWith(href); + }; + + return ( + +
+ +
+ +
+
+
+
+
+ {tabs.map((tab) => ( + +
+ {tab.name} +
+ + ))} +
+
+ {pathname === "/reader" && ( +
+ Publications +
+ )} +
+
+ {children} +
+
+ +
+
+
+ ); +} diff --git a/app/(home-pages)/reader/new/page.tsx b/app/(home-pages)/reader/new/page.tsx new file mode 100644 index 00000000..264c0f2a --- /dev/null +++ b/app/(home-pages)/reader/new/page.tsx @@ -0,0 +1,13 @@ +import { Suspense } from "react"; +import { getNewFeed } from "../getNewFeed"; +import { NewContent } from "../NewContent"; +import { FeedSkeleton } from "../FeedSkeleton"; + +export default async function NewPage() { + const feedPromise = getNewFeed(); + return ( + }> + + + ); +} diff --git a/app/(home-pages)/reader/page.tsx b/app/(home-pages)/reader/page.tsx index 6c43c512..b5d524ce 100644 --- a/app/(home-pages)/reader/page.tsx +++ b/app/(home-pages)/reader/page.tsx @@ -1,32 +1,13 @@ -import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; -import { InboxContent } from "./InboxContent"; -import { GlobalContent } from "./GlobalContent"; +import { Suspense } from "react"; import { getReaderFeed } from "./getReaderFeed"; +import { InboxContent } from "./InboxContent"; +import { FeedSkeleton } from "./FeedSkeleton"; -export default async function Reader(props: {}) { - let posts = await getReaderFeed(); +export default async function Reader() { + const feedPromise = getReaderFeed(); return ( - - Publications -
- ), - content: ( - - ), - }, - Global: { - controls: null, - content: , - }, - }} - /> + }> + + ); } diff --git a/components/PageLayouts/DashboardLayout.tsx b/components/PageLayouts/DashboardLayout.tsx index f578e73b..0ab1677f 100644 --- a/components/PageLayouts/DashboardLayout.tsx +++ b/components/PageLayouts/DashboardLayout.tsx @@ -71,7 +71,7 @@ export const useDashboardStore = create((set, get) => ({ }, })); -const DashboardIdContext = createContext(null); +export const DashboardIdContext = createContext(null); export const useDashboardId = () => { const id = useContext(DashboardIdContext); @@ -143,6 +143,7 @@ export function DashboardLayout< profileDid?: string; actions?: React.ReactNode; pageTitle?: string; + onTabHover?: (tabName: string) => void; }) { const searchParams = useSearchParams(); const tabParam = searchParams.get("tab"); @@ -207,6 +208,8 @@ export function DashboardLayout< name={t} selected={t === tab} onSelect={() => setTabWithUrl(t)} + onMouseEnter={() => props.onTabHover?.(t)} + onPointerDown={() => props.onTabHover?.(t)} /> ); })} diff --git a/components/Tab.tsx b/components/Tab.tsx index c0963e18..dc66e2e9 100644 --- a/components/Tab.tsx +++ b/components/Tab.tsx @@ -4,12 +4,16 @@ export const Tab = (props: { name: string; selected: boolean; onSelect: () => void; + onMouseEnter?: () => void; + onPointerDown?: () => void; href?: string; }) => { return (
props.onSelect()} + onMouseEnter={props.onMouseEnter} + onPointerDown={props.onPointerDown} > {props.name} {props.href && } -- 2.51.2