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 : (