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) => (
+
+ ))}
+
+ {isValidating && (
+
+ Loading more posts...
+
+ )}
+
+
+
+
+ );
+};
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 (
+
+
+
+ );
+}
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 && }