a tool for shared writing and social publishing

add hot feed

+278 -79
+37 -2
app/(home-pages)/reader/GlobalContent.tsx
··· 1 1 "use client"; 2 + import useSWR from "swr"; 3 + import { callRPC } from "app/api/rpc/client"; 4 + import { PostListing } from "components/PostListing"; 5 + import type { Post } from "./getReaderFeed"; 6 + import { 7 + DesktopInteractionPreviewDrawer, 8 + MobileInteractionPreviewDrawer, 9 + } from "./InteractionDrawers"; 2 10 3 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 + }); 16 + 17 + const posts = data?.posts ?? []; 18 + 19 + if (isLoading) { 20 + return ( 21 + <div className="text-center text-tertiary py-8">Loading posts...</div> 22 + ); 23 + } 24 + 25 + if (posts.length === 0) { 26 + return ( 27 + <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"> 28 + Nothing trending right now. Check back soon! 29 + </div> 30 + ); 31 + } 32 + 4 33 return ( 5 - <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"> 6 - Nothing here yet… 34 + <div className="flex flex-row gap-6 w-full"> 35 + <div className="flex flex-col gap-8 w-full"> 36 + {posts.map((p) => ( 37 + <PostListing {...p} key={p.documents.uri} /> 38 + ))} 39 + </div> 40 + <DesktopInteractionPreviewDrawer /> 41 + <MobileInteractionPreviewDrawer /> 7 42 </div> 8 43 ); 9 44 };
+4 -77
app/(home-pages)/reader/InboxContent.tsx
··· 4 4 import type { Cursor, Post } from "./getReaderFeed"; 5 5 import useSWRInfinite from "swr/infinite"; 6 6 import { getReaderFeed } from "./getReaderFeed"; 7 - import { useEffect, useRef, useState } from "react"; 7 + import { useEffect, useRef } from "react"; 8 8 import Link from "next/link"; 9 9 import { PostListing } from "components/PostListing"; 10 10 import { useHasBackgroundImage } from "components/Pages/useHasBackgroundImage"; 11 11 import { 12 - SelectedPostListing, 13 - useSelectedPostListing, 14 - } from "src/useSelectedPostState"; 15 - import { CommentsDrawerContent } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments"; 16 - import { CloseTiny } from "components/Icons/CloseTiny"; 17 - import { SpeedyLink } from "components/SpeedyLink"; 18 - import { GoToArrow } from "components/Icons/GoToArrow"; 12 + DesktopInteractionPreviewDrawer, 13 + MobileInteractionPreviewDrawer, 14 + } from "./InteractionDrawers"; 19 15 20 16 export const InboxContent = (props: { 21 17 posts: Post[]; ··· 107 103 <MobileInteractionPreviewDrawer /> 108 104 </div> 109 105 ); 110 - }; 111 - 112 - const MobileInteractionPreviewDrawer = () => { 113 - let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); 114 - 115 - return ( 116 - <div 117 - className={`z-20 fixed bottom-0 left-0 right-0 border border-border-light shrink-0 w-screen h-[90vh] px-3 bg-bg-leaflet rounded-t-lg overflow-auto ${selectedPost === null ? "hidden" : "block md:hidden "}`} 118 - > 119 - <PreviewDrawerContent selectedPost={selectedPost} /> 120 - </div> 121 - ); 122 - }; 123 - const DesktopInteractionPreviewDrawer = () => { 124 - let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); 125 - 126 - return ( 127 - <div 128 - className={`hidden md:block border border-border-light shrink-0 w-96 mr-2 px-3 h-[calc(100vh-100px)] sticky top-11 bottom-4 right-0 rounded-lg overflow-auto ${selectedPost === null ? "shadow-none border-dashed bg-transparent" : "shadow-md border-border bg-bg-page "}`} 129 - > 130 - <PreviewDrawerContent selectedPost={selectedPost} /> 131 - </div> 132 - ); 133 - }; 134 - 135 - const PreviewDrawerContent = (props: { 136 - selectedPost: SelectedPostListing | null; 137 - }) => { 138 - if (!props.selectedPost || !props.selectedPost.document) return; 139 - 140 - if (props.selectedPost.drawer === "quotes") { 141 - return ( 142 - <> 143 - {/*<MentionsDrawerContent 144 - did={selectedPost.document_uri} 145 - quotesAndMentions={[]} 146 - />*/} 147 - </> 148 - ); 149 - } else 150 - return ( 151 - <> 152 - <div className="w-full text-sm text-tertiary flex justify-between pt-3 gap-3"> 153 - <div className="truncate min-w-0 grow"> 154 - Comments for {props.selectedPost.document.title} 155 - </div> 156 - <button 157 - className="text-tertiary" 158 - onClick={() => 159 - useSelectedPostListing.getState().setSelectedPostListing(null) 160 - } 161 - > 162 - <CloseTiny /> 163 - </button> 164 - </div> 165 - <SpeedyLink 166 - className="shrink-0 flex gap-1 items-center " 167 - href={"/"} 168 - ></SpeedyLink> 169 - <ButtonPrimary fullWidth compact className="text-sm! mt-1"> 170 - See Full Post <GoToArrow /> 171 - </ButtonPrimary> 172 - <CommentsDrawerContent 173 - noCommentBox 174 - document_uri={props.selectedPost.document_uri} 175 - comments={[]} 176 - /> 177 - </> 178 - ); 179 106 }; 180 107 181 108 export const ReaderEmpty = () => {
+80
app/(home-pages)/reader/InteractionDrawers.tsx
··· 1 + "use client"; 2 + import { ButtonPrimary } from "components/Buttons"; 3 + import { 4 + SelectedPostListing, 5 + useSelectedPostListing, 6 + } from "src/useSelectedPostState"; 7 + import { CommentsDrawerContent } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments"; 8 + import { CloseTiny } from "components/Icons/CloseTiny"; 9 + import { SpeedyLink } from "components/SpeedyLink"; 10 + import { GoToArrow } from "components/Icons/GoToArrow"; 11 + 12 + export const MobileInteractionPreviewDrawer = () => { 13 + let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); 14 + 15 + return ( 16 + <div 17 + className={`z-20 fixed bottom-0 left-0 right-0 border border-border-light shrink-0 w-screen h-[90vh] px-3 bg-bg-leaflet rounded-t-lg overflow-auto ${selectedPost === null ? "hidden" : "block md:hidden "}`} 18 + > 19 + <PreviewDrawerContent selectedPost={selectedPost} /> 20 + </div> 21 + ); 22 + }; 23 + 24 + export const DesktopInteractionPreviewDrawer = () => { 25 + let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); 26 + 27 + return ( 28 + <div 29 + className={`hidden md:block border border-border-light shrink-0 w-96 mr-2 px-3 h-[calc(100vh-100px)] sticky top-11 bottom-4 right-0 rounded-lg overflow-auto ${selectedPost === null ? "shadow-none border-dashed bg-transparent" : "shadow-md border-border bg-bg-page "}`} 30 + > 31 + <PreviewDrawerContent selectedPost={selectedPost} /> 32 + </div> 33 + ); 34 + }; 35 + 36 + const PreviewDrawerContent = (props: { 37 + selectedPost: SelectedPostListing | null; 38 + }) => { 39 + if (!props.selectedPost || !props.selectedPost.document) return; 40 + 41 + if (props.selectedPost.drawer === "quotes") { 42 + return ( 43 + <> 44 + {/*<MentionsDrawerContent 45 + did={selectedPost.document_uri} 46 + quotesAndMentions={[]} 47 + />*/} 48 + </> 49 + ); 50 + } else 51 + return ( 52 + <> 53 + <div className="w-full text-sm text-tertiary flex justify-between pt-3 gap-3"> 54 + <div className="truncate min-w-0 grow"> 55 + Comments for {props.selectedPost.document.title} 56 + </div> 57 + <button 58 + className="text-tertiary" 59 + onClick={() => 60 + useSelectedPostListing.getState().setSelectedPostListing(null) 61 + } 62 + > 63 + <CloseTiny /> 64 + </button> 65 + </div> 66 + <SpeedyLink 67 + className="shrink-0 flex gap-1 items-center " 68 + href={"/"} 69 + ></SpeedyLink> 70 + <ButtonPrimary fullWidth compact className="text-sm! mt-1"> 71 + See Full Post <GoToArrow /> 72 + </ButtonPrimary> 73 + <CommentsDrawerContent 74 + noCommentBox 75 + document_uri={props.selectedPost.document_uri} 76 + comments={[]} 77 + /> 78 + </> 79 + ); 80 + };
+148
app/api/rpc/[command]/get_hot_feed.ts
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + import { drizzle } from "drizzle-orm/node-postgres"; 5 + import { sql } from "drizzle-orm"; 6 + import { pool } from "supabase/pool"; 7 + import Client from "ioredis"; 8 + import { AtUri } from "@atproto/api"; 9 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 10 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 11 + import { 12 + normalizeDocumentRecord, 13 + normalizePublicationRecord, 14 + } from "src/utils/normalizeRecords"; 15 + import type { Post } from "app/(home-pages)/reader/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 type GetHotFeedReturnType = Awaited< 26 + ReturnType<(typeof get_hot_feed)["handler"]> 27 + >; 28 + 29 + export const get_hot_feed = makeRoute({ 30 + route: "get_hot_feed", 31 + input: z.object({}), 32 + handler: async ({}, { supabase }: Pick<Env, "supabase">) => { 33 + // Check Redis cache 34 + if (redisClient) { 35 + const cached = await redisClient.get(CACHE_KEY); 36 + if (cached) { 37 + return JSON.parse(cached) as { posts: Post[] }; 38 + } 39 + } 40 + 41 + // Run ranked SQL query to get top 50 URIs 42 + const client = await pool.connect(); 43 + const db = drizzle(client); 44 + 45 + let uris: string[]; 46 + try { 47 + const ranked = await db.execute(sql` 48 + SELECT uri 49 + FROM documents 50 + WHERE indexed = true 51 + AND sort_date > now() - interval '7 days' 52 + ORDER BY 53 + (bsky_like_count + recommend_count * 5)::numeric 54 + / power(extract(epoch from (now() - sort_date)) / 3600 + 2, 1.5) DESC 55 + LIMIT 50 56 + `); 57 + uris = ranked.rows.map((row: any) => row.uri as string); 58 + } finally { 59 + client.release(); 60 + } 61 + 62 + if (uris.length === 0) { 63 + return { posts: [] as Post[] }; 64 + } 65 + 66 + // Batch-fetch documents with publication joins and interaction counts 67 + const { data: documents } = await supabase 68 + .from("documents") 69 + .select( 70 + `*, 71 + comments_on_documents(count), 72 + document_mentions_in_bsky(count), 73 + recommends_on_documents(count), 74 + documents_in_publications(publications(*))`, 75 + ) 76 + .in("uri", uris); 77 + 78 + // Build lookup map for enrichment 79 + const docMap = new Map( 80 + (documents || []).map((d) => [d.uri, d]), 81 + ); 82 + 83 + // Process in ranked order, deduplicating by identity key (DID/rkey) 84 + const seen = new Set<string>(); 85 + const orderedDocs: (typeof documents extends (infer T)[] | null ? T : never)[] = []; 86 + for (const uri of uris) { 87 + try { 88 + const parsed = new AtUri(uri); 89 + const identityKey = `${parsed.host}/${parsed.rkey}`; 90 + if (seen.has(identityKey)) continue; 91 + seen.add(identityKey); 92 + } catch { 93 + // invalid URI, skip dedup check 94 + } 95 + const doc = docMap.get(uri); 96 + if (doc) orderedDocs.push(doc); 97 + } 98 + 99 + // Enrich into Post[] 100 + const posts = ( 101 + await Promise.all( 102 + orderedDocs.map(async (doc) => { 103 + const pub = doc.documents_in_publications?.[0]?.publications; 104 + const uri = new AtUri(doc.uri); 105 + const handle = await idResolver.did.resolve(uri.host); 106 + 107 + const normalizedData = normalizeDocumentRecord(doc.data, doc.uri); 108 + if (!normalizedData) return null; 109 + 110 + const normalizedPubRecord = pub 111 + ? normalizePublicationRecord(pub.record) 112 + : null; 113 + 114 + const post: Post = { 115 + publication: pub 116 + ? { 117 + href: getPublicationURL(pub), 118 + pubRecord: normalizedPubRecord, 119 + uri: pub.uri || "", 120 + } 121 + : undefined, 122 + author: handle?.alsoKnownAs?.[0] 123 + ? `@${handle.alsoKnownAs[0].slice(5)}` 124 + : null, 125 + documents: { 126 + comments_on_documents: doc.comments_on_documents, 127 + document_mentions_in_bsky: doc.document_mentions_in_bsky, 128 + recommends_on_documents: doc.recommends_on_documents, 129 + data: normalizedData, 130 + uri: doc.uri, 131 + sort_date: doc.sort_date, 132 + }, 133 + }; 134 + return post; 135 + }), 136 + ) 137 + ).filter((post): post is Post => post !== null); 138 + 139 + const response = { posts }; 140 + 141 + // Cache in Redis 142 + if (redisClient) { 143 + await redisClient.setex(CACHE_KEY, CACHE_TTL, JSON.stringify(response)); 144 + } 145 + 146 + return response; 147 + }, 148 + });
+2
app/api/rpc/[command]/route.ts
··· 15 15 import { search_publication_documents } from "./search_publication_documents"; 16 16 import { get_profile_data } from "./get_profile_data"; 17 17 import { get_user_recommendations } from "./get_user_recommendations"; 18 + import { get_hot_feed } from "./get_hot_feed"; 18 19 19 20 let supabase = createClient<Database>( 20 21 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 43 44 search_publication_documents, 44 45 get_profile_data, 45 46 get_user_recommendations, 47 + get_hot_feed, 46 48 ]; 47 49 export async function POST( 48 50 req: Request,
+7
components/PostListing.tsx
··· 22 22 import { mergePreferences } from "src/utils/mergePreferences"; 23 23 import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny"; 24 24 import { getDocumentURL } from "app/lish/createPub/getPublicationURL"; 25 + import { RecommendButton } from "./RecommendButton"; 25 26 26 27 export const PostListing = (props: Post) => { 27 28 let pubRecord = props.publication?.pubRecord as ··· 146 147 postUrl={postUrl} 147 148 quotesCount={quotes} 148 149 commentsCount={comments} 150 + recommendsCount={recommends} 149 151 tags={tags} 150 152 showComments={mergedPrefs.showComments !== false} 151 153 showMentions={mergedPrefs.showMentions !== false} ··· 205 207 const Interactions = (props: { 206 208 quotesCount: number; 207 209 commentsCount: number; 210 + recommendsCount: number; 208 211 tags?: string[]; 209 212 postUrl: string; 210 213 showComments: boolean; ··· 228 231 className={`flex gap-2 text-tertiary text-sm items-center justify-between px-1`} 229 232 > 230 233 <div className="postListingsInteractions flex gap-3"> 234 + <RecommendButton 235 + documentUri={props.documentUri} 236 + recommendsCount={props.recommendsCount} 237 + /> 231 238 {!props.showMentions || props.quotesCount === 0 ? null : ( 232 239 <button 233 240 aria-label="Post quotes"