ATProto forum built with ESAV

tanstack query

rimar1337 7ba87718 46243fdd

+1460 -1058
+1 -1
index.html
··· 14 14 <title>ForumTest</title> 15 15 </head> 16 16 <body class="bg-gray-900"> 17 - <div id="app" class="overflow-auto h-dvh max-h-dvh"></div> 17 + <div id="app" class="overflow-auto h-dvh max-h-dvh [scrollbar-gutter:stable]"></div> 18 18 <script type="module" src="/src/main.tsx"></script> 19 19 </body> 20 20 </html>
+1 -1
src/components/Header.tsx
··· 7 7 export default function Header(){ 8 8 9 9 10 - return <div className="flex flex-row h-10 items-center px-2 sticky top-0 bg-gray-700 z-50"> 10 + return <div className=" flex flex-row h-10 items-center px-2 sticky top-0 bg-gray-700 z-50"> 11 11 <Link to="/"><span className=" text-gray-50 font-bold">ForumTest</span></Link> 12 12 {/* <div className="spacer flex-1" /> */} 13 13 <SearchBox />
+17
src/helpers/cachedidentityresolver.ts
··· 46 46 set(`handleDid:${data.did}`, JSON.stringify(data)); 47 47 } 48 48 return data; 49 + } 50 + 51 + export async function resolveIdentity({ 52 + didOrHandle, 53 + }: { 54 + didOrHandle: string; 55 + }): Promise<ResolvedIdentity|undefined> { 56 + const isDidInput = didOrHandle.startsWith("did:"); 57 + const url = `https://free-fly-24.deno.dev/?${ 58 + isDidInput 59 + ? `did=${encodeURIComponent(didOrHandle)}` 60 + : `handle=${encodeURIComponent(didOrHandle)}` 61 + }`; 62 + const res = await fetch(url); 63 + if (!res.ok) throw new Error("Failed to resolve handle/did"); 64 + const data = await res.json(); 65 + return data; 49 66 }
+11 -3
src/main.tsx
··· 9 9 import reportWebVitals from "./reportWebVitals.ts"; 10 10 import { AuthProvider } from "./providers/PassAuthProvider.tsx"; 11 11 import { PersistentStoreProvider } from "./providers/PersistentStoreProvider.tsx"; 12 + import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 13 + 14 + const queryClient = new QueryClient(); 12 15 13 16 // Create a new router instance 14 17 const router = createRouter({ 15 18 routeTree, 16 - context: {}, 19 + context: { 20 + queryClient, 21 + }, 17 22 defaultPreload: "intent", 18 23 scrollRestoration: true, 19 24 defaultStructuralSharing: true, ··· 35 40 <StrictMode> 36 41 <PersistentStoreProvider> 37 42 <AuthProvider> 38 - <RouterProvider router={router} /> 43 + <QueryClientProvider client={queryClient}> 44 + {/* Pass the router instance with the context to the provider */} 45 + <RouterProvider router={router} /> 46 + </QueryClientProvider> 39 47 </AuthProvider> 40 48 </PersistentStoreProvider> 41 49 </StrictMode> ··· 45 53 // If you want to start measuring performance in your app, pass a function 46 54 // to log results (for example: reportWebVitals(console.log)) 47 55 // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 48 - reportWebVitals(); 56 + reportWebVitals();
+12 -5
src/routes/__root.tsx
··· 1 - import Header from '@/components/Header' 2 - import { Outlet, createRootRoute } from '@tanstack/react-router' 3 - import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' 1 + import Header from "@/components/Header"; 2 + import type { QueryClient } from "@tanstack/react-query"; 3 + import { 4 + Outlet, 5 + createRootRoute, 6 + createRootRouteWithContext, 7 + } from "@tanstack/react-router"; 8 + import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; 4 9 5 - export const Route = createRootRoute({ 10 + export const Route = createRootRouteWithContext<{ 11 + queryClient: QueryClient; 12 + }>()({ 6 13 component: () => ( 7 14 <> 8 15 <Header /> ··· 10 17 <TanStackRouterDevtools /> 11 18 </> 12 19 ), 13 - }) 20 + });
+166 -152
src/routes/f/$forumHandle.tsx
··· 1 1 import { 2 - cachedResolveIdentity, 2 + resolveIdentity, 3 3 type ResolvedIdentity, 4 4 } from "@/helpers/cachedidentityresolver"; 5 5 import { esavQuery } from "@/helpers/esquery"; 6 - import { usePersistentStore } from "@/providers/PersistentStoreProvider"; 7 - import { createFileRoute, Link, useLoaderData, useNavigate } from "@tanstack/react-router"; 6 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 8 7 import { Outlet } from "@tanstack/react-router"; 9 - import { useEffect, useState } from "react"; 10 - 11 - export const Route = createFileRoute("/f/$forumHandle")({ 12 - loader: ({ params }) => { 13 - console.log("[loader] params.forumHandle:", params.forumHandle); 14 - return { forumHandle: params.forumHandle }; 15 - }, 16 - component: ForumHeader, 17 - }); 8 + import { useState } from "react"; 9 + import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query"; 18 10 19 11 type ForumDoc = { 20 - $type: "com.example.ft.forum.definition"; 21 - $metadata: { 22 - uri: string; 23 - did: string; 24 - }; 12 + "$metadata.uri": string; 13 + "$metadata.cid": string; 14 + "$metadata.did": string; 15 + "$metadata.collection": string; 16 + "$metadata.rkey": string; 17 + "$metadata.indexedAt": string; 25 18 displayName?: string; 26 19 description?: string; 27 20 $raw?: { ··· 30 23 }; 31 24 }; 32 25 26 + type ResolvedForumData = { 27 + forumDoc: ForumDoc; 28 + identity: ResolvedIdentity; 29 + }; 30 + 31 + const forumQueryOptions = (queryClient: QueryClient, forumHandle: string) => ({ 32 + queryKey: ["forum", forumHandle], 33 + queryFn: async (): Promise<ResolvedForumData> => { 34 + if (!forumHandle) { 35 + throw new Error("Forum handle is required."); 36 + } 37 + const normalizedHandle = decodeURIComponent(forumHandle).replace(/^@/, ""); 38 + 39 + const identity = await queryClient.fetchQuery({ 40 + queryKey: ["identity", normalizedHandle], 41 + queryFn: () => resolveIdentity({ didOrHandle: normalizedHandle }), 42 + staleTime: 1000 * 60 * 60 * 24, // 24 hours 43 + }); 44 + 45 + if (!identity) { 46 + throw new Error(`Could not resolve forum handle: @${normalizedHandle}`); 47 + } 48 + 49 + const forumRes = await esavQuery<{ 50 + hits: { hits: { _source: ForumDoc }[] }; 51 + }>({ 52 + query: { 53 + bool: { 54 + must: [ 55 + { term: { "$metadata.did": identity.did } }, 56 + { 57 + term: { 58 + "$metadata.collection": "com.example.ft.forum.definition", 59 + }, 60 + }, 61 + { term: { "$metadata.rkey": "self" } }, 62 + ], 63 + }, 64 + }, 65 + }); 66 + 67 + const forumDoc = forumRes.hits.hits[0]?._source; 68 + if (!forumDoc) { 69 + throw new Error("Forum definition not found."); 70 + } 71 + 72 + return { forumDoc, identity }; 73 + }, 74 + }); 75 + 76 + export const Route = createFileRoute("/f/$forumHandle")({ 77 + loader: ({ context: { queryClient }, params }) => 78 + queryClient.ensureQueryData( 79 + forumQueryOptions(queryClient, params.forumHandle) 80 + ), 81 + component: ForumHeader, 82 + pendingComponent: ForumHeaderContentSkeleton, 83 + errorComponent: ({ error }) => ( 84 + <div className="text-red-500 text-center pt-10"> 85 + Error: {(error as Error).message} 86 + </div> 87 + ), 88 + }); 89 + 33 90 function ForumHeaderContentSkeleton() { 34 91 return ( 35 92 <> ··· 54 111 <div className="flex items-center justify-between pl-3 pr-[6px] py-1.5"> 55 112 <div className="flex flex-wrap items-center gap-3 text-sm"> 56 113 {[...Array(6)].map((_, i) => ( 57 - <div key={i} className="h-5 w-20 bg-gray-700 rounded animate-pulse" /> 114 + <div 115 + key={i} 116 + className="h-5 w-20 bg-gray-700 rounded animate-pulse" 117 + /> 58 118 ))} 59 119 </div> 60 120 <div className="relative w-48"> 61 - <div className="h-[34px] w-full bg-gray-700 rounded-[11px] animate-pulse" /> 121 + <div className="h-[34px] w-full bg-gray-700 rounded-[11px] animate-pulse" /> 62 122 </div> 63 123 </div> 64 124 </div> ··· 103 163 </form> 104 164 ); 105 165 } 106 - function ForumHeaderContent({ forumDoc, identity, forumHandle }:{ forumDoc:ForumDoc, identity: ResolvedIdentity, forumHandle: string }) { 166 + function ForumHeaderContent({ 167 + forumDoc, 168 + identity, 169 + forumHandle, 170 + }: { 171 + forumDoc: ForumDoc; 172 + identity: ResolvedIdentity; 173 + forumHandle: string; 174 + }) { 107 175 const did = identity?.did; 108 176 const bannerCid = forumDoc?.$raw?.banner?.ref?.$link; 109 177 const avatarCid = forumDoc?.$raw?.avatar?.ref?.$link; ··· 118 186 119 187 return ( 120 188 <div className="w-full flex flex-col items-center pt-6"> 121 - <div className="w-full max-w-5xl rounded-2xl bg-gray-800 border border-t-0 shadow-2xl overflow-hidden"> 122 - <div className="relative w-full h-32"> 123 - {bannerUrl ? ( 124 - <div 125 - className="absolute inset-0 bg-cover bg-center" 126 - style={{ backgroundImage: `url(${bannerUrl})` }} 127 - /> 128 - ) : ( 129 - <div className="absolute inset-0 bg-gray-700/50" /> 130 - )} 131 - <div className="absolute inset-0 bg-black/60" /> 132 - <div className="relative z-10 flex items-center p-6 h-full"> 133 - <div className="flex items-center gap-4 max-w-1/2"> 134 - {/*//@ts-ignore */} 135 - <Link to={`/f/${forumHandle}`} className="flex items-center gap-4 no-underline"> 136 - {avatarUrl ? ( 137 - <img 138 - src={avatarUrl} 139 - alt="Forum avatar" 140 - className="w-16 h-16 rounded-full border border-gray-700 object-cover" 141 - /> 142 - ) : ( 143 - <div className="w-16 h-16 rounded-full bg-gray-700 flex items-center justify-center text-gray-400"> 144 - ? 145 - </div> 146 - )} 147 - <div> 148 - <div className="text-white text-3xl font-bold"> 149 - {forumDoc.displayName || "Unnamed Forum"} 150 - </div> 151 - <div className="text-blue-300 font-mono"> 152 - /f/{decodeURIComponent(forumHandle || "")} 153 - </div> 189 + <div className="w-full max-w-5xl rounded-2xl bg-gray-800 border border-t-0 shadow-2xl overflow-hidden"> 190 + <div className="relative w-full h-32"> 191 + {bannerUrl ? ( 192 + <div 193 + className="absolute inset-0 bg-cover bg-center" 194 + style={{ backgroundImage: `url(${bannerUrl})` }} 195 + /> 196 + ) : ( 197 + <div className="absolute inset-0 bg-gray-700/50" /> 198 + )} 199 + <div className="absolute inset-0 bg-black/60" /> 200 + <div className="relative z-10 flex items-center p-6 h-full"> 201 + <div className="flex items-center gap-4 max-w-1/2"> 202 + <Link 203 + //@ts-ignore 204 + to={`/f/${forumHandle}`} 205 + className="flex items-center gap-4 no-underline" 206 + > 207 + {avatarUrl ? ( 208 + <img 209 + src={avatarUrl} 210 + alt="Forum avatar" 211 + className="w-16 h-16 rounded-full border border-gray-700 object-cover" 212 + /> 213 + ) : ( 214 + <div className="w-16 h-16 rounded-full bg-gray-700 flex items-center justify-center text-gray-400"> 215 + ? 154 216 </div> 155 - </Link> 156 - </div> 157 - <div className="ml-auto text-gray-300 text-base text-end max-w-1/2"> 158 - {forumDoc.description || "No description provided."} 159 - </div> 217 + )} 218 + <div> 219 + <div className="text-white text-3xl font-bold"> 220 + {forumDoc.displayName || "Unnamed Forum"} 221 + </div> 222 + <div className="text-blue-300 font-mono"> 223 + /f/{decodeURIComponent(forumHandle || "")} 224 + </div> 225 + </div> 226 + </Link> 160 227 </div> 161 - </div> 162 - 163 - <div className="flex items-center justify-between pl-3 pr-[6px] py-1.5"> 164 - <div className="flex flex-wrap items-center gap-3 text-sm text-gray-300 font-medium"> 165 - {[ 166 - "All Topics", 167 - "Announcements", 168 - "General", 169 - "Support", 170 - "Off-topic", 171 - "Introductions", 172 - "Guides", 173 - "Feedback", 174 - ].map((label) => ( 175 - <button 176 - key={label} 177 - className="hover:underline hover:text-white transition" 178 - onClick={() => console.log(`Clicked ${label}`)} 179 - > 180 - {label} 181 - </button> 182 - ))} 228 + <div className="ml-auto text-gray-300 text-base text-end max-w-1/2"> 229 + {forumDoc.description || "No description provided."} 183 230 </div> 231 + </div> 232 + </div> 184 233 185 - <ForumHeaderSearch /> 234 + <div className="flex items-center justify-between pl-3 pr-[6px] py-1.5"> 235 + <div className="flex flex-wrap items-center gap-3 text-sm text-gray-300 font-medium"> 236 + {[ 237 + "All Topics", 238 + "Announcements", 239 + "General", 240 + "Support", 241 + "Off-topic", 242 + "Introductions", 243 + "Guides", 244 + "Feedback", 245 + ].map((label) => ( 246 + <button 247 + key={label} 248 + className="hover:underline hover:text-white transition" 249 + onClick={() => console.log(`Clicked ${label}`)} 250 + > 251 + {label} 252 + </button> 253 + ))} 186 254 </div> 255 + 256 + <ForumHeaderSearch /> 187 257 </div> 188 258 </div> 259 + </div> 189 260 ); 190 261 } 191 262 192 263 function ForumHeader() { 193 - const { forumHandle } = useLoaderData({ 194 - from: "/f/$forumHandle", 264 + const { forumHandle } = Route.useParams(); 265 + const initialData = Route.useLoaderData(); 266 + const queryClient = useQueryClient(); 267 + 268 + const { data } = useQuery({ 269 + ...forumQueryOptions(queryClient, forumHandle), 270 + initialData, 195 271 }); 196 - const { get, set } = usePersistentStore(); 197 - const [forumDoc, setForumDoc] = useState<ForumDoc | null>(null); 198 - const [error, setError] = useState<string | null>(null); 199 - const [identity, setIdentity] = useState<ResolvedIdentity | null>(null); 200 272 201 - useEffect(() => { 202 - setForumDoc(null); 203 - setError(null); 204 - setIdentity(null); 205 - 206 - async function loadForum() { 207 - if (!forumHandle) return; 208 - 209 - try { 210 - const normalizedHandle = decodeURIComponent(forumHandle).replace( 211 - /^@/, 212 - "" 213 - ); 214 - const identity = await cachedResolveIdentity({ 215 - didOrHandle: normalizedHandle, 216 - get, 217 - set, 218 - }); 219 - setIdentity(identity); 220 - 221 - if (!identity) throw new Error("Could not resolve forum handle"); 222 - const resolvedDid = identity.did; 223 - //setDid(resolvedDid); 224 - 225 - const forumRes = await esavQuery<{ 226 - hits: { hits: { _source: ForumDoc }[] }; 227 - }>({ 228 - query: { 229 - bool: { 230 - must: [ 231 - { term: { "$metadata.did": resolvedDid } }, 232 - { 233 - term: { 234 - "$metadata.collection": "com.example.ft.forum.definition", 235 - }, 236 - }, 237 - { term: { "$metadata.rkey": "self" } }, 238 - ], 239 - }, 240 - }, 241 - }); 242 - 243 - const doc = forumRes.hits.hits[0]?._source; 244 - if (!doc) throw new Error("Forum definition not found."); 245 - 246 - setForumDoc(doc); 247 - } catch (e) { 248 - setError((e as Error).message); 249 - } 250 - } 251 - 252 - loadForum(); 253 - }, [forumHandle, get, set]); 254 - 255 - if (error) return <div className="text-red-500 text-center pt-10">Error: {error}</div>; 273 + const { forumDoc, identity } = data; 256 274 257 275 return ( 258 276 <> 259 - {!forumDoc || !identity ? ( 260 - <ForumHeaderContentSkeleton /> 261 - ) : ( 262 - <ForumHeaderContent 263 - forumDoc={forumDoc} 264 - identity={identity} 265 - forumHandle={forumHandle} 266 - /> 267 - )} 277 + <ForumHeaderContent 278 + forumDoc={forumDoc} 279 + identity={identity} 280 + forumHandle={forumHandle} 281 + /> 268 282 <Outlet /> 269 283 </> 270 284 ); 271 - } 285 + }
+596 -472
src/routes/f/$forumHandle/index.tsx
··· 1 1 import { 2 2 createFileRoute, 3 - useLoaderData, 4 3 useNavigate, 5 4 Link, 5 + useParams, 6 6 } from "@tanstack/react-router"; 7 7 import { useEffect, useState } from "react"; 8 8 import { 9 - cachedResolveIdentity, 9 + resolveIdentity, 10 10 type ResolvedIdentity, 11 11 } from "@/helpers/cachedidentityresolver"; 12 - import { usePersistentStore } from "@/providers/PersistentStoreProvider"; 13 12 import { esavQuery } from "@/helpers/esquery"; 14 13 import * as Select from "@radix-ui/react-select"; 15 14 import * as Dialog from "@radix-ui/react-dialog"; 16 - import { 17 - ChevronDownIcon, 18 - CheckIcon, 19 - Cross2Icon, 20 - } from "@radix-ui/react-icons"; 15 + import { ChevronDownIcon, CheckIcon, Cross2Icon } from "@radix-ui/react-icons"; 21 16 import { useAuth } from "@/providers/PassAuthProvider"; 22 - import { AtUri } from "@atproto/api"; 17 + import { AtUri, BskyAgent } from "@atproto/api"; 18 + import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query"; 23 19 24 20 type PostDoc = { 25 - $type: "com.example.ft.topic.post"; 26 - $metadata: { 27 - uri: string; 28 - did: string; 29 - rkey: string; 30 - indexedAt: string; 31 - }; 21 + "$metadata.uri": string; 22 + "$metadata.cid": string; 23 + "$metadata.did": string; 24 + "$metadata.collection": string; 25 + "$metadata.rkey": string; 26 + "$metadata.indexedAt": string; 32 27 forum: string; 33 28 text: string; 34 29 title: string; 35 30 reply?: any; 31 + }; 32 + 33 + type LatestReply = { 34 + "$metadata.uri": string; 35 + "$metadata.cid": string; 36 + "$metadata.did": string; 37 + "$metadata.collection": string; 38 + "$metadata.rkey": string; 39 + "$metadata.indexedAt": string; 40 + }; 41 + 42 + type TopReaction = { 43 + emoji: string; 44 + count: number; 45 + }; 46 + 47 + type EnrichedPostDoc = PostDoc & { 36 48 participants?: string[]; 37 49 replyCount?: number; 38 - [key: string]: any; 50 + latestReply: LatestReply | null; 51 + topReaction: TopReaction | null; 52 + }; 53 + 54 + type ProfileData = { 55 + did: string; 56 + handle: string | null; 57 + pdsUrl: string | null; 58 + profile: { 59 + displayName?: string; 60 + avatar?: { ref: { $link: string } }; 61 + } | null; 39 62 }; 40 63 41 - export const Route = createFileRoute("/f/$forumHandle/")({ 42 - loader: ({ params }) => ({ forumHandle: params.forumHandle }), 43 - component: Forum, 64 + type TopicListData = { 65 + posts: EnrichedPostDoc[]; 66 + identity: ResolvedIdentity; 67 + profilesMap: Record<string, ProfileData>; 68 + }; 69 + 70 + const topicListQueryOptions = ( 71 + queryClient: QueryClient, 72 + forumHandle: string 73 + ) => ({ 74 + queryKey: ["topics", forumHandle], 75 + queryFn: async (): Promise<TopicListData> => { 76 + const normalizedHandle = decodeURIComponent(forumHandle).replace(/^@/, ""); 77 + 78 + const identity = await queryClient.fetchQuery({ 79 + queryKey: ["identity", normalizedHandle], 80 + queryFn: () => resolveIdentity({ didOrHandle: normalizedHandle }), 81 + staleTime: 1000 * 60 * 60 * 24, // 24 hours 82 + }); 83 + 84 + if (!identity) { 85 + throw new Error(`Could not resolve forum handle: @${normalizedHandle}`); 86 + } 87 + 88 + const postRes = await esavQuery<{ 89 + hits: { hits: { _source: PostDoc }[] }; 90 + }>({ 91 + query: { 92 + bool: { 93 + must: [ 94 + { term: { forum: identity.did } }, 95 + { term: { "$metadata.collection": "com.example.ft.topic.post" } }, 96 + { bool: { must_not: [{ exists: { field: "root" } }] } }, 97 + ], 98 + }, 99 + }, 100 + sort: [{ "$metadata.indexedAt": { order: "desc" } }], 101 + size: 100, 102 + }); 103 + const initialPosts = postRes.hits.hits.map((h) => h._source); 104 + 105 + const postsWithDetails = await Promise.all( 106 + initialPosts.map(async (post) => { 107 + const [repliesRes, latestReplyRes] = await Promise.all([ 108 + esavQuery<{ 109 + hits: { total: { value: number } }; 110 + aggregations: { unique_dids: { buckets: { key: string }[] } }; 111 + }>({ 112 + size: 0, 113 + track_total_hits: true, 114 + query: { 115 + bool: { must: [{ term: { root: post["$metadata.uri"] } }] }, 116 + }, 117 + aggs: { 118 + unique_dids: { terms: { field: "$metadata.did", size: 10000 } }, 119 + }, 120 + }), 121 + esavQuery<{ 122 + hits: { hits: { _source: LatestReply }[] }; 123 + }>({ 124 + query: { 125 + bool: { must: [{ term: { root: post["$metadata.uri"] } }] }, 126 + }, 127 + sort: [{ "$metadata.indexedAt": { order: "desc" } }], 128 + size: 1, 129 + _source: ["$metadata.did", "$metadata.indexedAt"], 130 + }), 131 + ]); 132 + 133 + const replyCount = repliesRes.hits.total.value; 134 + const replyDids = repliesRes.aggregations.unique_dids.buckets.map( 135 + (b) => b.key 136 + ); 137 + const participants = Array.from( 138 + new Set([post["$metadata.did"], ...replyDids]) 139 + ); 140 + const latestReply = latestReplyRes.hits.hits[0]?._source ?? null; 141 + 142 + return { ...post, replyCount, participants, latestReply }; 143 + }) 144 + ); 145 + 146 + const postUris = postsWithDetails.map((p) => p["$metadata.uri"]); 147 + const didsToResolve = new Set<string>(); 148 + postsWithDetails.forEach((p) => { 149 + didsToResolve.add(p["$metadata.did"]); 150 + p.participants?.forEach((did) => didsToResolve.add(did)); 151 + if (p.latestReply) { 152 + didsToResolve.add(p.latestReply["$metadata.did"]); 153 + } 154 + }); 155 + const authorDids = Array.from(didsToResolve); 156 + 157 + const [reactionsRes, pdsProfiles] = await Promise.all([ 158 + esavQuery<{ 159 + hits: { 160 + hits: { 161 + _source: { reactionSubject: string; reactionEmoji: string }; 162 + }[]; 163 + }; 164 + }>({ 165 + query: { 166 + bool: { 167 + must: [ 168 + { 169 + term: { 170 + "$metadata.collection": "com.example.ft.topic.reaction", 171 + }, 172 + }, 173 + { terms: { reactionSubject: postUris } }, 174 + ], 175 + }, 176 + }, 177 + _source: ["reactionSubject", "reactionEmoji"], 178 + size: 10000, 179 + }), 180 + Promise.all( 181 + authorDids.map(async (did) => { 182 + try { 183 + const identityRes = await queryClient.fetchQuery({ 184 + queryKey: ["identity", did], 185 + queryFn: () => resolveIdentity({ didOrHandle: did }), 186 + staleTime: 1000 * 60 * 60 * 24, 187 + }); 188 + 189 + if (!identityRes?.pdsUrl) { 190 + return { 191 + did, 192 + handle: identityRes?.handle ?? null, 193 + pdsUrl: null, 194 + profile: null, 195 + }; 196 + } 197 + 198 + const profileUrl = `${identityRes.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.actor.profile&rkey=self`; 199 + const profileReq = await fetch(profileUrl); 200 + 201 + if (!profileReq.ok) { 202 + console.warn( 203 + `Failed to fetch profile for ${did} from ${identityRes.pdsUrl}` 204 + ); 205 + return { 206 + did, 207 + handle: identityRes.handle, 208 + pdsUrl: identityRes.pdsUrl, 209 + profile: null, 210 + }; 211 + } 212 + 213 + const profileData = await profileReq.json(); 214 + return { 215 + did, 216 + handle: identityRes.handle, 217 + pdsUrl: identityRes.pdsUrl, 218 + profile: profileData.value, 219 + }; 220 + } catch (e) { 221 + console.error(`Error resolving or fetching profile for ${did}`, e); 222 + return { did, handle: null, pdsUrl: null, profile: null }; 223 + } 224 + }) 225 + ), 226 + ]); 227 + 228 + const reactionsByPost: Record<string, Record<string, number>> = {}; 229 + for (const hit of reactionsRes.hits.hits) { 230 + const { reactionSubject, reactionEmoji } = hit._source; 231 + if (!reactionsByPost[reactionSubject]) 232 + reactionsByPost[reactionSubject] = {}; 233 + reactionsByPost[reactionSubject][reactionEmoji] = 234 + (reactionsByPost[reactionSubject][reactionEmoji] || 0) + 1; 235 + } 236 + 237 + const topReactions: Record<string, TopReaction> = {}; 238 + for (const uri in reactionsByPost) { 239 + const counts = reactionsByPost[uri]; 240 + const topEmoji = Object.entries(counts).reduce( 241 + (a, b) => (b[1] > a[1] ? b : a), 242 + ["", 0] 243 + ); 244 + if (topEmoji[0]) { 245 + topReactions[uri] = { emoji: topEmoji[0], count: topEmoji[1] }; 246 + } 247 + } 248 + 249 + const profilesMap: Record<string, ProfileData> = {}; 250 + for (const p of pdsProfiles) { 251 + profilesMap[p.did] = p; 252 + } 253 + 254 + const finalPosts = postsWithDetails.map((post) => ({ 255 + ...post, 256 + topReaction: topReactions[post["$metadata.uri"]] || null, 257 + })); 258 + 259 + return { posts: finalPosts, identity, profilesMap }; 260 + }, 44 261 }); 45 262 46 263 function getRelativeTimeString(input: string | Date): string { ··· 49 266 if (isNaN(date.getTime())) return "invalid date"; 50 267 const diff = (date.getTime() - now.getTime()) / 1000; 51 268 const units: [Intl.RelativeTimeFormatUnit, number][] = [ 52 - ["year", 31536000],["month", 2592000],["week", 604800],["day", 86400],["hour", 3600],["minute", 60],["second", 1], 269 + ["year", 31536000], 270 + ["month", 2592000], 271 + ["week", 604800], 272 + ["day", 86400], 273 + ["hour", 3600], 274 + ["minute", 60], 275 + ["second", 1], 53 276 ]; 54 277 const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); 55 278 for (const [unit, secondsInUnit] of units) { ··· 59 282 return "just now"; 60 283 } 61 284 285 + export const Route = createFileRoute("/f/$forumHandle/")({ 286 + loader: ({ context: { queryClient }, params }) => 287 + queryClient.ensureQueryData( 288 + topicListQueryOptions(queryClient, params.forumHandle) 289 + ), 290 + component: Forum, 291 + pendingComponent: TopicListSkeleton, 292 + errorComponent: ({ error }) => ( 293 + <div className="text-red-500 p-8 text-center"> 294 + Error: {(error as Error).message} 295 + </div> 296 + ), 297 + }); 298 + 62 299 function ForumHeaderSkeleton() { 63 300 return ( 64 301 <div className="flex flex-wrap items-center justify-between mb-4 gap-4 animate-pulse"> ··· 103 340 </td> 104 341 105 342 <td className="px-4 py-3 text-right rounded-r-lg"> 106 - <div className="flex flex-col items-end space-y-1.5"> 107 - <div className="h-4 w-24 bg-gray-700 rounded-md" /> 108 - <div className="h-3 w-20 bg-gray-600 rounded-md" /> 343 + <div className="flex items-center justify-end gap-2"> 344 + <div className="flex flex-col items-end space-y-1.5"> 345 + <div className="h-4 w-24 bg-gray-700 rounded-md" /> 346 + <div className="h-3 w-20 bg-gray-600 rounded-md" /> 347 + </div> 348 + <div className="w-8 h-8 rounded-full bg-gray-700" /> 109 349 </div> 110 350 </td> 111 351 </tr> 112 352 ); 113 353 } 354 + function TopicListSkeleton() { 355 + return ( 356 + <div className="w-full flex flex-col items-center pt-6 px-4"> 357 + <div className="w-full max-w-5xl"> 358 + <ForumHeaderSkeleton /> 359 + <table className="w-full table-auto border-separate border-spacing-y-2"> 360 + <thead> 361 + <tr className="text-left text-sm text-gray-400"> 362 + <th className="px-4 py-2">Topic</th> 363 + <th className="px-4 py-2 text-center">Participants</th> 364 + <th className="px-4 py-2 text-center">Replies</th> 365 + <th className="px-4 py-2 text-center">Reactions</th> 366 + <th className="px-4 py-2 text-right">Last Post</th> 367 + </tr> 368 + </thead> 369 + <tbody> 370 + {Array.from({ length: 10 }).map((_, i) => ( 371 + <TopicRowSkeleton key={i} /> 372 + ))} 373 + </tbody> 374 + </table> 375 + </div> 376 + </div> 377 + ); 378 + } 114 379 115 - export function Forum({ forumHandle: propHandle }: { forumHandle?: string }) { 380 + export function Forum() { 116 381 const navigate = useNavigate(); 117 382 const { agent, loading: authLoading } = useAuth(); 118 - const { forumHandle: routeHandle } = useLoaderData({ from: "/f/$forumHandle/" }); 119 - const forumHandle = propHandle ?? routeHandle; 383 + const { forumHandle } = useParams({ from: "/f/$forumHandle/" }); 384 + 385 + const initialData = Route.useLoaderData(); 386 + const queryClient = useQueryClient(); 120 387 121 - const { get, set } = usePersistentStore(); 122 - const [posts, setPosts] = useState<PostDoc[]>([]); 123 - const [error, setError] = useState<string | null>(null); 124 - const [identity, setIdentity] = useState<ResolvedIdentity | null>(null); 125 - const [participantAvatars, setParticipantAvatars] = useState<Record<string, { avatarCid?: string; pdsUrl: string; handle?: string }>>({}); 126 - const [postAuthors, setPostAuthors] = useState<Record<string, string>>({}); 127 - const [isLoading, setIsLoading] = useState(true); 388 + const { data } = useQuery({ 389 + ...topicListQueryOptions(queryClient, forumHandle), 390 + initialData, 391 + refetchInterval: 1000 * 60, // refresh every minute 392 + }); 393 + 394 + const { posts, identity, profilesMap } = data; 128 395 129 396 const [selectedCategory, setSelectedCategory] = useState("uncategorized"); 130 397 const [sortOrder, setSortOrder] = useState("latest"); ··· 134 401 const [isSubmitting, setIsSubmitting] = useState(false); 135 402 const [formError, setFormError] = useState<string | null>(null); 136 403 137 - useEffect(() => { 138 - async function loadForum() { 139 - if (!forumHandle) return; 140 - 141 - setIsLoading(true); 142 - setPosts([]); 143 - setError(null); 144 - 145 - try { 146 - const normalizedHandle = decodeURIComponent(forumHandle).replace( 147 - /^@/, 148 - "" 149 - ); 150 - const identity = await cachedResolveIdentity({ 151 - didOrHandle: normalizedHandle, 152 - get, 153 - set, 154 - }); 155 - setIdentity(identity); 156 - 157 - if (!identity) throw new Error("Could not resolve forum handle"); 158 - const resolvedDid = identity.did; 159 - 160 - const postRes = await esavQuery<{ 161 - hits: { hits: { _source: PostDoc }[] }; 162 - }>({ 163 - query: { 164 - bool: { 165 - must: [ 166 - { term: { forum: resolvedDid } }, 167 - { 168 - term: { "$metadata.collection": "com.example.ft.topic.post" }, 169 - }, 170 - { bool: { must_not: [{ exists: { field: "root" } }] } }, 171 - ], 172 - }, 173 - }, 174 - sort: [ 175 - { 176 - "$metadata.indexedAt": { 177 - order: "desc", 178 - }, 179 - }, 180 - ], 181 - size: 100, 182 - }); 183 - 184 - const initialPosts = postRes.hits.hits.map((h) => h._source); 185 - 186 - const postsWithReplies = await Promise.all( 187 - initialPosts.map(async (post) => { 188 - const topicUri = post["$metadata.uri"]; 189 - 190 - const repliesRes = await esavQuery<{ 191 - hits: { total: { value: number } }; 192 - aggregations: { 193 - unique_dids: { buckets: { key: string }[] }; 194 - }; 195 - }>({ 196 - size: 0, 197 - track_total_hits: true, 198 - query: { 199 - bool: { 200 - must: [{ term: { root: topicUri } }], 201 - }, 202 - }, 203 - aggs: { 204 - unique_dids: { 205 - terms: { 206 - field: "$metadata.did", 207 - size: 10000, 208 - }, 209 - }, 210 - }, 211 - }); 212 - 213 - const replyCount = repliesRes.hits.total.value; 214 - const replyDids = repliesRes.aggregations.unique_dids.buckets.map( 215 - (bucket) => bucket.key 216 - ); 217 - 218 - const allParticipants = Array.from( 219 - new Set([post["$metadata.did"], ...replyDids]) 220 - ); 221 - 222 - return { 223 - ...post, 224 - replyCount: replyCount, 225 - participants: allParticipants, 226 - }; 227 - }) 228 - ); 229 - 230 - setPosts(postsWithReplies); 231 - 232 - const authorsToResolve = new Set( 233 - // @ts-ignore 234 - postsWithReplies.map((post) => post["$metadata.did"]) 235 - ); 236 - 237 - const participantsToResolve = new Set<string>(); 238 - postsWithReplies.forEach((post) => { 239 - post.participants?.forEach((did) => { 240 - if (did) participantsToResolve.add(did); 241 - }); 242 - }); 243 - 244 - const peopleToResolve = new Set<string>([ 245 - ...authorsToResolve, 246 - ...participantsToResolve, 247 - ]); 248 - 249 - const resolvedAuthors: Record<string, string> = {}; 250 - await Promise.all( 251 - Array.from(peopleToResolve).map(async (did) => { 252 - try { 253 - const identity = await cachedResolveIdentity({ 254 - didOrHandle: did, 255 - get, 256 - set, 257 - }); 258 - if (identity?.handle) resolvedAuthors[did] = identity.handle; 259 - } catch {} 260 - }) 261 - ); 262 - 263 - setPostAuthors(resolvedAuthors); 264 - } catch (e) { 265 - setError((e as Error).message); 266 - } finally { 267 - setIsLoading(false); 268 - } 269 - } 270 - 271 - loadForum(); 272 - }, [forumHandle, get, set]); 273 - 274 - useEffect(() => { 275 - if (!agent || authLoading || posts.length === 0) return; 276 - 277 - const fetchAvatars = async () => { 278 - const participantsToResolve = new Set<string>(); 279 - posts.forEach((post) => { 280 - post.participants?.forEach((did) => { 281 - if (did) participantsToResolve.add(did); 282 - }); 283 - }); 284 - 285 - const avatarMap: Record< 286 - string, 287 - { avatarCid?: string; pdsUrl: string; handle?: string } 288 - > = {}; 289 - 290 - await Promise.all( 291 - Array.from(participantsToResolve).map(async (did) => { 292 - try { 293 - const identity = await cachedResolveIdentity({ 294 - didOrHandle: did, 295 - get, 296 - set, 297 - }); 298 - if (!identity) return; 299 - 300 - let avatarCid: string | undefined; 301 - try { 302 - const profile = await agent.com.atproto.repo.getRecord({ 303 - repo: did, 304 - collection: "app.bsky.actor.profile", 305 - rkey: "self", 306 - }); 307 - const rejason = JSON.parse(JSON.stringify(profile, null, 2)); 308 - avatarCid = rejason.data?.value?.avatar?.ref?.["$link"]; 309 - } catch {} 310 - 311 - avatarMap[did] = { 312 - avatarCid, 313 - pdsUrl: identity.pdsUrl, 314 - handle: identity.handle, 315 - }; 316 - } catch {} 317 - }) 318 - ); 319 - 320 - setParticipantAvatars(avatarMap); 321 - }; 322 - 323 - fetchAvatars(); 324 - }, [agent, authLoading, posts, get, set]); 325 - 326 404 const handleCreateTopic = async () => { 327 405 if (!agent || !agent.did || !identity) { 328 406 setFormError("You must be logged in to create a topic."); ··· 337 415 setFormError(null); 338 416 339 417 try { 340 - const topicRecord = { 341 - $type: "com.example.ft.topic.post", 342 - title: newTopicTitle, 343 - text: newTopicText, 344 - createdAt: new Date().toISOString(), 345 - forum: identity.did, 346 - }; 347 - 348 418 const response = await agent.com.atproto.repo.createRecord({ 349 - repo: agent?.did, 419 + repo: agent.did, 350 420 collection: "com.example.ft.topic.post", 351 - record: topicRecord, 421 + record: { 422 + $type: "com.example.ft.topic.post", 423 + title: newTopicTitle, 424 + text: newTopicText, 425 + createdAt: new Date().toISOString(), 426 + forum: identity.did, 427 + }, 352 428 }); 353 429 354 430 const postUri = new AtUri(response.data.uri); ··· 356 432 setIsModalOpen(false); 357 433 setNewTopicTitle(""); 358 434 setNewTopicText(""); 435 + 436 + queryClient.invalidateQueries({ queryKey: ["topics", forumHandle] }); 437 + 359 438 navigate({ 360 - to: `/f/${forumHandle}/t/${agent?.did}/${postUri.rkey}`, 439 + to: `/f/${forumHandle}/t/${agent.did}/${postUri.rkey}`, 361 440 }); 362 441 } catch (e) { 363 442 console.error("Failed to create topic:", e); ··· 367 446 } 368 447 }; 369 448 370 - if (error) return <div className="text-red-500 p-8">Error: {error}</div>; 371 - 372 449 return ( 373 450 <div className="w-full flex flex-col items-center pt-6 px-4"> 374 451 <div className="w-full max-w-5xl"> 375 - {isLoading ? ( 376 - <ForumHeaderSkeleton /> 377 - ) : ( 378 - <div className="flex flex-wrap items-center justify-between mb-4 gap-4"> 379 - <div className="flex items-center gap-2"> 380 - <span className="text-gray-100 text-sm">Category:</span> 381 - <Select.Root 382 - value={selectedCategory} 383 - onValueChange={setSelectedCategory} 452 + <div className="flex flex-wrap items-center justify-between mb-4 gap-4"> 453 + <div className="flex items-center gap-2"> 454 + <span className="text-gray-100 text-sm">Category:</span> 455 + <Select.Root 456 + value={selectedCategory} 457 + onValueChange={setSelectedCategory} 458 + > 459 + <Select.Trigger 460 + className="inline-flex items-center justify-between rounded-md bg-gray-900 px-3 py-2 text-sm text-gray-100 border border-gray-700 w-[150px] focus:outline-none" 461 + aria-label="Category" 384 462 > 385 - <Select.Trigger 386 - className="inline-flex items-center justify-between rounded-md bg-gray-900 px-3 py-2 text-sm text-gray-100 border border-gray-700 w-[150px] focus:outline-none" 387 - aria-label="Category" 388 - > 389 - <Select.Value placeholder="Select category" /> 390 - <Select.Icon className="text-gray-400"> 391 - <ChevronDownIcon /> 392 - </Select.Icon> 393 - </Select.Trigger> 394 - <Select.Portal> 395 - <Select.Content className="z-50 overflow-hidden rounded-md bg-gray-800 text-gray-100 shadow-lg"> 396 - <Select.Viewport className="p-1"> 397 - {["uncategorized", "general", "meta", "support"].map( 398 - (category) => ( 399 - <Select.Item 400 - key={category} 401 - value={category} 402 - className="flex items-center px-3 py-2 text-sm hover:bg-gray-700 rounded-md cursor-pointer select-none" 403 - > 404 - <Select.ItemIndicator className="mr-2"> 405 - <CheckIcon className="h-3 w-3 text-gray-100" /> 406 - </Select.ItemIndicator> 407 - <Select.ItemText>{category}</Select.ItemText> 408 - </Select.Item> 409 - ) 410 - )} 411 - </Select.Viewport> 412 - </Select.Content> 413 - </Select.Portal> 414 - </Select.Root> 415 - </div> 416 - 417 - <div className="flex items-center gap-2"> 418 - <span className="text-gray-100 text-sm">Sort by:</span> 419 - <Select.Root value={sortOrder} onValueChange={setSortOrder}> 420 - <Select.Trigger 421 - className="inline-flex items-center justify-between rounded-md bg-gray-900 px-3 py-2 text-sm text-gray-100 border border-gray-700 w-[150px] focus:outline-none" 422 - aria-label="Sort" 423 - > 424 - <Select.Value placeholder="Sort by" /> 425 - <Select.Icon className="text-gray-400"> 426 - <ChevronDownIcon /> 427 - </Select.Icon> 428 - </Select.Trigger> 429 - <Select.Portal> 430 - <Select.Content className="z-50 overflow-hidden rounded-md bg-gray-800 text-gray-100 shadow-lg"> 431 - <Select.Viewport className="p-1"> 432 - {["latest", "top", "active", "views"].map((sort) => ( 463 + <Select.Value placeholder="Select category" /> 464 + <Select.Icon className="text-gray-400"> 465 + <ChevronDownIcon /> 466 + </Select.Icon> 467 + </Select.Trigger> 468 + <Select.Portal> 469 + <Select.Content className="z-50 overflow-hidden rounded-md bg-gray-800 text-gray-100 shadow-lg"> 470 + <Select.Viewport className="p-1"> 471 + {["uncategorized", "general", "meta", "support"].map( 472 + (category) => ( 433 473 <Select.Item 434 - key={sort} 435 - value={sort} 474 + key={category} 475 + value={category} 436 476 className="flex items-center px-3 py-2 text-sm hover:bg-gray-700 rounded-md cursor-pointer select-none" 437 477 > 438 478 <Select.ItemIndicator className="mr-2"> 439 479 <CheckIcon className="h-3 w-3 text-gray-100" /> 440 480 </Select.ItemIndicator> 441 - <Select.ItemText>{sort}</Select.ItemText> 481 + <Select.ItemText>{category}</Select.ItemText> 442 482 </Select.Item> 443 - ))} 444 - </Select.Viewport> 445 - </Select.Content> 446 - </Select.Portal> 447 - </Select.Root> 448 - </div> 483 + ) 484 + )} 485 + </Select.Viewport> 486 + </Select.Content> 487 + </Select.Portal> 488 + </Select.Root> 489 + </div> 490 + 491 + <div className="flex items-center gap-2"> 492 + <span className="text-gray-100 text-sm">Sort by:</span> 493 + <Select.Root value={sortOrder} onValueChange={setSortOrder}> 494 + <Select.Trigger 495 + className="inline-flex items-center justify-between rounded-md bg-gray-900 px-3 py-2 text-sm text-gray-100 border border-gray-700 w-[150px] focus:outline-none" 496 + aria-label="Sort" 497 + > 498 + <Select.Value placeholder="Sort by" /> 499 + <Select.Icon className="text-gray-400"> 500 + <ChevronDownIcon /> 501 + </Select.Icon> 502 + </Select.Trigger> 503 + <Select.Portal> 504 + <Select.Content className="z-50 overflow-hidden rounded-md bg-gray-800 text-gray-100 shadow-lg"> 505 + <Select.Viewport className="p-1"> 506 + {["latest", "top", "active", "views"].map((sort) => ( 507 + <Select.Item 508 + key={sort} 509 + value={sort} 510 + className="flex items-center px-3 py-2 text-sm hover:bg-gray-700 rounded-md cursor-pointer select-none" 511 + > 512 + <Select.ItemIndicator className="mr-2"> 513 + <CheckIcon className="h-3 w-3 text-gray-100" /> 514 + </Select.ItemIndicator> 515 + <Select.ItemText>{sort}</Select.ItemText> 516 + </Select.Item> 517 + ))} 518 + </Select.Viewport> 519 + </Select.Content> 520 + </Select.Portal> 521 + </Select.Root> 522 + </div> 449 523 450 - <Dialog.Root open={isModalOpen} onOpenChange={setIsModalOpen}> 451 - <Dialog.Trigger asChild> 452 - <button 453 - className="ml-auto bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-md text-sm font-semibold transition disabled:bg-gray-500" 454 - disabled={!identity} 455 - title={ 456 - !identity ? "Loading forum..." : "Create a new topic" 457 - } 458 - > 459 - + New Topic 460 - </button> 461 - </Dialog.Trigger> 462 - <Dialog.Portal> 463 - <Dialog.Overlay className="bg-black/60 data-[state=open]:animate-overlayShow fixed inset-0 z-50" /> 464 - <Dialog.Content className="data-[state=open]:animate-contentShow fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-[90vw] max-w-lg p-6 bg-gray-800 text-gray-100 rounded-lg shadow-xl focus:outline-none"> 465 - <Dialog.Title className="text-xl font-bold mb-4"> 466 - Create New Topic in @{forumHandle} 467 - </Dialog.Title> 468 - <Dialog.Close asChild> 469 - <button 470 - className="absolute top-4 right-4 text-gray-400 hover:text-white" 471 - aria-label="Close" 472 - > 473 - <Cross2Icon /> 474 - </button> 475 - </Dialog.Close> 524 + <Dialog.Root open={isModalOpen} onOpenChange={setIsModalOpen}> 525 + <Dialog.Trigger asChild> 526 + <button 527 + className="ml-auto bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-md text-sm font-semibold transition disabled:bg-gray-500" 528 + disabled={!identity} 529 + title={!identity ? "Loading forum..." : "Create a new topic"} 530 + > 531 + + New Topic 532 + </button> 533 + </Dialog.Trigger> 534 + <Dialog.Portal> 535 + <Dialog.Overlay className="bg-black/60 data-[state=open]:animate-overlayShow fixed inset-0 z-50" /> 536 + <Dialog.Content className="data-[state=open]:animate-contentShow fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-[90vw] max-w-lg p-6 bg-gray-800 text-gray-100 rounded-lg shadow-xl focus:outline-none"> 537 + <Dialog.Title className="text-xl font-bold mb-4"> 538 + Create New Topic in {forumHandle} 539 + </Dialog.Title> 540 + <Dialog.Close asChild> 541 + <button 542 + className="absolute top-4 right-4 text-gray-400 hover:text-white" 543 + aria-label="Close" 544 + > 545 + <Cross2Icon /> 546 + </button> 547 + </Dialog.Close> 476 548 477 - {!agent || !agent.did ? ( 478 - <div className="text-center py-4"> 479 - <p className="text-gray-300"> 480 - You must be logged in to create a new topic. 481 - </p> 482 - <span className="inline-block mt-4 bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-md font-semibold"> 483 - Log In 484 - </span> 485 - </div> 486 - ) : ( 487 - <form 488 - onSubmit={(e) => { 489 - e.preventDefault(); 490 - handleCreateTopic(); 491 - }} 492 - > 493 - <fieldset disabled={isSubmitting} className="space-y-4"> 494 - <div> 495 - <label 496 - htmlFor="topic-title" 497 - className="text-sm font-medium text-gray-300 block mb-1" 498 - > 499 - Topic Title 500 - </label> 501 - <input 502 - id="topic-title" 503 - value={newTopicTitle} 504 - onChange={(e) => setNewTopicTitle(e.target.value)} 505 - className="w-full p-2 bg-gray-900 border border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none" 506 - placeholder="A short, descriptive title" 507 - required 508 - /> 509 - </div> 510 - <div> 511 - <label 512 - htmlFor="topic-text" 513 - className="text-sm font-medium text-gray-300 block mb-1" 514 - > 515 - Topic Content 516 - </label> 517 - <textarea 518 - id="topic-text" 519 - value={newTopicText} 520 - onChange={(e) => setNewTopicText(e.target.value)} 521 - className="w-full p-2 bg-gray-900 border border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none" 522 - rows={8} 523 - placeholder="Write the main content of your topic here..." 524 - required 525 - /> 526 - </div> 527 - </fieldset> 528 - {formError && ( 529 - <p className="text-red-400 text-sm mt-2"> 530 - {formError} 531 - </p> 532 - )} 533 - <div className="flex justify-end gap-4 mt-6"> 534 - <Dialog.Close asChild> 535 - <button 536 - type="button" 537 - className="px-4 py-2 rounded-md bg-gray-600 hover:bg-gray-500 font-semibold" 538 - disabled={isSubmitting} 539 - > 540 - Cancel 541 - </button> 542 - </Dialog.Close> 549 + {!agent || !agent.did ? ( 550 + <div className="text-center py-4"> 551 + <p className="text-gray-300"> 552 + You must be logged in to create a new topic. 553 + </p> 554 + <span className="inline-block mt-4 bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-md font-semibold"> 555 + Log In 556 + </span> 557 + </div> 558 + ) : ( 559 + <form 560 + onSubmit={(e) => { 561 + e.preventDefault(); 562 + handleCreateTopic(); 563 + }} 564 + > 565 + <fieldset disabled={isSubmitting} className="space-y-4"> 566 + <div> 567 + <label 568 + htmlFor="topic-title" 569 + className="text-sm font-medium text-gray-300 block mb-1" 570 + > 571 + Topic Title 572 + </label> 573 + <input 574 + id="topic-title" 575 + value={newTopicTitle} 576 + onChange={(e) => setNewTopicTitle(e.target.value)} 577 + className="w-full p-2 bg-gray-900 border border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none" 578 + placeholder="A short, descriptive title" 579 + required 580 + /> 581 + </div> 582 + <div> 583 + <label 584 + htmlFor="topic-text" 585 + className="text-sm font-medium text-gray-300 block mb-1" 586 + > 587 + Topic Content 588 + </label> 589 + <textarea 590 + id="topic-text" 591 + value={newTopicText} 592 + onChange={(e) => setNewTopicText(e.target.value)} 593 + className="w-full p-2 bg-gray-900 border border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none" 594 + rows={8} 595 + placeholder="Write the main content of your topic here..." 596 + required 597 + /> 598 + </div> 599 + </fieldset> 600 + {formError && ( 601 + <p className="text-red-400 text-sm mt-2">{formError}</p> 602 + )} 603 + <div className="flex justify-end gap-4 mt-6"> 604 + <Dialog.Close asChild> 543 605 <button 544 - type="submit" 545 - className="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-500 font-semibold disabled:bg-gray-500 disabled:cursor-not-allowed" 546 - disabled={ 547 - isSubmitting || 548 - !newTopicTitle.trim() || 549 - !newTopicText.trim() 550 - } 606 + type="button" 607 + className="px-4 py-2 rounded-md bg-gray-600 hover:bg-gray-500 font-semibold" 608 + disabled={isSubmitting} 551 609 > 552 - {isSubmitting ? "Creating..." : "Create Topic"} 610 + Cancel 553 611 </button> 554 - </div> 555 - </form> 556 - )} 557 - </Dialog.Content> 558 - </Dialog.Portal> 559 - </Dialog.Root> 560 - </div> 561 - )} 612 + </Dialog.Close> 613 + <button 614 + type="submit" 615 + className="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-500 font-semibold disabled:bg-gray-500 disabled:cursor-not-allowed" 616 + disabled={ 617 + isSubmitting || 618 + !newTopicTitle.trim() || 619 + !newTopicText.trim() 620 + } 621 + > 622 + {isSubmitting ? "Creating..." : "Create Topic"} 623 + </button> 624 + </div> 625 + </form> 626 + )} 627 + </Dialog.Content> 628 + </Dialog.Portal> 629 + </Dialog.Root> 630 + </div> 562 631 563 632 <table className="w-full table-auto border-separate border-spacing-y-2"> 564 633 <thead> ··· 566 635 <th className="px-4 py-2">Topic</th> 567 636 <th className="px-4 py-2 text-center">Participants</th> 568 637 <th className="px-4 py-2 text-center">Replies</th> 569 - <th className="px-4 py-2 text-center">Views</th> 638 + <th className="px-4 py-2 text-center">Reactions</th> 570 639 <th className="px-4 py-2 text-right">Last Post</th> 571 640 </tr> 572 641 </thead> 573 642 <tbody> 574 - {isLoading ? ( 575 - Array.from({ length: 10 }).map((_, i) => <TopicRowSkeleton key={i} />) 576 - ) : posts.length > 0 ? ( 577 - posts.map((post) => ( 578 - <tr 579 - onClick={() => 580 - navigate({ 581 - to: `/f/${forumHandle}/t/${post?.["$metadata.did"]}/${post?.["$metadata.rkey"]}`, 582 - }) 583 - } 584 - key={post?.["$metadata.uri"]} 585 - className="bg-gray-800 hover:bg-gray-700/50 rounded-lg cursor-pointer transition-colors duration-150 group relative" 586 - > 587 - <td className="px-4 py-3 text-white rounded-l-lg"> 588 - <Link 589 - // @ts-ignore 590 - to={`/f/${forumHandle}/t/${post?.["$metadata.did"]}/${post?.["$metadata.rkey"]}`} 591 - className="stretched-link" 592 - > 593 - <span className="sr-only">View topic:</span> 594 - </Link> 595 - <div className="font-semibold text-gray-50 line-clamp-1">{post.title}</div> 596 - <div className="text-sm text-gray-400">#general • #meta</div> 597 - </td> 598 - <td className="px-4 py-3"> 599 - <div className="flex -space-x-2 justify-center"> 600 - {post.participants?.slice(0, 5).map((did) => { 601 - const participant = participantAvatars[did]; 602 - const avatarUrl = 603 - participant?.avatarCid && participant?.pdsUrl 604 - ? `${participant.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${participant.avatarCid}` 605 - : undefined; 606 - return ( 607 - avatarUrl ? 643 + {posts.length > 0 ? ( 644 + posts.map((post) => { 645 + const rootAuthorProfile = profilesMap[post["$metadata.did"]]; 646 + 647 + const lastPostAuthorDid = post.latestReply 648 + ? post.latestReply["$metadata.did"] 649 + : post["$metadata.did"]; 650 + const lastPostTimestamp = post.latestReply 651 + ? post.latestReply["$metadata.indexedAt"] 652 + : post["$metadata.indexedAt"]; 653 + const lastPostAuthorProfile = profilesMap[lastPostAuthorDid]; 654 + 655 + const lastPostAuthorAvatar = 656 + lastPostAuthorProfile?.profile?.avatar?.ref?.$link && 657 + lastPostAuthorProfile.pdsUrl 658 + ? `${lastPostAuthorProfile.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${lastPostAuthorDid}&cid=${lastPostAuthorProfile.profile.avatar.ref.$link}` 659 + : undefined; 660 + 661 + return ( 662 + <tr 663 + onClick={() => 664 + navigate({ 665 + to: `/f/${forumHandle}/t/${post["$metadata.did"]}/${post["$metadata.rkey"]}`, 666 + }) 667 + } 668 + key={post["$metadata.uri"]} 669 + className="bg-gray-800 hover:bg-gray-700/50 rounded-lg cursor-pointer transition-colors duration-150 group relative" 670 + > 671 + <td className="px-4 py-3 text-white rounded-l-lg min-w-52"> 672 + <Link 673 + // @ts-ignore 674 + to={`/f/${forumHandle}/t/${post["$metadata.did"]}/${post["$metadata.rkey"]}`} 675 + className="stretched-link" 676 + > 677 + <span className="sr-only">View topic:</span> 678 + </Link> 679 + <div className="font-semibold text-gray-50 line-clamp-1"> 680 + {post.title} 681 + </div> 682 + <div className="text-sm text-gray-400"> 683 + by{" "} 684 + <span className="font-medium text-gray-300"> 685 + {rootAuthorProfile?.handle 686 + ? `@${rootAuthorProfile.handle}` 687 + : rootAuthorProfile?.did.slice(4, 12)} 688 + </span> 689 + , {getRelativeTimeString(post["$metadata.indexedAt"])} 690 + </div> 691 + </td> 692 + <td className="px-4 py-3"> 693 + <div className="flex -space-x-2 justify-center"> 694 + {post.participants?.slice(0, 5).map((did) => { 695 + const participant = profilesMap[did]; 696 + const avatarUrl = 697 + participant?.profile?.avatar?.ref?.$link && 698 + participant?.pdsUrl 699 + ? `${participant.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${participant.profile.avatar.ref.$link}` 700 + : undefined; 701 + return avatarUrl ? ( 608 702 <img 609 703 key={did} 610 704 src={avatarUrl} 611 705 alt={`@${participant?.handle || did.slice(0, 8)}`} 612 706 className="w-6 h-6 rounded-full border-2 border-gray-800 object-cover bg-gray-700" 613 707 title={`@${participant?.handle || did.slice(0, 8)}`} 614 - /> : <div className="w-6 h-6 rounded-full border-2 border-gray-800 bg-gray-700" /> 615 - ); 616 - })} 617 - </div> 618 - </td> 619 - <td className="px-4 py-3 text-center text-gray-100 font-medium"> 620 - {post.replyCount ?? 0} 621 - </td> 622 - <td className="px-4 py-3 text-center text-gray-300 font-medium"> 623 - idk 624 - </td> 625 - <td className="px-4 py-3 text-gray-400 text-right rounded-r-lg"> 626 - <div className="text-sm"> 627 - by{" "} 628 - <span className="text-blue-400 hover:underline"> 629 - {postAuthors[post?.["$metadata.did"]] 630 - ? `@${postAuthors[post?.["$metadata.did"]]}` 631 - : post?.["$metadata.did"].slice(4,12)} 632 - </span> 633 - </div> 634 - <div className="text-xs"> 635 - {getRelativeTimeString(post?.["$metadata.indexedAt"])} 636 - </div> 637 - </td> 638 - </tr> 639 - )) 708 + /> 709 + ) : ( 710 + <div 711 + key={did} 712 + className="w-6 h-6 rounded-full border-2 border-gray-800 bg-gray-700" 713 + title={`@${participant?.handle || did.slice(0, 8)}`} 714 + /> 715 + ); 716 + })} 717 + </div> 718 + </td> 719 + <td className="px-4 py-3 text-center text-gray-100 font-medium"> 720 + {(post.replyCount ?? 0) < 1 ? "-" : post.replyCount} 721 + </td> 722 + <td className="px-4 py-3 text-center text-gray-300 font-medium"> 723 + {post.topReaction ? ( 724 + <div 725 + className="flex items-center justify-center gap-1.5" 726 + title={`${post.topReaction.count} reactions`} 727 + > 728 + <span>{post.topReaction.emoji}</span> 729 + <span className="text-sm font-normal"> 730 + {post.topReaction.count} 731 + </span> 732 + </div> 733 + ) : ( 734 + "-" 735 + )} 736 + </td> 737 + <td className="px-4 py-3 text-gray-400 text-right rounded-r-lg"> 738 + <div className="flex items-center justify-end gap-2"> 739 + <div className="text-right"> 740 + <div className="text-sm font-semibold text-gray-100 line-clamp-1"> 741 + {lastPostAuthorProfile?.profile?.displayName || 742 + (lastPostAuthorProfile?.handle 743 + ? `@${lastPostAuthorProfile.handle}` 744 + : "...")} 745 + </div> 746 + <div className="text-xs"> 747 + {getRelativeTimeString(lastPostTimestamp)} 748 + </div> 749 + </div> 750 + {lastPostAuthorAvatar ? ( 751 + <img 752 + src={lastPostAuthorAvatar} 753 + alt={lastPostAuthorProfile?.profile?.displayName} 754 + className="w-8 h-8 rounded-full object-cover bg-gray-700 shrink-0" 755 + /> 756 + ) : ( 757 + <div className="w-8 h-8 rounded-full bg-gray-700 shrink-0" /> 758 + )} 759 + </div> 760 + </td> 761 + </tr> 762 + ); 763 + }) 640 764 ) : ( 641 765 <tr> 642 766 <td colSpan={5} className="text-center text-gray-500 py-10"> ··· 649 773 </div> 650 774 </div> 651 775 ); 652 - } 776 + }
+223 -202
src/routes/f/$forumHandle/t/$userHandle/$topicRKey.tsx
··· 1 - import { 2 - createFileRoute, 3 - useLoaderData, 4 - Link, 5 - } from "@tanstack/react-router"; 6 - import { useEffect, useMemo, useState, useCallback } from "react"; 1 + import { createFileRoute, Link, useParams } from "@tanstack/react-router"; 2 + import { useMemo, useState } from "react"; 7 3 import { useAuth } from "@/providers/PassAuthProvider"; 8 - import { usePersistentStore } from "@/providers/PersistentStoreProvider"; 9 4 import { esavQuery } from "@/helpers/esquery"; 10 5 import { 11 - cachedResolveIdentity, 6 + resolveIdentity, 12 7 type ResolvedIdentity, 13 8 } from "@/helpers/cachedidentityresolver"; 14 9 import AtpAgent from "@atproto/api"; ··· 20 15 Cross2Icon, 21 16 } from "@radix-ui/react-icons"; 22 17 import * as Popover from "@radix-ui/react-popover"; 18 + import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query"; 23 19 24 20 type PostDoc = { 25 - $type: "com.example.ft.topic.post"; 26 - $metadata: { 27 - uri: string; 28 - cid: string; 29 - did: string; 30 - rkey: string; 31 - indexedAt: string; 32 - }; 21 + "$metadata.uri": string; 22 + "$metadata.cid": string; 23 + "$metadata.did": string; 24 + "$metadata.collection": string; 25 + "$metadata.rkey": string; 26 + "$metadata.indexedAt": string; 33 27 forum: string; 34 28 text: string; 35 29 title?: string; ··· 41 35 }; 42 36 43 37 type ReactionDoc = { 44 - $type: "com.example.ft.topic.reaction"; 38 + "$metadata.uri": string; 39 + "$metadata.cid": string; 40 + "$metadata.did": string; 41 + "$metadata.rkey": string; 42 + "$metadata.indexedAt": string; 45 43 reactionEmoji: string; 46 44 reactionSubject: string; 47 45 }; ··· 52 50 footer?: string; 53 51 }; 54 52 53 + type TopicData = { 54 + posts: PostDoc[]; 55 + authors: Record<string, AuthorInfo>; 56 + reactions: Record<string, ReactionDoc[]>; 57 + }; 58 + 55 59 const EMOJI_SELECTION = ["👍", "❤️", "😂", "🔥", "🤔", "🎉", "🙏", "🤯"]; 56 60 61 + const topicQueryOptions = ( 62 + queryClient: QueryClient, 63 + userHandle: string, 64 + topicRKey: string 65 + ) => ({ 66 + queryKey: ["topic", userHandle, topicRKey], 67 + queryFn: async (): Promise<TopicData> => { 68 + const authorIdentity = await queryClient.fetchQuery({ 69 + queryKey: ["identity", userHandle], 70 + queryFn: () => resolveIdentity({ didOrHandle: userHandle }), 71 + staleTime: 1000 * 60 * 60 * 24, 72 + }); 73 + if (!authorIdentity) throw new Error("Could not find topic author."); 74 + 75 + const topicUri = `at://${authorIdentity.did}/com.example.ft.topic.post/${topicRKey}`; 76 + 77 + const [postRes, repliesRes] = await Promise.all([ 78 + esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({ 79 + query: { term: { "$metadata.uri": topicUri } }, 80 + size: 1, 81 + }), 82 + esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({ 83 + query: { term: { root: topicUri } }, 84 + sort: [{ "$metadata.indexedAt": "asc" }], 85 + size: 100, 86 + }), 87 + ]); 88 + 89 + if (postRes.hits.hits.length === 0) throw new Error("Topic not found."); 90 + const mainPost = postRes.hits.hits[0]._source; 91 + const fetchedReplies = repliesRes.hits.hits.map((h) => h._source); 92 + const allPosts = [mainPost, ...fetchedReplies]; 93 + 94 + const postUris = allPosts.map((p) => p["$metadata.uri"]); 95 + const authorDids = [...new Set(allPosts.map((p) => p["$metadata.did"]))]; 96 + 97 + const [reactionsRes, footersRes, pdsProfiles] = await Promise.all([ 98 + esavQuery<{ hits: { hits: { _source: ReactionDoc }[] } }>({ 99 + query: { 100 + bool: { 101 + must: [ 102 + { 103 + term: { 104 + "$metadata.collection": "com.example.ft.topic.reaction", 105 + }, 106 + }, 107 + { terms: { reactionSubject: postUris } }, 108 + ], 109 + }, 110 + }, 111 + _source: ["reactionSubject", "reactionEmoji"], 112 + size: 1000, 113 + }), 114 + esavQuery<{ 115 + hits: { 116 + hits: { _source: { "$metadata.did": string; footer: string } }[]; 117 + }; 118 + }>({ 119 + query: { 120 + bool: { 121 + must: [ 122 + { term: { $type: "com.example.ft.user.profile" } }, 123 + { terms: { "$metadata.did": authorDids } }, 124 + ], 125 + }, 126 + }, 127 + _source: ["$metadata.did", "footer"], 128 + size: authorDids.length, 129 + }), 130 + Promise.all( 131 + authorDids.map(async (did) => { 132 + try { 133 + const identity = await queryClient.fetchQuery({ 134 + queryKey: ["identity", did], 135 + queryFn: () => resolveIdentity({ didOrHandle: did }), 136 + staleTime: 1000 * 60 * 60 * 24, 137 + }); 138 + 139 + if (!identity?.pdsUrl) { 140 + console.warn( 141 + `Could not resolve PDS for ${did}, cannot fetch profile.` 142 + ); 143 + return { did, profile: null }; 144 + } 145 + 146 + const profileUrl = `${identity.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.actor.profile&rkey=self`; 147 + const profileRes = await fetch(profileUrl); 148 + 149 + if (!profileRes.ok) { 150 + console.warn( 151 + `Failed to fetch profile for ${did} from ${identity.pdsUrl}. Status: ${profileRes.status}` 152 + ); 153 + return { did, profile: null }; 154 + } 155 + 156 + const profileData = await profileRes.json(); 157 + return { did, profile: profileData.value }; 158 + } catch (e) { 159 + console.error( 160 + `Error during decentralized profile fetch for ${did}:`, 161 + e 162 + ); 163 + return { did, profile: null }; 164 + } 165 + }) 166 + ), 167 + ]); 168 + 169 + const reactionsByPostUri = reactionsRes.hits.hits.reduce( 170 + (acc, hit) => { 171 + const reaction = hit._source; 172 + (acc[reaction.reactionSubject] = 173 + acc[reaction.reactionSubject] || []).push(reaction); 174 + return acc; 175 + }, 176 + {} as Record<string, ReactionDoc[]> 177 + ); 178 + 179 + const footersByDid = footersRes.hits.hits.reduce( 180 + (acc, hit) => { 181 + acc[hit._source["$metadata.did"]] = hit._source.footer; 182 + return acc; 183 + }, 184 + {} as Record<string, string> 185 + ); 186 + 187 + const authors: Record<string, AuthorInfo> = {}; 188 + await Promise.all( 189 + authorDids.map(async (did) => { 190 + const identity = await queryClient.fetchQuery({ 191 + queryKey: ["identity", did], 192 + queryFn: () => resolveIdentity({ didOrHandle: did }), 193 + staleTime: 1000 * 60 * 60 * 24, 194 + }); 195 + if (!identity) return; 196 + const pdsProfile = pdsProfiles.find((p) => p.did === did)?.profile; 197 + authors[did] = { 198 + ...identity, 199 + displayName: pdsProfile?.displayName, 200 + avatarCid: pdsProfile?.avatar?.ref?.["$link"], 201 + footer: footersByDid[did], 202 + }; 203 + }) 204 + ); 205 + 206 + return { posts: allPosts, authors, reactions: reactionsByPostUri }; 207 + }, 208 + }); 57 209 export const Route = createFileRoute( 58 210 "/f/$forumHandle/t/$userHandle/$topicRKey" 59 211 )({ 60 - loader: ({ params }) => { 61 - return { 62 - forumHandle: decodeURIComponent(params.forumHandle), 63 - userHandle: decodeURIComponent(params.userHandle), 64 - topicRKey: params.topicRKey, 65 - }; 66 - }, 212 + loader: ({ context: { queryClient }, params }) => 213 + queryClient.ensureQueryData( 214 + topicQueryOptions( 215 + queryClient, 216 + decodeURIComponent(params.userHandle), 217 + params.topicRKey 218 + ) 219 + ), 67 220 component: ForumTopic, 221 + pendingComponent: TopicPageSkeleton, 222 + errorComponent: ({ error }) => ( 223 + <div className="text-center text-red-500 pt-20 text-lg"> 224 + Error: {(error as Error).message} 225 + </div> 226 + ), 68 227 }); 69 228 70 229 export function PostCardSkeleton() { ··· 279 438 } 280 439 281 440 export function ForumTopic() { 282 - const { forumHandle, userHandle, topicRKey } = useLoaderData({ 441 + const { forumHandle, userHandle, topicRKey } = useParams({ 283 442 from: "/f/$forumHandle/t/$userHandle/$topicRKey", 284 443 }); 444 + const { agent, loading: authLoading } = useAuth(); 445 + const queryClient = useQueryClient(); 446 + const initialData = Route.useLoaderData(); 285 447 286 - const { agent, loading: authLoading } = useAuth(); 287 - const { get, set } = usePersistentStore(); 448 + const { data, isError, error } = useQuery({ 449 + ...topicQueryOptions(queryClient, userHandle, topicRKey), 450 + initialData, 451 + refetchInterval: 30 * 1000, // refresh every half minute 452 + }); 288 453 289 - const [posts, setPosts] = useState<PostDoc[]>([]); 290 - const [authors, setAuthors] = useState<Record<string, AuthorInfo>>({}); 291 - const [reactions, setReactions] = useState<Record<string, ReactionDoc[]>>({}); 292 - const [error, setError] = useState<string | null>(null); 293 - const [isLoading, setIsLoading] = useState(true); 454 + const { posts, authors, reactions } = data; 294 455 295 456 const [replyText, setReplyText] = useState(""); 296 457 const [isSubmitting, setIsSubmitting] = useState(false); 297 458 const [replyingTo, setReplyingTo] = useState<PostDoc | null>(null); 298 459 const [isCreatingReaction, setIsCreatingReaction] = useState(false); 299 - 300 - const loadTopic = useCallback(async () => { 301 - setError(null); 302 - try { 303 - const authorIdentity = await cachedResolveIdentity({ 304 - didOrHandle: userHandle, 305 - get, 306 - set, 307 - }); 308 - if (!authorIdentity) throw new Error("Could not find topic author."); 309 - const topicUri = `at://${authorIdentity.did}/com.example.ft.topic.post/${topicRKey}`; 310 - 311 - const [postRes, repliesRes] = await Promise.all([ 312 - esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({ 313 - query: { term: { "$metadata.uri": topicUri } }, 314 - size: 1, 315 - }), 316 - esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({ 317 - query: { term: { root: topicUri } }, 318 - sort: [{ "$metadata.indexedAt": "asc" }], 319 - size: 100, 320 - }), 321 - ]); 322 - 323 - if (postRes.hits.hits.length === 0) throw new Error("Topic not found."); 324 - const mainPost = postRes.hits.hits[0]._source; 325 - const fetchedReplies = repliesRes.hits.hits.map((h) => h._source); 326 - const allPosts = [mainPost, ...fetchedReplies]; 327 - setPosts(allPosts); 328 - 329 - const postUris = allPosts.map((p) => p["$metadata.uri"]); 330 - const authorDids = [...new Set(allPosts.map((p) => p["$metadata.did"]))]; 331 - 332 - const [reactionsRes, footersRes, pdsProfiles] = await Promise.all([ 333 - esavQuery<{ hits: { hits: { _source: ReactionDoc }[] } }>({ 334 - query: { 335 - bool: { 336 - must: [ 337 - { 338 - term: { 339 - "$metadata.collection": "com.example.ft.topic.reaction", 340 - }, 341 - }, 342 - { terms: { reactionSubject: postUris } }, 343 - ], 344 - }, 345 - }, 346 - _source: ["reactionSubject", "reactionEmoji"], 347 - size: 1000, 348 - }), 349 - 350 - esavQuery<{ 351 - hits: { 352 - hits: { _source: { "$metadata.did": string; footer: string } }[]; 353 - }; 354 - }>({ 355 - query: { 356 - bool: { 357 - must: [ 358 - { term: { $type: "com.example.ft.user.profile" } }, 359 - { terms: { "$metadata.did": authorDids } }, 360 - ], 361 - }, 362 - }, 363 - _source: ["$metadata.did", "footer"], 364 - size: authorDids.length, 365 - }), 366 - 367 - Promise.all( 368 - authorDids.map(async (did) => { 369 - if (!agent) return { did, profile: null }; 370 - try { 371 - const res = await agent.com.atproto.repo.getRecord({ 372 - repo: did, 373 - collection: "app.bsky.actor.profile", 374 - rkey: "self", 375 - }); 376 - return { 377 - did, 378 - profile: JSON.parse(JSON.stringify(res.data.value)), 379 - }; 380 - } catch (e) { 381 - return { did, profile: null }; 382 - } 383 - }) 384 - ), 385 - ]); 386 - 387 - const reactionsByPostUri = reactionsRes.hits.hits.reduce( 388 - (acc, hit) => { 389 - const reaction = hit._source; 390 - (acc[reaction.reactionSubject] = acc[reaction.reactionSubject] || []).push(reaction); 391 - return acc; 392 - }, 393 - {} as Record<string, ReactionDoc[]> 394 - ); 395 - setReactions(reactionsByPostUri); 396 - 397 - const footersByDid = footersRes.hits.hits.reduce( 398 - (acc, hit) => { 399 - acc[hit._source["$metadata.did"]] = hit._source.footer; 400 - return acc; 401 - }, 402 - {} as Record<string, string> 403 - ); 404 - 405 - const newAuthors: Record<string, AuthorInfo> = {}; 406 - await Promise.all( 407 - authorDids.map(async (did) => { 408 - const identity = await cachedResolveIdentity({ 409 - didOrHandle: did, 410 - get, 411 - set, 412 - }); 413 - if (!identity) return; 414 - const pdsProfile = pdsProfiles.find((p) => p.did === did)?.profile; 415 - newAuthors[did] = { 416 - ...identity, 417 - displayName: pdsProfile?.displayName, 418 - avatarCid: pdsProfile?.avatar?.ref?.["$link"], 419 - footer: footersByDid[did], 420 - }; 421 - }) 422 - ); 423 - setAuthors(newAuthors); 424 - } catch (e) { 425 - setError((e as Error).message); 426 - } 427 - }, [topicRKey, userHandle, get, set, agent]); 428 - 429 - useEffect(() => { 430 - if (!authLoading) { 431 - setIsLoading(true); 432 - loadTopic().finally(() => setIsLoading(false)); 433 - } 434 - }, [authLoading, loadTopic]); 460 + const [mutationError, setMutationError] = useState<string | null>(null); 435 461 436 462 const handleSetReplyParent = (post: PostDoc) => { 437 463 setReplyingTo(post); 438 464 document.getElementById("reply-box")?.focus(); 439 465 }; 440 466 467 + const invalidateTopicQuery = () => { 468 + queryClient.invalidateQueries({ 469 + queryKey: ["topic", userHandle, topicRKey], 470 + }); 471 + }; 472 + 441 473 const handleCreateReaction = async (post: PostDoc, emoji: string) => { 442 474 if (!agent?.did || isCreatingReaction) return; 443 475 setIsCreatingReaction(true); 444 - const postUri = post["$metadata.uri"]; 476 + setMutationError(null); 445 477 try { 446 478 await agent.com.atproto.repo.createRecord({ 447 479 repo: agent.did, ··· 449 481 record: { 450 482 $type: "com.example.ft.topic.reaction", 451 483 reactionEmoji: emoji, 452 - subject: postUri, 484 + subject: post["$metadata.uri"], 453 485 createdAt: new Date().toISOString(), 454 486 }, 455 487 }); 456 - const newReaction: ReactionDoc = { 457 - $type: "com.example.ft.topic.reaction", 458 - reactionEmoji: emoji, 459 - reactionSubject: postUri, 460 - }; 461 - setReactions((prev) => ({ 462 - ...prev, 463 - [postUri]: [...(prev[postUri] || []), newReaction], 464 - })); 488 + invalidateTopicQuery(); 465 489 } catch (e) { 466 490 console.error("Failed to create reaction", e); 467 - setError("Failed to post reaction. Please try again."); 491 + setMutationError("Failed to post reaction. Please try again."); 468 492 } finally { 469 493 setIsCreatingReaction(false); 470 494 } ··· 474 498 if (!agent?.did || isSubmitting || !replyText.trim() || posts.length === 0) 475 499 return; 476 500 setIsSubmitting(true); 477 - setError(null); 501 + setMutationError(null); 478 502 try { 479 503 const rootPost = posts[0]; 480 - const rootRef = { 481 - uri: rootPost["$metadata.uri"], 482 - cid: rootPost["$metadata.cid"], 483 - }; 484 504 const parentPost = replyingTo || rootPost; 485 - const parentRef = { 486 - uri: parentPost["$metadata.uri"], 487 - cid: parentPost["$metadata.cid"], 488 - }; 489 505 await agent.com.atproto.repo.createRecord({ 490 506 repo: agent.did, 491 507 collection: "com.example.ft.topic.post", ··· 493 509 $type: "com.example.ft.topic.post", 494 510 text: replyText, 495 511 forum: forumHandle, 496 - reply: { root: rootRef, parent: parentRef }, 512 + reply: { 513 + root: { 514 + uri: rootPost["$metadata.uri"], 515 + cid: rootPost["$metadata.cid"], 516 + }, 517 + parent: { 518 + uri: parentPost["$metadata.uri"], 519 + cid: parentPost["$metadata.cid"], 520 + }, 521 + }, 497 522 createdAt: new Date().toISOString(), 498 523 }, 499 524 }); 500 525 setReplyText(""); 501 526 setReplyingTo(null); 502 - await loadTopic(); 527 + invalidateTopicQuery(); 503 528 } catch (e) { 504 - setError(`Failed to post reply: ${(e as Error).message}`); 529 + setMutationError(`Failed to post reply: ${(e as Error).message}`); 505 530 } finally { 506 531 setIsSubmitting(false); 507 532 } 508 533 }; 509 534 510 - if (isLoading) return <TopicPageSkeleton />; 511 - if (error) 535 + if (isError) 512 536 return ( 513 - <div className="text-center text-red-500 pt-20 text-lg"> 514 - Error: {error} 515 - </div> 516 - ); 517 - if (posts.length === 0) 518 - return ( 519 - <div className="text-center text-gray-400 pt-20 text-lg"> 520 - Topic not found. 537 + <div className="text-red-500 p-8 text-center"> 538 + Error: {(error as Error).message} 521 539 </div> 522 540 ); 523 541 ··· 580 598 </div> 581 599 )} 582 600 </div> 601 + {mutationError && ( 602 + <p className="text-red-400 text-sm mb-2">{mutationError}</p> 603 + )} 583 604 <textarea 584 605 id="reply-box" 585 606 value={replyText}
+153 -125
src/routes/index.tsx
··· 1 1 import { createFileRoute, Link } from "@tanstack/react-router"; 2 - import { useEffect, useState } from "react"; 2 + import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query"; 3 3 import "../App.css"; 4 4 import { esavQuery } from "@/helpers/esquery"; 5 - import { usePersistentStore } from "@/providers/PersistentStoreProvider"; 6 - import { cachedResolveIdentity } from "@/helpers/cachedidentityresolver"; 5 + import { resolveIdentity } from "@/helpers/cachedidentityresolver"; 7 6 8 7 type ForumDoc = { 9 - $type: "com.example.ft.forum.definition"; 10 - $metadata: { 11 - collection: string; 12 - uri: string; 13 - did: string; 14 - }; 8 + "$metadata.uri": string; 9 + "$metadata.cid": string; 10 + "$metadata.did": string; 11 + "$metadata.collection": string; 12 + "$metadata.rkey": string; 13 + "$metadata.indexedAt": string; 15 14 displayName?: string; 16 15 description?: string; 17 16 $raw?: { ··· 32 31 }; 33 32 }; 34 33 34 + const forumsQueryOptions = (queryClient: QueryClient) => ({ 35 + queryKey: ["forums", "list"], 36 + queryFn: async (): Promise<ResolvedForum[]> => { 37 + const res = await esavQuery<{ 38 + hits: { hits: { _source: ForumDoc }[] }; 39 + }>({ 40 + query: { 41 + bool: { 42 + must: [ 43 + { 44 + term: { 45 + "$metadata.collection": "com.example.ft.forum.definition", 46 + }, 47 + }, 48 + { term: { "$metadata.rkey": "self" } }, 49 + ], 50 + }, 51 + }, 52 + size: 50, 53 + }); 54 + const rawForums = res.hits.hits.map((hit) => hit._source); 55 + 56 + const resolvedForums = ( 57 + await Promise.all( 58 + rawForums.map(async (forum) => { 59 + const did = forum?.["$metadata.did"]; 60 + if (!did) return null; 61 + 62 + try { 63 + const identity = await queryClient.fetchQuery({ 64 + queryKey: ["identity", did], 65 + queryFn: () => resolveIdentity({ didOrHandle: did }), 66 + staleTime: 1000 * 60 * 60 * 24, 67 + }); 68 + return identity 69 + ? { 70 + ...forum, 71 + resolvedIdentity: { 72 + handle: identity.handle, 73 + pdsUrl: identity.pdsUrl, 74 + }, 75 + } 76 + : null; 77 + } catch (e) { 78 + console.warn(`Failed to resolve identity for ${did}`, e); 79 + return null; 80 + } 81 + }) 82 + ) 83 + ).filter(Boolean) as ResolvedForum[]; 84 + 85 + return resolvedForums; 86 + }, 87 + }); 88 + 35 89 export const Route = createFileRoute("/")({ 90 + loader: ({ context: { queryClient } }) => 91 + queryClient.ensureQueryData(forumsQueryOptions(queryClient)), 36 92 component: Home, 93 + pendingComponent: ForumGridSkeleton, 94 + errorComponent: ({ error }) => ( 95 + <div className="text-red-500 p-4">Error: {(error as Error).message}</div> 96 + ), 37 97 }); 38 98 99 + function ForumGridSkeleton() { 100 + return ( 101 + <div className="w-full flex flex-col items-center"> 102 + <div className="w-full max-w-7xl flex items-center flex-col"> 103 + <div className="w-full max-w-5xl mt-4 px-4 sm:px-0"> 104 + <div> 105 + <span className="text-gray-50 font-bold text-2xl">Forums</span> 106 + </div> 107 + <div className="mt-4 w-full forum-grid"> 108 + {Array.from({ length: 6 }).map((_, i) => ( 109 + <ForumCardSkeleton key={i} /> 110 + ))} 111 + </div> 112 + </div> 113 + </div> 114 + </div> 115 + ); 116 + } 117 + 39 118 function ForumCardSkeleton() { 40 119 return ( 41 120 <div className="relative bg-zinc-900 rounded-2xl overflow-hidden border border-zinc-800 shadow-sm aspect-video animate-pulse"> 42 121 <div className="absolute inset-0 bg-black/60" /> 43 - 44 122 <div className="relative z-10 flex flex-col justify-between h-full p-5"> 45 123 <div className="flex justify-between items-start gap-4"> 46 124 <div className="flex flex-col"> 47 - <div className="h-5 w-40 bg-gray-700 rounded-md mb-2" /> 125 + <div className="h-5 w-40 bg-gray-700 rounded-md mb-2" /> 48 126 <div className="h-7 w-56 bg-gray-700 rounded-md" /> 49 127 </div> 50 128 <div className="w-12 h-12 rounded-full bg-gray-700 flex-shrink-0" /> 51 129 </div> 52 - 53 130 <div className="flex flex-col gap-2 mt-4"> 54 - <div className="h-4 w-full bg-gray-600 rounded-md" /> 131 + <div className="h-4 w-full bg-gray-600 rounded-md" /> 55 132 <div className="h-4 w-3/4 bg-gray-600 rounded-md" /> 56 133 <div className="h-3 w-1/2 bg-gray-700 rounded-md mt-2" /> 57 134 </div> ··· 61 138 } 62 139 63 140 function Home() { 64 - const [forums, setForums] = useState<ResolvedForum[]>([]); 65 - const [loading, setLoading] = useState(true); 66 - const [error, setError] = useState<string | null>(null); 67 - const { get, set } = usePersistentStore(); 68 - 69 - useEffect(() => { 70 - async function fetchForums() { 71 - try { 72 - const res = await esavQuery<{ 73 - hits: { hits: { _source: ForumDoc }[] }; 74 - }>({ 75 - query: { 76 - bool: { 77 - must: [ 78 - { term: { "$metadata.collection": "com.example.ft.forum.definition" } }, 79 - { term: { "$metadata.rkey": "self" } }, 80 - ], 81 - }, 82 - }, 83 - size: 50, 84 - }); 85 - 86 - const rawForums = res.hits.hits.map((hit) => hit._source); 87 - 88 - const resolvedForums = ( 89 - await Promise.all( 90 - rawForums.map(async (forum) => { 91 - const did = forum?.["$metadata.did"]; 92 - if (!did) return null; 93 - try { 94 - const identity = await cachedResolveIdentity({ didOrHandle: did, get, set }); 95 - return identity ? { ...forum, resolvedIdentity: { handle: identity.handle, pdsUrl: identity.pdsUrl } } : null; 96 - } catch (e) { 97 - console.warn(`Failed to resolve identity for ${did}`, e); 98 - return null; 99 - } 100 - }) 101 - ) 102 - ).filter(Boolean) as ResolvedForum[]; 141 + const initialData = Route.useLoaderData(); 142 + const queryClient = useQueryClient(); 103 143 104 - setForums(resolvedForums); 105 - } catch (err) { 106 - setError((err as Error).message); 107 - } finally { 108 - setLoading(false); 109 - } 110 - } 111 - 112 - fetchForums(); 113 - }, [get, set]); 144 + const { data: forums }: { data: ResolvedForum[] } = useQuery({ 145 + ...forumsQueryOptions(queryClient), 146 + initialData, 147 + }); 114 148 115 149 return ( 116 150 <div className="w-full flex flex-col items-center"> ··· 121 155 </div> 122 156 123 157 <div className="mt-4 w-full forum-grid"> 124 - {loading ? ( 125 - Array.from({ length: 6 }).map((_, i) => <ForumCardSkeleton key={i} />) 126 - ) : error ? ( 127 - <div className="text-red-500 col-span-full text-center py-10">Error: {error}</div> 128 - ) : ( 129 - forums.map((forum) => { 130 - const did = forum?.["$metadata.did"]; 131 - const { resolvedIdentity } = forum; 132 - if (!resolvedIdentity) return null; 158 + {forums.map((forum) => { 159 + const did = forum?.["$metadata.did"]; 160 + const { resolvedIdentity } = forum; 161 + if (!resolvedIdentity) return null; 133 162 134 - const cidBanner = forum?.$raw?.banner?.ref?.$link; 135 - const cidAvatar = forum?.$raw?.avatar?.ref?.$link; 163 + const cidBanner = forum?.$raw?.banner?.ref?.$link; 164 + const cidAvatar = forum?.$raw?.avatar?.ref?.$link; 136 165 137 - const bannerUrl = 138 - cidBanner && resolvedIdentity 139 - ? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidBanner}` 140 - : null; 166 + const bannerUrl = 167 + cidBanner && resolvedIdentity 168 + ? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidBanner}` 169 + : null; 141 170 142 - const avatarUrl = 143 - cidAvatar && resolvedIdentity 144 - ? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidAvatar}` 145 - : null; 171 + const avatarUrl = 172 + cidAvatar && resolvedIdentity 173 + ? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidAvatar}` 174 + : null; 146 175 147 - return ( 148 - <Link 176 + return ( 177 + <Link 149 178 // @ts-ignore 150 - to={`/f/@${resolvedIdentity.handle}`} 151 - className="block" 179 + to={`/f/@${resolvedIdentity.handle}`} 180 + className="block" 181 + key={forum?.$metadata?.uri} 182 + > 183 + <div 152 184 key={forum?.$metadata?.uri} 185 + className="relative bg-zinc-900 rounded-2xl overflow-hidden border border-zinc-800 shadow-sm aspect-video hover:border-blue-500/50 transition-all duration-200" 153 186 > 154 - <div 155 - key={forum?.$metadata?.uri} 156 - className="relative bg-zinc-900 rounded-2xl overflow-hidden border border-zinc-800 shadow-sm aspect-video hover:border-blue-500/50 transition-all duration-200" 157 - > 158 - {bannerUrl && ( 159 - <div 160 - className="absolute inset-0 bg-cover bg-center" 161 - style={{ backgroundImage: `url(${bannerUrl})` }} 162 - /> 163 - )} 164 - <div className="absolute inset-0 bg-black/60" /> 165 - <div className="relative z-10 flex flex-col justify-between h-full p-5"> 166 - <div className="flex justify-between items-start gap-4"> 167 - <div className="flex flex-col"> 168 - {resolvedIdentity?.handle && ( 169 - <div className="text-blue-300 text-base font-mono mb-1"> 170 - /f/@{resolvedIdentity.handle} 171 - </div> 172 - )} 173 - <div className="text-white text-2xl font-bold leading-tight"> 174 - {forum.displayName || 'Unnamed Forum'} 187 + {bannerUrl && ( 188 + <div 189 + className="absolute inset-0 bg-cover bg-center" 190 + style={{ backgroundImage: `url(${bannerUrl})` }} 191 + /> 192 + )} 193 + <div className="absolute inset-0 bg-black/60" /> 194 + <div className="relative z-10 flex flex-col justify-between h-full p-5"> 195 + <div className="flex justify-between items-start gap-4"> 196 + <div className="flex flex-col"> 197 + {resolvedIdentity?.handle && ( 198 + <div className="text-blue-300 text-base font-mono mb-1"> 199 + /f/@{resolvedIdentity.handle} 175 200 </div> 201 + )} 202 + <div className="text-white text-2xl font-bold leading-tight"> 203 + {forum.displayName || "Unnamed Forum"} 176 204 </div> 177 - {avatarUrl && ( 178 - <img 179 - src={avatarUrl} 180 - alt="Avatar" 181 - className="w-12 h-12 rounded-full object-cover border border-zinc-700 flex-shrink-0" 182 - /> 183 - )} 205 + </div> 206 + {avatarUrl && ( 207 + <img 208 + src={avatarUrl} 209 + alt="Avatar" 210 + className="w-12 h-12 rounded-full object-cover border border-zinc-700 flex-shrink-0" 211 + /> 212 + )} 213 + </div> 214 + <div className="flex flex-col gap-2 mt-4"> 215 + <div className="text-sm text-gray-200 line-clamp-2"> 216 + {forum.description || "No description available."} 184 217 </div> 185 - <div className="flex flex-col gap-2 mt-4"> 186 - <div className="text-sm text-gray-200 line-clamp-2"> 187 - {forum.description || 'No description available.'} 188 - </div> 189 - <div className="text-xs text-gray-400 font-medium"> 190 - 0 members · ~0 topics · Active a while ago 191 - </div> 218 + <div className="text-xs text-gray-400 font-medium"> 219 + 0 members · ~0 topics · Active a while ago 192 220 </div> 193 221 </div> 194 222 </div> 195 - </Link> 196 - ); 197 - }) 198 - )} 223 + </div> 224 + </Link> 225 + ); 226 + })} 199 227 </div> 200 228 </div> 201 229 </div> 202 230 </div> 203 231 ); 204 - } 232 + }
+280 -97
src/routes/search.tsx
··· 14 14 } from "@/helpers/cachedidentityresolver"; 15 15 import AtpAgent, { AtUri } from "@atproto/api"; 16 16 import { ArrowRightIcon } from "@radix-ui/react-icons"; 17 - import { PostCard, PostCardSkeleton } from "@/routes/f/$forumHandle/t/$userHandle/$topicRKey"; // Adjust path as needed 17 + import { 18 + PostCard, 19 + PostCardSkeleton, 20 + } from "@/routes/f/$forumHandle/t/$userHandle/$topicRKey"; 18 21 19 22 type PostDoc = { 20 - $type: "com.example.ft.topic.post"; 21 - $metadata: { uri: string; cid: string; did: string; rkey: string; indexedAt: string; }; 23 + "$metadata.uri": string; 24 + "$metadata.cid": string; 25 + "$metadata.did": string; 26 + "$metadata.collection": string; 27 + "$metadata.rkey": string; 28 + "$metadata.indexedAt": string; 22 29 forum: string; 23 30 text: string; 24 31 title?: string; 25 - reply?: { root: { uri:string; cid:string; }; parent: { uri:string; cid:string; }; }; 32 + reply?: { 33 + root: { uri: string; cid: string }; 34 + parent: { uri: string; cid: string }; 35 + }; 26 36 [key: string]: any; 27 37 }; 28 38 type ReactionDoc = { 29 - $type: "com.example.ft.topic.reaction"; 39 + "$metadata.uri": string; 40 + "$metadata.cid": string; 41 + "$metadata.did": string; 42 + "$metadata.collection": string; 43 + "$metadata.rkey": string; 44 + "$metadata.indexedAt": string; 30 45 reactionEmoji: string; 31 46 reactionSubject: string; 32 47 }; ··· 37 52 }; 38 53 39 54 export const Route = createFileRoute("/search")({ 40 - validateSearch: (search: Record<string, unknown>) => ({ q: typeof search.q === "string" ? search.q : "" }), 55 + validateSearch: (search: Record<string, unknown>) => ({ 56 + q: typeof search.q === "string" ? search.q : "", 57 + }), 41 58 component: SearchPage, 42 59 }); 43 60 ··· 53 70 54 71 function SearchResultCard({ post, ...rest }: SearchResultCardProps) { 55 72 const navigate = useNavigate(); 56 - const [forumHandle, setForumHandle] = useState<string | undefined>(undefined) 73 + const [forumHandle, setForumHandle] = useState<string | undefined>(undefined); 57 74 const { get, set } = usePersistentStore(); 58 75 59 76 const rootUri = useMemo(() => post.root || post["$metadata.uri"], [post]); 60 77 const postUri = post["$metadata.uri"]; 61 78 62 - const [threadLink, setThreadLink] = useState<{ to: string, hash: string } | null>(null); 79 + const [threadLink, setThreadLink] = useState<{ 80 + to: string; 81 + hash: string; 82 + } | null>(null); 63 83 64 84 useEffect(() => { 65 85 let isCancelled = false; 66 86 const buildLink = async () => { 67 87 try { 68 88 const rootAtUri = new AtUri(rootUri); 69 - const authorIdentity = await cachedResolveIdentity({ didOrHandle: rootAtUri.hostname, get, set }); 70 - setForumHandle("@"+authorIdentity?.handle) 89 + const authorIdentity = await cachedResolveIdentity({ 90 + didOrHandle: rootAtUri.hostname, 91 + get, 92 + set, 93 + }); 94 + setForumHandle("@" + authorIdentity?.handle); 71 95 if (!isCancelled && authorIdentity?.handle) { 72 96 setThreadLink({ 73 - to: '/f/$forumHandle/t/$userHandle/$topicRKey', 97 + to: "/f/$forumHandle/t/$userHandle/$topicRKey", 74 98 hash: postUri, 75 99 }); 76 100 } 77 - } catch(e) { 78 - console.error("Failed to build thread link for search result", e) 101 + } catch (e) { 102 + console.error("Failed to build thread link for search result", e); 79 103 } 80 104 }; 81 105 buildLink(); 82 - return () => { isCancelled = true; }; 106 + return () => { 107 + isCancelled = true; 108 + }; 83 109 }, [rootUri, postUri, get, set]); 84 - 110 + 85 111 const handleNavigateToPost = () => { 86 112 if (!threadLink) return; 87 113 const rootAtUri = new AtUri(rootUri); ··· 89 115 if (!authorIdentity?.handle) return; 90 116 91 117 navigate({ 92 - to: threadLink.to, 93 - params: { 94 - forumHandle: post.forum, 95 - userHandle: authorIdentity.handle, 96 - topicRKey: rootAtUri.rkey, 97 - }, 98 - hash: threadLink.hash 118 + to: threadLink.to, 119 + params: { 120 + forumHandle: post.forum, 121 + userHandle: authorIdentity.handle, 122 + topicRKey: rootAtUri.rkey, 123 + }, 124 + hash: threadLink.hash, 99 125 }); 100 126 }; 101 127 ··· 104 130 <div className="flex justify-between items-center px-4 py-2.5 border-b border-gray-700/50"> 105 131 <span className="text-sm text-gray-400"> 106 132 From forum:{" "} 107 - <Link 133 + <Link 108 134 to="/f/$forumHandle" 109 135 params={{ forumHandle: post.forum }} 110 136 className="font-semibold text-blue-300 hover:underline" ··· 113 139 </Link> 114 140 </span> 115 141 {threadLink ? ( 116 - <Link 117 - to={threadLink.to} 118 - params={{ 119 - forumHandle: post.forum, 120 - userHandle: authors[new AtUri(rootUri).hostname]?.handle || '', // Needs pre-fetched author handle 121 - topicRKey: new AtUri(rootUri).rkey 122 - }} 123 - hash={threadLink.hash} 124 - className="flex items-center gap-2 text-sm font-semibold text-blue-300 hover:text-white transition-colors" 125 - > 126 - View Full Thread <ArrowRightIcon /> 127 - </Link> 142 + <Link 143 + to={threadLink.to} 144 + params={{ 145 + forumHandle: post.forum, 146 + userHandle: authors[new AtUri(rootUri).hostname]?.handle || "", 147 + topicRKey: new AtUri(rootUri).rkey, 148 + }} 149 + hash={threadLink.hash} 150 + className="flex items-center gap-2 text-sm font-semibold text-blue-300 hover:text-white transition-colors" 151 + > 152 + View Full Thread <ArrowRightIcon /> 153 + </Link> 128 154 ) : ( 129 - <span className="flex items-center gap-2 text-sm font-semibold text-gray-500"> 130 - View Full Thread <ArrowRightIcon /> 131 - </span> 155 + <span className="flex items-center gap-2 text-sm font-semibold text-gray-500"> 156 + View Full Thread <ArrowRightIcon /> 157 + </span> 132 158 )} 133 159 </div> 134 160 135 - <PostCard 136 - {...rest} 137 - post={post} 138 - onSetReplyParent={handleNavigateToPost} 139 - /> 161 + <PostCard {...rest} post={post} onSetReplyParent={handleNavigateToPost} /> 140 162 </div> 141 163 ); 142 164 } ··· 148 170 149 171 const { agent, loading: authLoading } = useAuth(); 150 172 const { get, set } = usePersistentStore(); 151 - 173 + 152 174 const [results, setResults] = useState<PostDoc[]>([]); 153 175 const [reactions, setReactions] = useState<Record<string, ReactionDoc[]>>({}); 154 176 const [_authors, setAuthors] = useState<Record<string, AuthorInfo>>({}); 155 177 const [error, setError] = useState<string | null>(null); 156 178 const [isLoading, setIsLoading] = useState(false); 157 179 const [isCreatingReaction, setIsCreatingReaction] = useState(false); 158 - 180 + 159 181 useEffect(() => { 160 182 authors = _authors; 161 183 }, [_authors]); 162 184 163 - const performSearch = useCallback(async (query: string) => { 164 - if (!query.trim()) { setResults([]); return; } 165 - setIsLoading(true); 166 - setError(null); 167 - setResults([]); 168 - setAuthors({}); 169 - setReactions({}); 170 - try { 171 - const searchRes = await esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({ 172 - query: { bool: { must: { multi_match: { query: query, fields: ["text", "title^2"], }, }, filter: [ { term: { "$metadata.collection": "com.example.ft.topic.post" } }, ], }, }, 173 - sort: [ { _score: "desc" }, { "$metadata.indexedAt": "desc" } ], 174 - size: 25, 175 - }); 176 - const foundPosts = searchRes.hits.hits.map((hit) => hit._source); 177 - if (foundPosts.length === 0) { setIsLoading(false); return; } 178 - setResults(foundPosts); 179 - 180 - const allUris = foundPosts.flatMap(p => [p["$metadata.uri"], p.reply?.root.uri]).filter(Boolean) as string[]; 181 - const uniqueUris = [...new Set(allUris)]; 182 - const allDids = [...new Set(uniqueUris.map(uri => new AtUri(uri).hostname))]; 183 - 184 - const [reactionsRes, footersRes, pdsProfiles] = await Promise.all([ 185 - esavQuery<{ hits: { hits: { _source: ReactionDoc }[] } }>({ query: { bool: { must: [{ term: { "$metadata.collection": "com.example.ft.topic.reaction" } }], filter: [{ terms: { reactionSubject: allUris.filter(u => u.includes('post')) } }], }, }, _source: ["reactionSubject", "reactionEmoji"], size: 1000, }), 186 - esavQuery<{ hits: { hits: { _source: { "$metadata.did": string; footer: string } }[] } }>({ query: { bool: { must: [{ term: { $type: "com.example.ft.user.profile" } }], filter: [{ terms: { "$metadata.did": allDids } }], }, }, _source: ["$metadata.did", "footer"], size: allDids.length, }), 187 - Promise.all(allDids.map(async (did) => { if (!agent) return { did, profile: null }; try { const res = await agent.com.atproto.repo.getRecord({ repo: did, collection: "app.bsky.actor.profile", rkey: "self", }); return { did, profile: JSON.parse(JSON.stringify(res.data.value)) }; } catch (e) { return { did, profile: null }; } })), 188 - ]); 189 - 190 - const reactionsByPostUri = reactionsRes.hits.hits.reduce((acc, hit) => { const r = hit._source; (acc[r.reactionSubject] = acc[r.reactionSubject] || []).push(r); return acc; }, {} as Record<string, ReactionDoc[]>); 191 - setReactions(reactionsByPostUri); 185 + const performSearch = useCallback( 186 + async (query: string) => { 187 + if (!query.trim()) { 188 + setResults([]); 189 + return; 190 + } 191 + setIsLoading(true); 192 + setError(null); 193 + setResults([]); 194 + setAuthors({}); 195 + setReactions({}); 196 + try { 197 + const searchRes = await esavQuery<{ 198 + hits: { hits: { _source: PostDoc }[] }; 199 + }>({ 200 + query: { 201 + bool: { 202 + must: { 203 + multi_match: { query: query, fields: ["text", "title^2"] }, 204 + }, 205 + filter: [ 206 + { 207 + term: { "$metadata.collection": "com.example.ft.topic.post" }, 208 + }, 209 + ], 210 + }, 211 + }, 212 + sort: [{ _score: "desc" }, { "$metadata.indexedAt": "desc" }], 213 + size: 25, 214 + }); 215 + const foundPosts = searchRes.hits.hits.map((hit) => hit._source); 216 + if (foundPosts.length === 0) { 217 + setIsLoading(false); 218 + return; 219 + } 220 + setResults(foundPosts); 221 + 222 + const allUris = foundPosts 223 + .flatMap((p) => [p["$metadata.uri"], p.reply?.root.uri]) 224 + .filter(Boolean) as string[]; 225 + const uniqueUris = [...new Set(allUris)]; 226 + const allDids = [ 227 + ...new Set(uniqueUris.map((uri) => new AtUri(uri).hostname)), 228 + ]; 229 + 230 + const [reactionsRes, footersRes, pdsProfiles] = await Promise.all([ 231 + esavQuery<{ hits: { hits: { _source: ReactionDoc }[] } }>({ 232 + query: { 233 + bool: { 234 + must: [ 235 + { 236 + term: { 237 + "$metadata.collection": "com.example.ft.topic.reaction", 238 + }, 239 + }, 240 + ], 241 + filter: [ 242 + { 243 + terms: { 244 + reactionSubject: allUris.filter((u) => 245 + u.includes("post") 246 + ), 247 + }, 248 + }, 249 + ], 250 + }, 251 + }, 252 + _source: ["reactionSubject", "reactionEmoji"], 253 + size: 1000, 254 + }), 255 + esavQuery<{ 256 + hits: { 257 + hits: { _source: { "$metadata.did": string; footer: string } }[]; 258 + }; 259 + }>({ 260 + query: { 261 + bool: { 262 + must: [{ term: { $type: "com.example.ft.user.profile" } }], 263 + filter: [{ terms: { "$metadata.did": allDids } }], 264 + }, 265 + }, 266 + _source: ["$metadata.did", "footer"], 267 + size: allDids.length, 268 + }), 269 + Promise.all( 270 + allDids.map(async (did) => { 271 + if (!agent) return { did, profile: null }; 272 + try { 273 + const res = await agent.com.atproto.repo.getRecord({ 274 + repo: did, 275 + collection: "app.bsky.actor.profile", 276 + rkey: "self", 277 + }); 278 + return { 279 + did, 280 + profile: JSON.parse(JSON.stringify(res.data.value)), 281 + }; 282 + } catch (e) { 283 + return { did, profile: null }; 284 + } 285 + }) 286 + ), 287 + ]); 192 288 193 - const footersByDid = footersRes.hits.hits.reduce((acc, hit) => { acc[hit._source["$metadata.did"]] = hit._source.footer; return acc; }, {} as Record<string, string>); 289 + const reactionsByPostUri = reactionsRes.hits.hits.reduce( 290 + (acc, hit) => { 291 + const r = hit._source; 292 + (acc[r.reactionSubject] = acc[r.reactionSubject] || []).push(r); 293 + return acc; 294 + }, 295 + {} as Record<string, ReactionDoc[]> 296 + ); 297 + setReactions(reactionsByPostUri); 194 298 195 - const newAuthors: Record<string, AuthorInfo> = {}; 196 - await Promise.all(allDids.map(async (did) => { 197 - const identity = await cachedResolveIdentity({ didOrHandle: did, get, set }); 198 - if (!identity) return; 199 - const pdsProfile = pdsProfiles.find((p) => p.did === did)?.profile; 200 - newAuthors[did] = { ...identity, displayName: pdsProfile?.displayName, avatarCid: pdsProfile?.avatar?.ref?.["$link"], footer: footersByDid[did], }; 201 - })); 202 - setAuthors(newAuthors); 203 - } catch (e) { console.error("Search failed:", e); setError("An error occurred during the search."); } finally { setIsLoading(false); } 204 - }, [agent, get, set]); 299 + const footersByDid = footersRes.hits.hits.reduce( 300 + (acc, hit) => { 301 + acc[hit._source["$metadata.did"]] = hit._source.footer; 302 + return acc; 303 + }, 304 + {} as Record<string, string> 305 + ); 306 + 307 + const newAuthors: Record<string, AuthorInfo> = {}; 308 + await Promise.all( 309 + allDids.map(async (did) => { 310 + const identity = await cachedResolveIdentity({ 311 + didOrHandle: did, 312 + get, 313 + set, 314 + }); 315 + if (!identity) return; 316 + const pdsProfile = pdsProfiles.find((p) => p.did === did)?.profile; 317 + newAuthors[did] = { 318 + ...identity, 319 + displayName: pdsProfile?.displayName, 320 + avatarCid: pdsProfile?.avatar?.ref?.["$link"], 321 + footer: footersByDid[did], 322 + }; 323 + }) 324 + ); 325 + setAuthors(newAuthors); 326 + } catch (e) { 327 + console.error("Search failed:", e); 328 + setError("An error occurred during the search."); 329 + } finally { 330 + setIsLoading(false); 331 + } 332 + }, 333 + [agent, get, set] 334 + ); 205 335 206 336 useEffect(() => { 207 337 if (!authLoading) performSearch(q); ··· 212 342 setIsCreatingReaction(true); 213 343 const postUri = post["$metadata.uri"]; 214 344 try { 215 - await agent.com.atproto.repo.createRecord({ repo: agent.did, collection: "com.example.ft.topic.reaction", record: { $type: "com.example.ft.topic.reaction", reactionEmoji: emoji, subject: postUri, createdAt: new Date().toISOString(), }, }); 216 - const newReaction: ReactionDoc = { $type: "com.example.ft.topic.reaction", reactionEmoji: emoji, reactionSubject: postUri }; 217 - setReactions((prev) => ({ ...prev, [postUri]: [...(prev[postUri] || []), newReaction] })); 218 - } catch (e) { console.error("Failed to create reaction", e); setError("Failed to post reaction."); } finally { setIsCreatingReaction(false); } 345 + const date = new Date().toISOString(); 346 + const response = await agent.com.atproto.repo.createRecord({ 347 + repo: agent.did, 348 + collection: "com.example.ft.topic.reaction", 349 + record: { 350 + $type: "com.example.ft.topic.reaction", 351 + reactionEmoji: emoji, 352 + subject: postUri, 353 + createdAt: date, 354 + }, 355 + }); 356 + const uri = new AtUri(response.data.uri) 357 + const newReaction: ReactionDoc = { 358 + "$metadata.collection": "com.example.ft.topic.reaction", 359 + "$metadata.uri": response.data.uri, 360 + "$metadata.cid": response.data.cid, 361 + "$metadata.did": agent.did, 362 + "$metadata.rkey": uri.rkey, 363 + "$metadata.indexedAt": date, 364 + reactionEmoji: emoji, 365 + reactionSubject: postUri, 366 + }; 367 + setReactions((prev) => ({ 368 + ...prev, 369 + [postUri]: [...(prev[postUri] || []), newReaction], 370 + })); 371 + } catch (e) { 372 + console.error("Failed to create reaction", e); 373 + setError("Failed to post reaction."); 374 + } finally { 375 + setIsCreatingReaction(false); 376 + } 219 377 }; 220 378 221 379 const renderContent = () => { 222 - if (isLoading) return <div className="space-y-4">{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</div>; 223 - if (error) return <div className="text-center text-red-400 p-8">{error}</div>; 224 - if (!q.trim()) return <div className="text-center text-gray-400 p-8">Enter a search term to begin.</div>; 225 - if (results.length === 0) return <div className="text-center text-gray-400 p-8">No results found for "{q}".</div>; 380 + if (isLoading) 381 + return ( 382 + <div className="space-y-4"> 383 + {Array.from({ length: 3 }).map((_, i) => ( 384 + <PostCardSkeleton key={i} /> 385 + ))} 386 + </div> 387 + ); 388 + if (error) 389 + return <div className="text-center text-red-400 p-8">{error}</div>; 390 + if (!q.trim()) 391 + return ( 392 + <div className="text-center text-gray-400 p-8"> 393 + Enter a search term to begin. 394 + </div> 395 + ); 396 + if (results.length === 0) 397 + return ( 398 + <div className="text-center text-gray-400 p-8"> 399 + No results found for "{q}". 400 + </div> 401 + ); 226 402 return ( 227 403 <div className="space-y-4"> 228 404 {results.map((post, index) => ( ··· 240 416 </div> 241 417 ); 242 418 }; 243 - 419 + 244 420 return ( 245 421 <div className="w-full flex flex-col items-center pt-6 px-4 pb-12"> 246 422 <div className="w-full max-w-5xl space-y-4"> 247 - <h1 className="text-2xl font-bold text-gray-100 mb-2">Search Results</h1> 248 - {q && <p className="text-gray-400">Showing results for: <span className="font-semibold text-gray-200">"{q}"</span></p>} 423 + <h1 className="text-2xl font-bold text-gray-100 mb-2"> 424 + Search Results 425 + </h1> 426 + {q && ( 427 + <p className="text-gray-400"> 428 + Showing results for:{" "} 429 + <span className="font-semibold text-gray-200">"{q}"</span> 430 + </p> 431 + )} 249 432 <div className="mt-6">{renderContent()}</div> 250 433 </div> 251 434 </div> 252 435 ); 253 - } 436 + }