a tool for shared writing and social publishing

WIP suspense reader pages

+472 -36
+13
app/(home-pages)/reader/FeedSkeleton.tsx
··· 1 + export function FeedSkeleton() { 2 + return ( 3 + <div className="flex flex-col gap-6 w-full animate-pulse"> 4 + {[...Array(3)].map((_, i) => ( 5 + <div key={i} className="flex flex-col gap-3 p-2"> 6 + <div className="h-4 bg-border-light rounded w-1/3" /> 7 + <div className="h-3 bg-border-light rounded w-2/3" /> 8 + <div className="h-3 bg-border-light rounded w-1/2" /> 9 + </div> 10 + ))} 11 + </div> 12 + ); 13 + }
+16 -5
app/(home-pages)/reader/GlobalContent.tsx
··· 1 1 "use client"; 2 + import { use } from "react"; 2 3 import useSWR from "swr"; 3 4 import { callRPC } from "app/api/rpc/client"; 4 5 import { PostListing } from "components/PostListing"; ··· 8 9 MobileInteractionPreviewDrawer, 9 10 } from "./InteractionDrawers"; 10 11 11 - export const GlobalContent = () => { 12 - const { data, isLoading } = useSWR("hot_feed", async () => { 13 - const res = await callRPC("get_hot_feed", {}); 14 - return res as unknown as { posts: Post[] }; 15 - }); 12 + export const GlobalContent = (props: { 13 + promise: Promise<{ posts: Post[] }>; 14 + }) => { 15 + const initialData = use(props.promise); 16 + 17 + const { data, isLoading } = useSWR( 18 + "hot_feed", 19 + async () => { 20 + const res = await callRPC("get_hot_feed", {}); 21 + return res as unknown as { posts: Post[] }; 22 + }, 23 + { 24 + fallbackData: { posts: initialData.posts }, 25 + }, 26 + ); 16 27 17 28 const posts = data?.posts ?? []; 18 29
+5 -3
app/(home-pages)/reader/InboxContent.tsx
··· 1 1 "use client"; 2 + import { use } from "react"; 2 3 import { ButtonPrimary } from "components/Buttons"; 3 4 import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 4 5 import type { Cursor, Post } from "./getReaderFeed"; ··· 14 15 } from "./InteractionDrawers"; 15 16 16 17 export const InboxContent = (props: { 17 - posts: Post[]; 18 - nextCursor: Cursor | null; 18 + promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>; 19 19 }) => { 20 + const { posts, nextCursor } = use(props.promise); 21 + 20 22 const getKey = ( 21 23 pageIndex: number, 22 24 previousPageData: { ··· 38 40 getKey, 39 41 ([_, cursor]) => getReaderFeed(cursor), 40 42 { 41 - fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], 43 + fallbackData: [{ posts, nextCursor }], 42 44 revalidateFirstPage: false, 43 45 }, 44 46 );
+93
app/(home-pages)/reader/NewContent.tsx
··· 1 + "use client"; 2 + 3 + import { use } from "react"; 4 + import type { Cursor, Post } from "./getReaderFeed"; 5 + import useSWRInfinite from "swr/infinite"; 6 + import { getNewFeed } from "./getNewFeed"; 7 + import { useEffect, useRef } from "react"; 8 + import { PostListing } from "components/PostListing"; 9 + import { 10 + DesktopInteractionPreviewDrawer, 11 + MobileInteractionPreviewDrawer, 12 + } from "./InteractionDrawers"; 13 + 14 + export const NewContent = (props: { 15 + promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>; 16 + }) => { 17 + const { posts, nextCursor } = use(props.promise); 18 + 19 + const getKey = ( 20 + pageIndex: number, 21 + previousPageData: { 22 + posts: Post[]; 23 + nextCursor: Cursor | null; 24 + } | null, 25 + ) => { 26 + if (previousPageData && !previousPageData.nextCursor) return null; 27 + if (pageIndex === 0) return ["new-feed", null] as const; 28 + return ["new-feed", previousPageData?.nextCursor] as const; 29 + }; 30 + 31 + const { data, size, setSize, isValidating } = useSWRInfinite( 32 + getKey, 33 + ([_, cursor]) => getNewFeed(cursor), 34 + { 35 + fallbackData: [{ posts, nextCursor }], 36 + revalidateFirstPage: false, 37 + }, 38 + ); 39 + 40 + const loadMoreRef = useRef<HTMLDivElement>(null); 41 + 42 + useEffect(() => { 43 + const observer = new IntersectionObserver( 44 + (entries) => { 45 + if (entries[0].isIntersecting && !isValidating) { 46 + const hasMore = data && data[data.length - 1]?.nextCursor; 47 + if (hasMore) { 48 + setSize(size + 1); 49 + } 50 + } 51 + }, 52 + { threshold: 0.1 }, 53 + ); 54 + 55 + if (loadMoreRef.current) { 56 + observer.observe(loadMoreRef.current); 57 + } 58 + 59 + return () => observer.disconnect(); 60 + }, [data, size, setSize, isValidating]); 61 + 62 + const allPosts = data ? data.flatMap((page) => page.posts) : []; 63 + 64 + if (allPosts.length === 0 && !isValidating) { 65 + return ( 66 + <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary"> 67 + No posts yet. Check back soon! 68 + </div> 69 + ); 70 + } 71 + 72 + return ( 73 + <div className="flex flex-row gap-6 w-full"> 74 + <div className="flex flex-col gap-6 w-full relative"> 75 + {allPosts.map((p) => ( 76 + <PostListing {...p} key={p.documents.uri} /> 77 + ))} 78 + <div 79 + ref={loadMoreRef} 80 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 81 + aria-hidden="true" 82 + /> 83 + {isValidating && ( 84 + <div className="text-center text-tertiary py-4"> 85 + Loading more posts... 86 + </div> 87 + )} 88 + </div> 89 + <DesktopInteractionPreviewDrawer /> 90 + <MobileInteractionPreviewDrawer /> 91 + </div> 92 + ); 93 + };
+138
app/(home-pages)/reader/getHotFeed.ts
··· 1 + "use server"; 2 + 3 + import { drizzle } from "drizzle-orm/node-postgres"; 4 + import { sql } from "drizzle-orm"; 5 + import { pool } from "supabase/pool"; 6 + import Client from "ioredis"; 7 + import { AtUri } from "@atproto/api"; 8 + import { idResolver } from "./idResolver"; 9 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 + import { 11 + normalizeDocumentRecord, 12 + normalizePublicationRecord, 13 + } from "src/utils/normalizeRecords"; 14 + import { supabaseServerClient } from "supabase/serverClient"; 15 + import type { Post } from "./getReaderFeed"; 16 + 17 + let redisClient: Client | null = null; 18 + if (process.env.REDIS_URL && process.env.NODE_ENV === "production") { 19 + redisClient = new Client(process.env.REDIS_URL); 20 + } 21 + 22 + const CACHE_KEY = "hot_feed_v1"; 23 + const CACHE_TTL = 300; // 5 minutes 24 + 25 + export async function getHotFeed(): Promise<{ posts: Post[] }> { 26 + // Check Redis cache 27 + if (redisClient) { 28 + const cached = await redisClient.get(CACHE_KEY); 29 + if (cached) { 30 + return JSON.parse(cached) as { posts: Post[] }; 31 + } 32 + } 33 + 34 + // Run ranked SQL query to get top 50 URIs 35 + const client = await pool.connect(); 36 + const db = drizzle(client); 37 + 38 + let uris: string[]; 39 + try { 40 + const ranked = await db.execute(sql` 41 + SELECT uri 42 + FROM documents 43 + WHERE indexed = true 44 + AND sort_date > now() - interval '7 days' 45 + ORDER BY 46 + (bsky_like_count + recommend_count * 5)::numeric 47 + / power(extract(epoch from (now() - sort_date)) / 3600 + 2, 1.5) DESC 48 + LIMIT 50 49 + `); 50 + uris = ranked.rows.map((row: any) => row.uri as string); 51 + } finally { 52 + client.release(); 53 + } 54 + 55 + if (uris.length === 0) { 56 + return { posts: [] }; 57 + } 58 + 59 + // Batch-fetch documents with publication joins and interaction counts 60 + const { data: documents } = await supabaseServerClient 61 + .from("documents") 62 + .select( 63 + `*, 64 + comments_on_documents(count), 65 + document_mentions_in_bsky(count), 66 + recommends_on_documents(count), 67 + documents_in_publications(publications(*))`, 68 + ) 69 + .in("uri", uris); 70 + 71 + // Build lookup map for enrichment 72 + const docMap = new Map((documents || []).map((d) => [d.uri, d])); 73 + 74 + // Process in ranked order, deduplicating by identity key (DID/rkey) 75 + const seen = new Set<string>(); 76 + const orderedDocs: NonNullable<typeof documents>[number][] = []; 77 + for (const uri of uris) { 78 + try { 79 + const parsed = new AtUri(uri); 80 + const identityKey = `${parsed.host}/${parsed.rkey}`; 81 + if (seen.has(identityKey)) continue; 82 + seen.add(identityKey); 83 + } catch { 84 + // invalid URI, skip dedup check 85 + } 86 + const doc = docMap.get(uri); 87 + if (doc) orderedDocs.push(doc); 88 + } 89 + 90 + // Enrich into Post[] 91 + const posts = ( 92 + await Promise.all( 93 + orderedDocs.map(async (doc) => { 94 + const pub = doc.documents_in_publications?.[0]?.publications; 95 + const uri = new AtUri(doc.uri); 96 + const handle = await idResolver.did.resolve(uri.host); 97 + 98 + const normalizedData = normalizeDocumentRecord(doc.data, doc.uri); 99 + if (!normalizedData) return null; 100 + 101 + const normalizedPubRecord = pub 102 + ? normalizePublicationRecord(pub.record) 103 + : null; 104 + 105 + const post: Post = { 106 + publication: pub 107 + ? { 108 + href: getPublicationURL(pub), 109 + pubRecord: normalizedPubRecord, 110 + uri: pub.uri || "", 111 + } 112 + : undefined, 113 + author: handle?.alsoKnownAs?.[0] 114 + ? `@${handle.alsoKnownAs[0].slice(5)}` 115 + : null, 116 + documents: { 117 + comments_on_documents: doc.comments_on_documents, 118 + document_mentions_in_bsky: doc.document_mentions_in_bsky, 119 + recommends_on_documents: doc.recommends_on_documents, 120 + data: normalizedData, 121 + uri: doc.uri, 122 + sort_date: doc.sort_date, 123 + }, 124 + }; 125 + return post; 126 + }), 127 + ) 128 + ).filter((post): post is Post => post !== null); 129 + 130 + const response = { posts }; 131 + 132 + // Cache in Redis 133 + if (redisClient) { 134 + await redisClient.setex(CACHE_KEY, CACHE_TTL, JSON.stringify(response)); 135 + } 136 + 137 + return response; 138 + }
+91
app/(home-pages)/reader/getNewFeed.ts
··· 1 + "use server"; 2 + 3 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 + import { supabaseServerClient } from "supabase/serverClient"; 5 + import { AtUri } from "@atproto/api"; 6 + import { idResolver } from "./idResolver"; 7 + import { 8 + normalizeDocumentRecord, 9 + normalizePublicationRecord, 10 + } from "src/utils/normalizeRecords"; 11 + import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords"; 12 + import type { Cursor, Post } from "./getReaderFeed"; 13 + 14 + export async function getNewFeed( 15 + cursor?: Cursor | null, 16 + ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { 17 + let query = supabaseServerClient 18 + .from("documents") 19 + .select( 20 + `*, 21 + comments_on_documents(count), 22 + document_mentions_in_bsky(count), 23 + recommends_on_documents(count), 24 + documents_in_publications!inner(publications!inner(*))`, 25 + ) 26 + .or( 27 + "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", 28 + { referencedTable: "documents_in_publications.publications" }, 29 + ) 30 + .order("sort_date", { ascending: false }) 31 + .order("uri", { ascending: false }) 32 + .limit(25); 33 + 34 + if (cursor) { 35 + query = query.or( 36 + `sort_date.lt.${cursor.timestamp},and(sort_date.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 37 + ); 38 + } 39 + 40 + let { data: rawFeed, error } = await query; 41 + 42 + const feed = deduplicateByUriOrdered(rawFeed || []); 43 + 44 + let posts = ( 45 + await Promise.all( 46 + feed.map(async (post) => { 47 + let pub = post.documents_in_publications[0]?.publications!; 48 + let uri = new AtUri(post.uri); 49 + let handle = await idResolver.did.resolve(uri.host); 50 + 51 + const normalizedData = normalizeDocumentRecord(post.data, post.uri); 52 + if (!normalizedData) return null; 53 + 54 + const normalizedPubRecord = normalizePublicationRecord(pub?.record); 55 + 56 + let p: Post = { 57 + publication: { 58 + href: getPublicationURL(pub), 59 + pubRecord: normalizedPubRecord, 60 + uri: pub?.uri || "", 61 + }, 62 + author: handle?.alsoKnownAs?.[0] 63 + ? `@${handle.alsoKnownAs[0].slice(5)}` 64 + : null, 65 + documents: { 66 + comments_on_documents: post.comments_on_documents, 67 + document_mentions_in_bsky: post.document_mentions_in_bsky, 68 + recommends_on_documents: post.recommends_on_documents, 69 + data: normalizedData, 70 + uri: post.uri, 71 + sort_date: post.sort_date, 72 + }, 73 + }; 74 + return p; 75 + }) || [], 76 + ) 77 + ).filter((post): post is Post => post !== null); 78 + 79 + const nextCursor = 80 + posts.length > 0 81 + ? { 82 + timestamp: posts[posts.length - 1].documents.sort_date, 83 + uri: posts[posts.length - 1].documents.uri, 84 + } 85 + : null; 86 + 87 + return { 88 + posts, 89 + nextCursor, 90 + }; 91 + }
+13
app/(home-pages)/reader/hot/page.tsx
··· 1 + import { Suspense } from "react"; 2 + import { getHotFeed } from "../getHotFeed"; 3 + import { GlobalContent } from "../GlobalContent"; 4 + import { FeedSkeleton } from "../FeedSkeleton"; 5 + 6 + export default async function HotPage() { 7 + const feedPromise = getHotFeed(); 8 + return ( 9 + <Suspense fallback={<FeedSkeleton />}> 10 + <GlobalContent promise={feedPromise} /> 11 + </Suspense> 12 + ); 13 + }
+74
app/(home-pages)/reader/layout.tsx
··· 1 + "use client"; 2 + 3 + import { usePathname } from "next/navigation"; 4 + import Link from "next/link"; 5 + import { Header } from "components/PageHeader"; 6 + import { Footer } from "components/ActionBar/Footer"; 7 + import { DesktopNavigation } from "components/ActionBar/DesktopNavigation"; 8 + import { MobileNavigation } from "components/ActionBar/MobileNavigation"; 9 + import { MediaContents } from "components/Media"; 10 + import { DashboardIdContext } from "components/PageLayouts/DashboardLayout"; 11 + 12 + const tabs = [ 13 + { name: "Subs", href: "/reader" }, 14 + { name: "What's Hot", href: "/reader/hot" }, 15 + { name: "New", href: "/reader/new" }, 16 + ]; 17 + 18 + export default function ReaderLayout({ 19 + children, 20 + }: { 21 + children: React.ReactNode; 22 + }) { 23 + const pathname = usePathname(); 24 + 25 + const isActive = (href: string) => { 26 + if (href === "/reader") return pathname === "/reader"; 27 + return pathname.startsWith(href); 28 + }; 29 + 30 + return ( 31 + <DashboardIdContext.Provider value="reader"> 32 + <div className="dashboard pwa-padding relative max-w-(--breakpoint-lg) w-full h-full mx-auto flex sm:flex-row flex-col sm:items-stretch sm:px-6"> 33 + <MediaContents mobile={false}> 34 + <div className="flex flex-col gap-3 my-6"> 35 + <DesktopNavigation currentPage="reader" /> 36 + </div> 37 + </MediaContents> 38 + <div 39 + className="w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-3 px-3 sm:pt-8 sm:pb-3 sm:pl-6 sm:pr-4" 40 + id="home-content" 41 + > 42 + <Header> 43 + <div className="pubDashTabs flex flex-row gap-1"> 44 + {tabs.map((tab) => ( 45 + <Link key={tab.name} href={tab.href}> 46 + <div 47 + className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${ 48 + isActive(tab.href) 49 + ? "text-accent-2 bg-accent-1 font-bold -mb-px" 50 + : "text-tertiary" 51 + }`} 52 + > 53 + {tab.name} 54 + </div> 55 + </Link> 56 + ))} 57 + </div> 58 + <div className="sm:block grow"> 59 + {pathname === "/reader" && ( 60 + <div className="place-self-end text text-tertiary text-sm"> 61 + Publications 62 + </div> 63 + )} 64 + </div> 65 + </Header> 66 + {children} 67 + </div> 68 + <Footer> 69 + <MobileNavigation currentPage="reader" /> 70 + </Footer> 71 + </div> 72 + </DashboardIdContext.Provider> 73 + ); 74 + }
+13
app/(home-pages)/reader/new/page.tsx
··· 1 + import { Suspense } from "react"; 2 + import { getNewFeed } from "../getNewFeed"; 3 + import { NewContent } from "../NewContent"; 4 + import { FeedSkeleton } from "../FeedSkeleton"; 5 + 6 + export default async function NewPage() { 7 + const feedPromise = getNewFeed(); 8 + return ( 9 + <Suspense fallback={<FeedSkeleton />}> 10 + <NewContent promise={feedPromise} /> 11 + </Suspense> 12 + ); 13 + }
+8 -27
app/(home-pages)/reader/page.tsx
··· 1 - import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 1 + import { Suspense } from "react"; 2 + import { getReaderFeed } from "./getReaderFeed"; 2 3 import { InboxContent } from "./InboxContent"; 3 - import { GlobalContent } from "./GlobalContent"; 4 - import { getReaderFeed } from "./getReaderFeed"; 4 + import { FeedSkeleton } from "./FeedSkeleton"; 5 5 6 - export default async function Reader(props: {}) { 7 - let posts = await getReaderFeed(); 6 + export default async function Reader() { 7 + const feedPromise = getReaderFeed(); 8 8 return ( 9 - <DashboardLayout 10 - id="reader" 11 - currentPage="reader" 12 - defaultTab="Subs" 13 - actions={null} 14 - tabs={{ 15 - Subs: { 16 - controls: ( 17 - <div className="place-self-end text text-tertiary text-sm"> 18 - Publications 19 - </div> 20 - ), 21 - content: ( 22 - <InboxContent nextCursor={posts.nextCursor} posts={posts.posts} /> 23 - ), 24 - }, 25 - Global: { 26 - controls: null, 27 - content: <GlobalContent />, 28 - }, 29 - }} 30 - /> 9 + <Suspense fallback={<FeedSkeleton />}> 10 + <InboxContent promise={feedPromise} /> 11 + </Suspense> 31 12 ); 32 13 }
+4 -1
components/PageLayouts/DashboardLayout.tsx
··· 71 71 }, 72 72 })); 73 73 74 - const DashboardIdContext = createContext<string | null>(null); 74 + export const DashboardIdContext = createContext<string | null>(null); 75 75 76 76 export const useDashboardId = () => { 77 77 const id = useContext(DashboardIdContext); ··· 143 143 profileDid?: string; 144 144 actions?: React.ReactNode; 145 145 pageTitle?: string; 146 + onTabHover?: (tabName: string) => void; 146 147 }) { 147 148 const searchParams = useSearchParams(); 148 149 const tabParam = searchParams.get("tab"); ··· 207 208 name={t} 208 209 selected={t === tab} 209 210 onSelect={() => setTabWithUrl(t)} 211 + onMouseEnter={() => props.onTabHover?.(t)} 212 + onPointerDown={() => props.onTabHover?.(t)} 210 213 /> 211 214 ); 212 215 })}
+4
components/Tab.tsx
··· 4 4 name: string; 5 5 selected: boolean; 6 6 onSelect: () => void; 7 + onMouseEnter?: () => void; 8 + onPointerDown?: () => void; 7 9 href?: string; 8 10 }) => { 9 11 return ( 10 12 <div 11 13 className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`} 12 14 onClick={() => props.onSelect()} 15 + onMouseEnter={props.onMouseEnter} 16 + onPointerDown={props.onPointerDown} 13 17 > 14 18 {props.name} 15 19 {props.href && <ExternalLinkTiny />}