ATProto forum built with ESAV

ESAV Live and new namespace

rimar1337 e82d8d2b 2d2089a7

+11 -11
README.md
··· 12 12 custom record types: 13 13 ```json 14 14 "record_types": [ 15 - "com.example.ft.topic.post", 16 - "com.example.ft.topic.reaction", 17 - "com.example.ft.topic.moderation", 18 - "com.example.ft.forum.definition", 19 - "com.example.ft.forum.layout", 20 - "com.example.ft.forum.request", 21 - "com.example.ft.forum.accept", 22 - "com.example.ft.forum.category" 15 + "party.whey.ft.topic.post", 16 + "party.whey.ft.topic.reaction", 17 + "party.whey.ft.topic.moderation", 18 + "party.whey.ft.forum.definition", 19 + "party.whey.ft.forum.layout", 20 + "party.whey.ft.forum.request", 21 + "party.whey.ft.forum.accept", 22 + "party.whey.ft.forum.category" 23 23 ], 24 24 ``` 25 25 26 26 custom indexes: 27 27 ```json 28 28 "index_fields": { 29 - "com.example.ft.topic.reaction": { 29 + "party.whey.ft.topic.reaction": { 30 30 "subject": { 31 31 "id": "reactionSubject", 32 32 "type": "keyword" ··· 36 36 "type": "keyword" 37 37 } 38 38 }, 39 - "com.example.ft.topic.post": { 39 + "party.whey.ft.topic.post": { 40 40 "text": { 41 41 "id": "text", 42 42 "type": "text" ··· 58 58 "type": "keyword" 59 59 } 60 60 }, 61 - "com.example.ft.forum.definition": { 61 + "party.whey.ft.forum.definition": { 62 62 "description": { 63 63 "id": "description", 64 64 "type": "text"
+30
package-lock.json
··· 18 18 "@tanstack/react-router-devtools": "^1.130.2", 19 19 "@tanstack/router-plugin": "^1.121.2", 20 20 "idb-keyval": "^6.2.2", 21 + "jotai": "^2.13.0", 21 22 "react": "^19.0.0", 22 23 "react-dom": "^19.0.0", 23 24 "tailwindcss": "^4.1.11" ··· 3647 3648 "license": "MIT", 3648 3649 "bin": { 3649 3650 "jiti": "lib/jiti-cli.mjs" 3651 + } 3652 + }, 3653 + "node_modules/jotai": { 3654 + "version": "2.13.0", 3655 + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.13.0.tgz", 3656 + "integrity": "sha512-H43zXdanNTdpfOEJ4NVbm4hgmrctpXLZagjJNcqAywhUv+sTE7esvFjwm5oBg/ywT9Qw63lIkM6fjrhFuW8UDg==", 3657 + "license": "MIT", 3658 + "engines": { 3659 + "node": ">=12.20.0" 3660 + }, 3661 + "peerDependencies": { 3662 + "@babel/core": ">=7.0.0", 3663 + "@babel/template": ">=7.0.0", 3664 + "@types/react": ">=17.0.0", 3665 + "react": ">=17.0.0" 3666 + }, 3667 + "peerDependenciesMeta": { 3668 + "@babel/core": { 3669 + "optional": true 3670 + }, 3671 + "@babel/template": { 3672 + "optional": true 3673 + }, 3674 + "@types/react": { 3675 + "optional": true 3676 + }, 3677 + "react": { 3678 + "optional": true 3679 + } 3650 3680 } 3651 3681 }, 3652 3682 "node_modules/js-tokens": {
+1
package.json
··· 22 22 "@tanstack/react-router-devtools": "^1.130.2", 23 23 "@tanstack/router-plugin": "^1.121.2", 24 24 "idb-keyval": "^6.2.2", 25 + "jotai": "^2.13.0", 25 26 "react": "^19.0.0", 26 27 "react-dom": "^19.0.0", 27 28 "tailwindcss": "^4.1.11"
+111
src/esav/ESAVLiveProvider.tsx
··· 1 + import { useSetAtom, useStore } from "jotai"; 2 + import { useEffect, useRef, type PropsWithChildren } from "react"; 3 + import { addLogEntryAtom, documentsAtom, queryStateFamily, websocketAtom, websocketStatusAtom } from './atoms'; 4 + import type { QueryDeltaMessage } from "./types"; 5 + 6 + export function ESAVLiveProvider({ 7 + children, 8 + url, 9 + }: PropsWithChildren<{ url: string }>) { 10 + const store = useStore(); 11 + const setWebsocket = useSetAtom(websocketAtom); 12 + const setWebsocketStatus = useSetAtom(websocketStatusAtom); 13 + const addLog = useSetAtom(addLogEntryAtom); 14 + 15 + const reconnectTimer = useRef<number | null>(null); 16 + 17 + const isUnmounting = useRef(false); 18 + 19 + useEffect(() => { 20 + let reconnectAttempts = 0; 21 + const connect = () => { 22 + if (isUnmounting.current) return; 23 + 24 + console.log(`[ESAV] Connecting (Attempt ${reconnectAttempts + 1})...`); 25 + setWebsocketStatus("connecting"); 26 + const ws = new WebSocket(url); 27 + 28 + ws.onopen = () => { 29 + console.log("[ESAV] WebSocket connection opened"); 30 + setWebsocketStatus("open"); 31 + setWebsocket(ws); 32 + reconnectAttempts = 0; 33 + if (reconnectTimer.current) { 34 + clearTimeout(reconnectTimer.current); 35 + } 36 + }; 37 + 38 + ws.onmessage = (event) => { 39 + try { 40 + const message = JSON.parse(event.data); 41 + 42 + if (message.type === "query-delta") { 43 + addLog({ type: 'incoming', payload: message }); 44 + const deltaMessage = message as QueryDeltaMessage; 45 + const { documents, queries } = deltaMessage 46 + 47 + if (documents) { 48 + store.set(documentsAtom, (prev) => ({ ...prev, ...documents })); 49 + } 50 + 51 + if (queries) { 52 + for (const queryId in queries) { 53 + const targetQueryAtom = queryStateFamily(queryId); 54 + store.set(targetQueryAtom, queries[queryId]); 55 + } 56 + } 57 + } else if (message.type === "ping") { 58 + ws.send(JSON.stringify({ type: "pong" })); 59 + } else if (message.type === "error") { 60 + addLog({ type: 'incoming', payload: message }); 61 + console.error("[ESAV] Received error from server:", message.error); 62 + } 63 + } catch (e) { 64 + console.error("[ESAV] Failed to parse message from server", e); 65 + } 66 + }; 67 + ws.onclose = () => { 68 + console.log("[ESAV] WebSocket connection closed"); 69 + setWebsocket(null); 70 + if (isUnmounting.current) { 71 + console.log("[ESAV] Unmounting, not reconnecting."); 72 + return; 73 + } 74 + 75 + setWebsocketStatus("closed"); 76 + 77 + const delay = Math.min(1000 * 2 ** reconnectAttempts, 30000); 78 + console.log(`[ESAV] Will attempt to reconnect in ${delay / 1000}s`); 79 + reconnectAttempts++; 80 + 81 + if (reconnectTimer.current) clearTimeout(reconnectTimer.current); 82 + reconnectTimer.current = setTimeout(connect, delay); 83 + }; 84 + 85 + ws.onerror = (err) => { 86 + console.error("[ESAV] WebSocket error", err); 87 + ws.close(); 88 + }; 89 + }; 90 + 91 + isUnmounting.current = false; 92 + connect(); 93 + 94 + return () => { 95 + isUnmounting.current = true; 96 + console.log( 97 + "[ESAV] Provider unmounting. Cleaning up timers and connection." 98 + ); 99 + if (reconnectTimer.current) { 100 + clearTimeout(reconnectTimer.current); 101 + } 102 + const currentWs = store.get(websocketAtom); 103 + if (currentWs) { 104 + currentWs.onclose = null; 105 + currentWs.close(); 106 + } 107 + }; 108 + }, [url, store, setWebsocket, setWebsocketStatus]); 109 + 110 + return <>{children}</>; 111 + }
+69
src/esav/atoms.ts
··· 1 + import { atom } from 'jotai'; 2 + import { atomFamily } from 'jotai/utils'; 3 + import type { EsavDocument, QueryState, LogEntry } from './types'; 4 + const MAX_LOG_SIZE = 500; 5 + 6 + /** 7 + * Manages the WebSocket instance itself. 8 + * Should only be written to by the provider. 9 + */ 10 + export const websocketAtom = atom<WebSocket | null>(null); 11 + 12 + /** 13 + * Tracks the current status of the WebSocket connection. 14 + */ 15 + export const websocketStatusAtom = atom<'connecting' | 'open' | 'closed'>('closed'); 16 + 17 + /** 18 + * A global, normalized cache for all documents received from the server. 19 + * Maps a document URI (at://...) to its full data. 20 + * This prevents data duplication across multiple queries. 21 + */ 22 + export const documentsAtom = atom<Record<string, EsavDocument>>({}); 23 + 24 + /** 25 + * A family of atoms to hold the state for each individual query. 26 + * You get the state for a query by providing its unique queryId. 27 + */ 28 + export const queryStateFamily = atomFamily((_queryId: string) => 29 + atom<QueryState | null>(null) 30 + ); 31 + 32 + /** 33 + * Tracks active subscriptions and their component usage count. 34 + * This is an internal atom used by our hooks to know when to 35 + * send `subscribe` and `unsubscribe` messages. 36 + */ 37 + export const activeSubscriptionsAtom = atom< 38 + Record<string, { count: number; esQuery: Record<string, any> }> 39 + >({}); 40 + 41 + 42 + /** 43 + * Holds the array of log entries for display. 44 + */ 45 + export const logEntriesAtom = atom<LogEntry[]>([]); 46 + 47 + let logIdCounter = 0; 48 + 49 + /** 50 + * A "write-only" atom to add a new entry to the log. 51 + * This encapsulates the logic for creating a new entry with an ID and timestamp. 52 + * Any component can call this to add a log without needing to know the implementation details. 53 + */ 54 + export const addLogEntryAtom = atom( 55 + null, 56 + (get, set, newEntry: Omit<LogEntry, 'id' | 'timestamp'>) => { 57 + const entry: LogEntry = { 58 + id: logIdCounter++, 59 + timestamp: new Date(), 60 + ...newEntry, 61 + }; 62 + const currentLog = get(logEntriesAtom); 63 + const newLog = [entry, ...currentLog]; 64 + if (newLog.length > MAX_LOG_SIZE) { 65 + newLog.length = MAX_LOG_SIZE; 66 + } 67 + set(logEntriesAtom, newLog); 68 + } 69 + );
+167
src/esav/components.tsx
··· 1 + import { useAtomValue } from "jotai"; 2 + import { useState } from "react"; 3 + import { websocketStatusAtom, logEntriesAtom } from "./atoms"; 4 + import type { LogEntry } from "./types"; 5 + 6 + 7 + export function ReconnectingHeader() { 8 + const status = useAtomValue(websocketStatusAtom); 9 + 10 + if (status === "open") { 11 + return null; 12 + } 13 + 14 + const message = 15 + status === "connecting" 16 + ? "Connecting to ESAV Live..." 17 + : "Connection lost. Attempting to reconnect..."; 18 + 19 + return ( 20 + <div 21 + style={{ 22 + position: "sticky", 23 + top: 0, 24 + left: 0, 25 + width: "100%", 26 + padding: "8px", 27 + backgroundColor: "#ffc107", 28 + color: "#333", 29 + textAlign: "center", 30 + fontWeight: "bold", 31 + zIndex: 1000, 32 + boxShadow: "0 2px 4px rgba(0,0,0,0.1)", 33 + }} 34 + > 35 + {message} 36 + </div> 37 + ); 38 + } 39 + 40 + const LogEntryItem = ({ entry }: { entry: LogEntry }) => { 41 + const { type, timestamp, payload } = entry; 42 + 43 + const typeStyles = { 44 + incoming: { icon: "⬇️", color: "#4caf50", name: "Incoming" }, 45 + outgoing: { icon: "⬆️", color: "#ffeb3b", name: "Outgoing" }, 46 + status: { icon: "ℹ️", color: "#2196f3", name: "Status" }, 47 + error: { icon: "❌", color: "#f44336", name: "Error" }, 48 + }; 49 + 50 + const { icon, color, name } = typeStyles[type]; 51 + 52 + return ( 53 + <div 54 + style={{ 55 + borderBottom: "1px solid #444", 56 + padding: "8px", 57 + fontFamily: "monospace", 58 + fontSize: "12px", 59 + borderLeft: `4px solid ${color}`, 60 + }} 61 + > 62 + <div style={{ fontWeight: "bold", marginBottom: "4px" }}> 63 + <span style={{ marginRight: "8px" }}>{icon}</span> 64 + {name} 65 + <span style={{ float: "right", color: "#888" }}> 66 + {timestamp.toLocaleTimeString()} 67 + </span> 68 + </div> 69 + {typeof payload === "object" ? ( 70 + <pre 71 + style={{ 72 + margin: 0, 73 + padding: "8px", 74 + backgroundColor: "rgba(0,0,0,0.2)", 75 + borderRadius: "4px", 76 + whiteSpace: "pre-wrap", 77 + wordBreak: "break-all", 78 + fontSize: "11px", 79 + maxHeight: "200px", 80 + overflowY: "auto", 81 + }} 82 + > 83 + {JSON.stringify(payload, null, 2)} 84 + </pre> 85 + ) : ( 86 + <code style={{ color: "#ccc" }}>{String(payload)}</code> 87 + )} 88 + </div> 89 + ); 90 + }; 91 + 92 + export function DeltaLogViewer() { 93 + const [open, setOpen] = useState(false); 94 + const log = useAtomValue(logEntriesAtom); 95 + 96 + return ( 97 + <div 98 + style={{ 99 + position: "fixed", 100 + bottom: "10px", 101 + right: "10px", 102 + width: open ? "min(850px,90dvw)" : "280px", 103 + backgroundColor: "#2d2d2d", 104 + color: "#f1f1f1", 105 + border: "1px solid #555", 106 + borderRadius: "8px", 107 + boxShadow: "0 4px 12px rgba(0,0,0,0.3)", 108 + zIndex: 2000, 109 + overflow: "hidden", 110 + display: "flex", 111 + flexDirection: "column", 112 + maxHeight: "600px", 113 + transition: "width 0.1s ease", 114 + }} 115 + > 116 + <div 117 + style={{ 118 + display: "flex", 119 + justifyContent: "space-between", 120 + alignItems: "center", 121 + padding: "8px 12px", 122 + backgroundColor: "#3c3c3c", 123 + borderBottom: "1px solid #555", 124 + fontWeight: 700, 125 + }} 126 + > 127 + <span>ESAV Live Log</span> 128 + <button 129 + onClick={() => setOpen(!open)} 130 + style={{ 131 + background: "transparent", 132 + border: "none", 133 + color: "#ccc", 134 + fontSize: "16px", 135 + cursor: "pointer", 136 + padding: "4px 8px", 137 + borderRadius: "4px", 138 + transition: "background 0.01s", 139 + }} 140 + onMouseEnter={(e) => (e.currentTarget.style.background = "#444")} 141 + onMouseLeave={(e) => 142 + (e.currentTarget.style.background = "transparent") 143 + } 144 + title={open ? "Collapse log" : "Expand log"} 145 + > 146 + {open ? "close" : "open"} 147 + </button> 148 + </div> 149 + <div 150 + style={{ 151 + flex: 1, 152 + overflowY: "auto", 153 + display: open ? "flex" : "none", 154 + flexDirection: "column", 155 + }} 156 + > 157 + {log.length === 0 ? ( 158 + <div style={{ padding: "10px", color: "#888" }}> 159 + Waiting for events... 160 + </div> 161 + ) : ( 162 + log.map((entry) => <LogEntryItem key={entry.id} entry={entry} />) 163 + )} 164 + </div> 165 + </div> 166 + ); 167 + }
+313
src/esav/hooks.ts
··· 1 + import { useAtom, useAtomValue, useSetAtom } from 'jotai'; 2 + import { useEffect, useMemo, useRef, useState } from 'react'; 3 + import { 4 + activeSubscriptionsAtom, 5 + documentsAtom, 6 + queryStateFamily, 7 + websocketAtom, 8 + websocketStatusAtom, 9 + addLogEntryAtom 10 + } from './atoms'; 11 + import type { EsavDocument, QueryDoc, SubscribeMessage, UnsubscribeMessage } from './types'; 12 + import { atomWithStorage } from 'jotai/utils'; 13 + 14 + interface UseEsavQueryOptions { 15 + enabled?: boolean; 16 + } 17 + 18 + /** 19 + * The primary hook for subscribing to a live query and getting its results. 20 + * Manages sending subscribe/unsubscribe messages automatically. 21 + * 22 + * @param queryId A unique ID for this query. 23 + * @param esQuery The full Elasticsearch query object. 24 + * @param options Hook options, like `enabled`. 25 + * @returns The hydrated query results and loading status. 26 + */ 27 + export function useEsavQuery( 28 + queryId: string, 29 + esQuery: Record<string, any>, 30 + options: UseEsavQueryOptions = { enabled: true } 31 + ) { 32 + // @ts-expect-error intended 33 + const [activeSubscriptions, setActiveSubscriptions] = useAtom(activeSubscriptionsAtom); 34 + const ws = useAtomValue(websocketAtom); 35 + const addLog = useSetAtom(addLogEntryAtom); 36 + const wsStatus = useAtomValue(websocketStatusAtom); 37 + const queryState = useAtomValue(queryStateFamily(queryId)); 38 + const allDocuments = useAtomValue(documentsAtom); 39 + 40 + const { enabled = true } = options; 41 + const stringifiedEsQuery = useMemo(() => JSON.stringify(esQuery), [esQuery]); 42 + 43 + const esQueryRef = useRef(esQuery); 44 + const queryStateRef = useRef(queryState); 45 + useEffect(() => { 46 + esQueryRef.current = esQuery; 47 + queryStateRef.current = queryState; 48 + }); 49 + 50 + useEffect(() => { 51 + if (!enabled || wsStatus !== 'open' || !ws) { 52 + return; 53 + } 54 + 55 + const currentQuery = esQueryRef.current; 56 + 57 + setActiveSubscriptions((prev) => { 58 + const count = prev[queryId]?.count ?? 0; 59 + if (count === 0) { 60 + console.log(`[ESAV] Subscribing to ${queryId}`); 61 + const message: SubscribeMessage = { 62 + type: 'subscribe', 63 + queryId, 64 + esquery: currentQuery, 65 + ecid: queryStateRef.current?.ecid, 66 + }; 67 + addLog({ type: 'outgoing', payload: message }); 68 + ws.send(JSON.stringify(message)); 69 + } 70 + return { ...prev, [queryId]: { count: count + 1, esQuery: currentQuery } }; 71 + }); 72 + 73 + return () => { 74 + setActiveSubscriptions((prev) => { 75 + const count = prev[queryId]?.count ?? 1; 76 + if (count <= 1) { 77 + console.log(`[ESAV] Unsubscribing from ${queryId}`); 78 + if (ws.readyState === WebSocket.OPEN) { 79 + const message: UnsubscribeMessage = { type: 'unsubscribe', queryId }; 80 + addLog({ type: 'outgoing', payload: message }); 81 + ws.send(JSON.stringify(message)); 82 + } 83 + const { [queryId]: _, ...rest } = prev; 84 + return rest; 85 + } else { 86 + return { ...prev, [queryId]: { ...prev[queryId], count: count - 1 } }; 87 + } 88 + }); 89 + }; 90 + }, [queryId, stringifiedEsQuery, enabled, ws, wsStatus, setActiveSubscriptions]); 91 + 92 + 93 + const hydratedData = useMemo(() => { 94 + if (!queryState?.result) return []; 95 + return queryState.result 96 + .map((uri) => allDocuments[uri]) 97 + .filter(Boolean); 98 + }, [queryState?.result, allDocuments]); 99 + 100 + const isLoading = wsStatus !== 'open' || queryState === null; 101 + 102 + return { 103 + data: hydratedData, 104 + uris: queryState?.result ?? [], 105 + ecid: queryState?.ecid, 106 + isLoading, 107 + status: wsStatus, 108 + }; 109 + } 110 + 111 + type DocumentMap = Record<string, EsavDocument | undefined>; 112 + 113 + /** 114 + * A simple hook to get a single document from the global cache. 115 + * @param uri The at:// URI of the document. 116 + */ 117 + export function useEsavDocument(uri: string): EsavDocument | undefined; 118 + export function useEsavDocument(uri: string[]): DocumentMap; 119 + export function useEsavDocument(uri: undefined): undefined; 120 + export function useEsavDocument(uri: string | string[] | undefined): EsavDocument | undefined | DocumentMap { 121 + const allDocuments = useAtomValue(documentsAtom); 122 + 123 + if (typeof uri === 'string') { 124 + return allDocuments[uri]; 125 + } 126 + 127 + if (Array.isArray(uri)) { 128 + return uri.reduce<DocumentMap>((acc, key) => { 129 + acc[key] = allDocuments[key]; 130 + return acc; 131 + }, {}); 132 + } 133 + 134 + return undefined; 135 + } 136 + 137 + 138 + export interface Profile { 139 + did: string; 140 + handle: string; 141 + pdsUrl: string; 142 + profile: { 143 + "$type": "app.bsky.actor.profile", 144 + "avatar"?: { 145 + "$type": "blob", 146 + "ref": { 147 + "$link": string 148 + }, 149 + "mimeType": string, 150 + "size": number 151 + }, 152 + "banner"?: { 153 + "$type": "blob", 154 + "ref": { 155 + "$link": string 156 + }, 157 + "mimeType": string, 158 + "size": number 159 + }, 160 + "createdAt": string, 161 + "description": string, 162 + "displayName": string 163 + }; 164 + } 165 + 166 + /** 167 + * A persistent atom to store the mapping from a user's handle to their DID. 168 + * This avoids re-resolving handles we've already seen. 169 + * 170 + * Stored in localStorage under the key 'handleToDidCache'. 171 + */ 172 + const handleToDidAtom = atomWithStorage<Record<string, string>>( 173 + 'handleToDidCache', 174 + {} 175 + ); 176 + 177 + /** 178 + * A persistent atom to store the full profile document, keyed by the user's DID. 179 + * This is the primary cache for profile data. 180 + * 181 + * Stored in localStorage under the key 'didToProfileCache'. 182 + */ 183 + const didToProfileAtom = atomWithStorage<Record<string, Profile>>( 184 + 'didToProfileCache', 185 + {} 186 + ); 187 + 188 + /** 189 + * Get a cached Profile document using Jotai persistent atoms. 190 + * It will first check the cache, and if the profile is not found, 191 + * it will fetch it from the network and update the cache. 192 + * 193 + * @param input The user's did or handle (with or without the @) 194 + * @returns A tuple containing the Profile (or null) and a boolean indicating if it's loading. 195 + */ 196 + export const useCachedProfileJotai = (input?: string | null): [Profile | null, boolean] => { 197 + const [handleToDidCache, setHandleToDidCache] = useAtom(handleToDidAtom); 198 + const [didToProfileCache, setDidToProfileCache] = useAtom(didToProfileAtom); 199 + 200 + const [profile, setProfile] = useState<Profile | null>(null); 201 + const [isLoading, setIsLoading] = useState(false); 202 + 203 + useEffect(() => { 204 + const resolveAndFetchProfile = async () => { 205 + if (!input) { 206 + setProfile(null); 207 + return; 208 + } 209 + 210 + setIsLoading(true); 211 + 212 + const normalizedInput = normalizeHandle(input); 213 + const type = classifyIdentifier(normalizedInput); 214 + 215 + if (type === "unknown") { 216 + console.error("Invalid identifier provided:", input); 217 + setProfile(null); 218 + setIsLoading(false); 219 + return; 220 + } 221 + 222 + let didFromCache: string | undefined; 223 + if (type === 'handle') { 224 + didFromCache = handleToDidCache[normalizedInput]; 225 + } else { 226 + didFromCache = normalizedInput; 227 + } 228 + 229 + if (didFromCache && didToProfileCache[didFromCache]) { 230 + setProfile(didToProfileCache[didFromCache]); 231 + setIsLoading(false); 232 + return; 233 + } 234 + 235 + try { 236 + const queryParam = type === "handle" ? "handle" : "did"; 237 + const res = await fetch( 238 + `https://esav.whey.party/xrpc/party.whey.esav.resolveIdentity?${queryParam}=${normalizedInput}&includeBskyProfile=true` 239 + ); 240 + 241 + if (!res.ok) { 242 + throw new Error(`Failed to fetch profile for ${input}`); 243 + } 244 + 245 + const newProfile: Profile = await res.json(); 246 + 247 + setDidToProfileCache(prev => ({ ...prev, [newProfile.did]: newProfile })); 248 + setHandleToDidCache(prev => ({ ...prev, [newProfile.handle]: newProfile.did })); 249 + 250 + setProfile(newProfile); 251 + 252 + } catch (error) { 253 + console.error(error); 254 + setProfile(null); 255 + } finally { 256 + setIsLoading(false); 257 + } 258 + }; 259 + 260 + resolveAndFetchProfile(); 261 + 262 + }, [input, handleToDidCache, didToProfileCache, setHandleToDidCache, setDidToProfileCache]); 263 + 264 + return [profile, isLoading]; 265 + }; 266 + 267 + export type IdentifierType = "did" | "handle" | "unknown"; 268 + 269 + function classifyIdentifier(input: string | null | undefined): IdentifierType { 270 + if (!input) return "unknown"; 271 + if (/^did:[a-z0-9]+:[\w.-]+$/i.test(input)) return "did"; 272 + if (/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(input)) return "handle"; 273 + return "unknown"; 274 + } 275 + 276 + function normalizeHandle(input: string): string { 277 + if (!input) return ''; 278 + return input.startsWith('@') ? input.slice(1) : input; 279 + } 280 + 281 + 282 + 283 + type AtUriParts = { 284 + did: string; 285 + collection: string; 286 + rkey: string; 287 + }; 288 + 289 + export function parseAtUri(uri: string): AtUriParts | null { 290 + if (!uri.startsWith('at://')) return null; 291 + 292 + const parts = uri.slice(5).split('/'); 293 + if (parts.length < 3) return null; 294 + 295 + const [did, collection, ...rest] = parts; 296 + const rkey = rest.join('/'); // in case rkey includes slashes (rare, but allowed) 297 + 298 + return { did, collection, rkey }; 299 + } 300 + /** 301 + * use useEsavDocument instead its nicer 302 + * @deprecated 303 + * @param uris 304 + * @returns 305 + */ 306 + export function useResolvedDocuments(uris: string[]) { 307 + const allDocuments = useAtomValue(documentsAtom); 308 + 309 + return uris.reduce<Record<string, QueryDoc | undefined>>((acc, uri) => { 310 + acc[uri] = allDocuments[uri].doc; 311 + return acc; 312 + }, {}); 313 + }
+52
src/esav/types.ts
··· 1 + // A document as stored in our global cache 2 + export interface EsavDocument { 3 + cid: string; 4 + doc: QueryDoc; 5 + } 6 + 7 + export interface QueryDoc { 8 + "$metadata.uri": string; 9 + "$metadata.cid": string; 10 + "$metadata.did": string; 11 + "$metadata.collection": string; 12 + "$metadata.rkey": string; 13 + "$metadata.indexedAt": string; 14 + $raw?: Record<string, unknown>; 15 + [key: string]: unknown; 16 + } 17 + 18 + // The state for a single query subscription 19 + export interface QueryState { 20 + ecid: string; 21 + result: string[]; // An ordered array of document URIs 22 + } 23 + 24 + // The server->client message we expect 25 + export interface QueryDeltaMessage { 26 + type: 'query-delta'; 27 + documents?: Record<string, EsavDocument>; 28 + queries?: Record<string, QueryState>; 29 + } 30 + 31 + // The client->server message for subscribing 32 + export interface SubscribeMessage { 33 + type: 'subscribe'; 34 + queryId: string; 35 + esquery: Record<string, any>; 36 + ecid?: string; // Optional last known ECID 37 + } 38 + 39 + // The client->server message for unsubscribing 40 + export interface UnsubscribeMessage { 41 + type: 'unsubscribe'; 42 + queryId: string; 43 + } 44 + 45 + export type LogEntryType = 'incoming' | 'outgoing' | 'status' | 'error'; 46 + 47 + export interface LogEntry { 48 + id: number; 49 + timestamp: Date; 50 + type: LogEntryType; 51 + payload: any; 52 + }
-1
src/helpers/cachedidentityresolver.ts
··· 3 3 handle: string 4 4 did: string 5 5 pdsUrl: string 6 - bskyPds: boolean 7 6 } 8 7 | undefined 9 8 const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
+15 -11
src/main.tsx
··· 10 10 import { AuthProvider } from "./providers/PassAuthProvider.tsx"; 11 11 import { PersistentStoreProvider } from "./providers/PersistentStoreProvider.tsx"; 12 12 import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 13 + import { ESAVLiveProvider } from "./esav/ESAVLiveProvider.tsx"; 13 14 14 15 const queryClient = new QueryClient(); 16 + const ESAV_WEBSOCKET_URL = 'wss://esav.whey.party/xrpc/party.whey.esav.esSync'; 15 17 16 18 // Create a new router instance 17 19 const router = createRouter({ ··· 37 39 if (rootElement && !rootElement.innerHTML) { 38 40 const root = ReactDOM.createRoot(rootElement); 39 41 root.render( 40 - <StrictMode> 41 - <PersistentStoreProvider> 42 - <AuthProvider> 43 - <QueryClientProvider client={queryClient}> 44 - {/* Pass the router instance with the context to the provider */} 45 - <RouterProvider router={router} /> 46 - </QueryClientProvider> 47 - </AuthProvider> 48 - </PersistentStoreProvider> 49 - </StrictMode> 42 + //<StrictMode> 43 + <ESAVLiveProvider url={ESAV_WEBSOCKET_URL}> 44 + <PersistentStoreProvider> 45 + <AuthProvider> 46 + <QueryClientProvider client={queryClient}> 47 + {/* Pass the router instance with the context to the provider */} 48 + <RouterProvider router={router} /> 49 + </QueryClientProvider> 50 + </AuthProvider> 51 + </PersistentStoreProvider> 52 + </ESAVLiveProvider> 53 + //</StrictMode> 50 54 ); 51 55 } 52 56 53 57 // If you want to start measuring performance in your app, pass a function 54 58 // to log results (for example: reportWebVitals(console.log)) 55 59 // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 56 - reportWebVitals(); 60 + reportWebVitals();
+4 -1
src/routes/__root.tsx
··· 7 7 } from "@tanstack/react-router"; 8 8 import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; 9 9 import { ReactQueryDevtools } from '@tanstack/react-query-devtools' 10 + import { DeltaLogViewer, ReconnectingHeader } from "@/esav/components"; 10 11 11 12 export const Route = createRootRouteWithContext<{ 12 13 queryClient: QueryClient; ··· 14 15 component: () => ( 15 16 <> 16 17 <Header /> 18 + <ReconnectingHeader /> 17 19 <Outlet /> 18 20 <TanStackRouterDevtools /> 19 21 <ReactQueryDevtools /> 22 + <DeltaLogViewer /> 20 23 </> 21 24 ), 22 - }); 25 + });
+52 -146
src/routes/f/$forumHandle.tsx
··· 5 5 import { esavQuery } from "@/helpers/esquery"; 6 6 import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 7 7 import { Outlet } from "@tanstack/react-router"; 8 - import { useState } from "react"; 8 + import { useMemo, useState } from "react"; 9 9 import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query"; 10 + import { useCachedProfileJotai, useEsavDocument, useEsavQuery } from "@/esav/hooks"; 10 11 11 12 type ForumDoc = { 12 13 "$metadata.uri": string; ··· 34 35 identity: ResolvedIdentity; 35 36 }; 36 37 37 - const forumQueryOptions = (queryClient: QueryClient, forumHandle: string) => ({ 38 - queryKey: ["forum", forumHandle], 39 - queryFn: async (): Promise<ResolvedForumData> => { 40 - if (!forumHandle) { 41 - throw new Error("Forum handle is required."); 42 - } 43 - const normalizedHandle = decodeURIComponent(forumHandle).replace(/^@/, ""); 44 - 45 - const identity = await queryClient.fetchQuery({ 46 - queryKey: ["identity", normalizedHandle], 47 - queryFn: () => resolveIdentity({ didOrHandle: normalizedHandle }), 48 - staleTime: 1000 * 60 * 60 * 24, // 24 hours 49 - }); 50 - 51 - if (!identity) { 52 - throw new Error(`Could not resolve forum handle: @${normalizedHandle}`); 53 - } 54 - 55 - const forumRes = await esavQuery<{ 56 - hits: { hits: { _source: ForumDoc }[] }; 57 - }>({ 58 - query: { 59 - bool: { 60 - must: [ 61 - { term: { "$metadata.did": identity.did } }, 62 - { 63 - term: { 64 - "$metadata.collection": "com.example.ft.forum.definition", 65 - }, 66 - }, 67 - { term: { "$metadata.rkey": "self" } }, 68 - ], 69 - }, 70 - }, 71 - }); 72 - 73 - const forumDoc = forumRes.hits.hits[0]?._source; 74 - if (!forumDoc) { 75 - throw new Error("Forum definition not found."); 76 - } 77 - 78 - return { forumDoc, identity }; 79 - }, 80 - }); 81 - 82 38 export const Route = createFileRoute("/f/$forumHandle")({ 83 - loader: async ({ context: { queryClient }, params }) => { 84 - const normalizedHandle = decodeURIComponent(params.forumHandle).replace(/^@/, ""); 85 - 86 - const identity = await queryClient.fetchQuery({ 87 - queryKey: ["identity", normalizedHandle], 88 - queryFn: () => resolveIdentity({ didOrHandle: normalizedHandle }), 89 - staleTime: 1000 * 60 * 60 * 24, 90 - }); 91 - 92 - if (!identity) { 93 - throw new Error(`Could not resolve forum handle: @${normalizedHandle}`); 94 - } 95 - 96 - const forums = queryClient.getQueryData<ResolvedForum[]>(["forums", "list"]); 97 - const forumFromList = forums?.find(f => f["$metadata.did"] === identity.did) 98 - 99 - const initialData: ResolvedForumData | undefined = forumFromList 100 - ? { 101 - forumDoc: forumFromList, 102 - identity: { 103 - handle: forumFromList.resolvedIdentity!.handle, 104 - did: forumFromList["$metadata.did"], 105 - pdsUrl: forumFromList.resolvedIdentity!.pdsUrl, 106 - bskyPds: false, 107 - }, 108 - } 109 - : undefined 110 - 111 - if (initialData) { 112 - return initialData; 113 - } 114 - 115 - // Fallback to direct fetch 116 - const forumRes = await esavQuery<{ 117 - hits: { hits: { _source: ForumDoc }[] }; 118 - }>({ 119 - query: { 120 - bool: { 121 - must: [ 122 - { term: { "$metadata.did": identity.did } }, 123 - { 124 - term: { 125 - "$metadata.collection": "com.example.ft.forum.definition", 126 - }, 127 - }, 128 - { term: { "$metadata.rkey": "self" } }, 129 - ], 130 - }, 131 - }, 132 - }); 133 - 134 - const forumDoc = forumRes.hits.hits[0]?._source; 135 - if (!forumDoc) { 136 - throw new Error("Forum definition not found."); 137 - } 138 - 139 - return { 140 - forumDoc, 141 - identity, 142 - }; 143 - }, 144 39 component: ForumHeader, 145 - pendingComponent: ForumHeaderContentSkeleton, 146 - errorComponent: ({ error }) => ( 147 - <div className="text-red-500 text-center pt-10"> 148 - Error: {(error as Error).message} 149 - </div> 150 - ), 151 40 }); 152 41 153 42 function ForumHeaderContentSkeleton() { ··· 186 75 </div> 187 76 </div> 188 77 </div> 189 - <Outlet /> 190 78 </> 191 79 ); 192 80 } ··· 227 115 </form> 228 116 ); 229 117 } 230 - function ForumHeaderContent({ 231 - forumDoc, 232 - identity, 233 - forumHandle, 234 - }: { 235 - forumDoc: ForumDoc; 236 - identity: ResolvedIdentity; 237 - forumHandle: string; 238 - }) { 239 - const did = identity?.did; 240 - const bannerCid = forumDoc?.$raw?.banner?.ref?.$link; 241 - const avatarCid = forumDoc?.$raw?.avatar?.ref?.$link; 118 + function ForumHeaderContent() { 119 + const { forumHandle } = Route.useParams(); 120 + const [profile, isLoading] = useCachedProfileJotai(forumHandle); 121 + 122 + const forumQuery = useMemo(() => { 123 + if (!profile?.did) { 124 + return null; 125 + } 126 + 127 + const query = { 128 + query: { 129 + bool: { 130 + must: [ 131 + { term: { "$metadata.did": profile.did } }, 132 + { 133 + term: { 134 + "$metadata.collection": "party.whey.ft.forum.definition", 135 + }, 136 + }, 137 + { term: { "$metadata.rkey": "self" } }, 138 + ], 139 + }, 140 + }, 141 + sort: [{ '$metadata.indexedAt': 'desc' }] 142 + }; 143 + return query; 144 + }, [profile?.did]); 145 + 146 + const { 147 + uris = [], 148 + isLoading: isQueryLoading, 149 + } = useEsavQuery(`forumtest/${profile?.did}`, forumQuery!, { 150 + enabled: !!profile?.did && !!forumQuery, 151 + }); 152 + 153 + const data = useEsavDocument(uris[0]); 154 + 155 + if (!profile || isLoading || isQueryLoading || !data) { 156 + return <ForumHeaderContentSkeleton />; 157 + } 158 + 159 + const did = profile.did; 160 + const bannerCid = profile.profile?.banner?.ref?.$link; 161 + const avatarCid = profile.profile?.avatar?.ref?.$link; 162 + 242 163 const bannerUrl = 243 164 did && bannerCid 244 - ? `${identity?.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${bannerCid}` 165 + ? `${profile?.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${bannerCid}` 245 166 : null; 246 167 const avatarUrl = 247 168 did && avatarCid 248 - ? `${identity?.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${avatarCid}` 169 + ? `${profile?.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${avatarCid}` 249 170 : null; 250 171 251 172 return ( ··· 281 202 )} 282 203 <div> 283 204 <div className="text-white text-3xl font-bold"> 284 - {forumDoc.displayName || "Unnamed Forum"} 205 + {profile.profile.displayName || "Unnamed Forum"} 285 206 </div> 286 207 <div className="text-blue-300 font-mono"> 287 208 /f/{decodeURIComponent(forumHandle || "")} ··· 290 211 </Link> 291 212 </div> 292 213 <div className="ml-auto text-gray-300 text-base text-end max-w-1/2"> 293 - {forumDoc.description || "No description provided."} 214 + {profile.profile.description || "No description provided."} 294 215 </div> 295 216 </div> 296 217 </div> ··· 325 246 } 326 247 327 248 function ForumHeader() { 328 - const { forumHandle } = Route.useParams(); 329 - const initialData = Route.useLoaderData(); 330 - const queryClient = useQueryClient(); 331 - 332 - const { data } = useQuery({ 333 - ...forumQueryOptions(queryClient, forumHandle), 334 - initialData, 335 - }); 336 - 337 - const { forumDoc, identity } = data; 338 - 339 249 return ( 340 250 <> 341 - <ForumHeaderContent 342 - forumDoc={forumDoc} 343 - identity={identity} 344 - forumHandle={forumHandle} 345 - /> 251 + <ForumHeaderContent/> 346 252 <Outlet /> 347 253 </> 348 254 );
+302 -341
src/routes/f/$forumHandle/index.tsx
··· 4 4 Link, 5 5 useParams, 6 6 } from "@tanstack/react-router"; 7 - import { useEffect, useState } from "react"; 7 + import { useEffect, useMemo, useState } from "react"; 8 8 import { 9 9 resolveIdentity, 10 10 type ResolvedIdentity, ··· 16 16 import { useAuth } from "@/providers/PassAuthProvider"; 17 17 import { AtUri, BskyAgent } from "@atproto/api"; 18 18 import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query"; 19 + import { 20 + useCachedProfileJotai, 21 + useEsavQuery, 22 + useEsavDocument, 23 + parseAtUri, 24 + type Profile, 25 + useResolvedDocuments, 26 + } from "@/esav/hooks"; 19 27 20 28 type PostDoc = { 21 29 "$metadata.uri": string; ··· 67 75 profilesMap: Record<string, ProfileData>; 68 76 }; 69 77 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 - }, 261 - }); 262 - 263 78 function getRelativeTimeString(input: string | Date): string { 264 79 const date = typeof input === "string" ? new Date(input) : input; 265 80 const now = new Date(); ··· 283 98 } 284 99 285 100 export const Route = createFileRoute("/f/$forumHandle/")({ 286 - loader: ({ context: { queryClient }, params }) => 287 - queryClient.ensureQueryData( 288 - topicListQueryOptions(queryClient, params.forumHandle) 289 - ), 290 101 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 102 }); 298 103 299 104 function ForumHeaderSkeleton() { ··· 378 183 } 379 184 380 185 export function Forum() { 186 + const { forumHandle } = Route.useParams(); 187 + const [profile, isLoading] = useCachedProfileJotai(forumHandle); 188 + 189 + const postsQuery = useMemo(() => { 190 + if (!profile?.did) { 191 + return null; 192 + } 193 + 194 + const query = { 195 + query: { 196 + bool: { 197 + must: [ 198 + { term: { forum: profile.did } }, 199 + { term: { "$metadata.collection": "party.whey.ft.topic.post" } }, 200 + { bool: { must_not: [{ exists: { field: "root" } }] } }, 201 + ], 202 + }, 203 + }, 204 + sort: [{ "$metadata.indexedAt": { order: "desc" } }] 205 + }; 206 + return query; 207 + }, [profile?.did]); 208 + 209 + const { uris = [], isLoading: isQueryLoading } = useEsavQuery( 210 + `forumtest/${profile?.did}/topics`, 211 + postsQuery!, 212 + { 213 + enabled: !!profile?.did && !!postsQuery, 214 + } 215 + ); 216 + 381 217 const navigate = useNavigate(); 382 218 const { agent, loading: authLoading } = useAuth(); 383 - const { forumHandle } = useParams({ from: "/f/$forumHandle/" }); 384 219 385 - const initialData = Route.useLoaderData(); 386 220 const queryClient = useQueryClient(); 387 221 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; 395 - 396 222 const [selectedCategory, setSelectedCategory] = useState("uncategorized"); 397 223 const [sortOrder, setSortOrder] = useState("latest"); 398 224 const [isModalOpen, setIsModalOpen] = useState(false); ··· 402 228 const [formError, setFormError] = useState<string | null>(null); 403 229 404 230 const handleCreateTopic = async () => { 405 - if (!agent || !agent.did || !identity) { 231 + if (!agent || !agent.did) { 406 232 setFormError("You must be logged in to create a topic."); 407 233 return; 408 234 } ··· 417 243 try { 418 244 const response = await agent.com.atproto.repo.createRecord({ 419 245 repo: agent.did, 420 - collection: "com.example.ft.topic.post", 246 + collection: "party.whey.ft.topic.post", 421 247 record: { 422 - $type: "com.example.ft.topic.post", 248 + $type: "party.whey.ft.topic.post", 423 249 title: newTopicTitle, 424 250 text: newTopicText, 425 251 createdAt: new Date().toISOString(), 426 - forum: identity.did, 252 + forum: profile?.did, 427 253 }, 428 254 }); 429 255 ··· 446 272 } 447 273 }; 448 274 275 + if (!profile || isLoading || isQueryLoading) { 276 + return <TopicListSkeleton />; 277 + } 278 + 449 279 return ( 450 280 <div className="w-full flex flex-col items-center pt-6 px-4"> 451 281 <div className="w-full max-w-5xl"> ··· 525 355 <Dialog.Trigger asChild> 526 356 <button 527 357 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"} 358 + disabled={!profile} 359 + title={!profile ? "Loading forum..." : "Create a new topic"} 530 360 > 531 361 + New Topic 532 362 </button> ··· 640 470 </tr> 641 471 </thead> 642 472 <tbody> 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 ? ( 702 - <img 703 - key={did} 704 - src={avatarUrl} 705 - alt={`@${participant?.handle || did.slice(0, 8)}`} 706 - className="w-6 h-6 rounded-full border-2 border-gray-800 object-cover bg-gray-700" 707 - title={`@${participant?.handle || did.slice(0, 8)}`} 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 - }) 473 + {uris.length > 0 ? ( 474 + uris.map((uri) => ( 475 + <TopicRow 476 + forumHandle={forumHandle} 477 + key={uri} 478 + profile={profile} 479 + uri={uri} 480 + /> 481 + )) 764 482 ) : ( 765 483 <tr> 766 484 <td colSpan={5} className="text-center text-gray-500 py-10"> ··· 774 492 </div> 775 493 ); 776 494 } 495 + 496 + function TopicRow({ 497 + forumHandle, 498 + profile, 499 + uri, 500 + }: { 501 + forumHandle: string; 502 + profile: Profile; 503 + uri: string; 504 + }) { 505 + const navigate = useNavigate(); 506 + const topic = useEsavDocument(uri); 507 + const parsed = parseAtUri(uri); 508 + 509 + const fullRepliesQuery = { 510 + query: { 511 + bool: { must: [{ term: { root: uri } }] }, 512 + }, 513 + sort: [{ "$metadata.indexedAt": { order: "asc" } }], 514 + }; 515 + 516 + const { uris: repliesUris = [], isLoading: isQueryLoading } = useEsavQuery( 517 + `forumtest/${profile.did}/${uri}/replies`, 518 + fullRepliesQuery!, 519 + { 520 + enabled: !!fullRepliesQuery, 521 + } 522 + ); 523 + 524 + const topReactions = { 525 + query: { 526 + bool: { 527 + must: [ 528 + { 529 + term: { 530 + "$metadata.collection": "party.whey.ft.topic.reaction", 531 + }, 532 + }, 533 + { 534 + terms: { 535 + reactionSubject: [uri] 536 + } 537 + }, 538 + ], 539 + }, 540 + }, 541 + sort: [{ "$metadata.indexedAt": { order: "asc" } }], 542 + }; 543 + 544 + const { uris: reactionUris = [], isLoading: isReactionsLoading } = 545 + useEsavQuery(`forumtest/${profile.did}/${uri}/OPreply/reactions`, topReactions!, { 546 + enabled: !!topReactions, 547 + }); 548 + 549 + const lastReplyUri = 550 + repliesUris.length > 0 ? repliesUris[repliesUris.length - 1] : uri; 551 + 552 + const [op, isOpLoading] = useCachedProfileJotai(parsed?.did); 553 + const [lastReplyAuthor, isLastReplyAuthorLoading] = useCachedProfileJotai( 554 + lastReplyUri && parseAtUri(lastReplyUri)?.did 555 + ); 556 + 557 + const lastReply = useEsavDocument(lastReplyUri); 558 + 559 + const participants = Array.from( 560 + new Set( 561 + [ 562 + parsed?.did, 563 + ...repliesUris.map((i) => parseAtUri(i)?.did), 564 + ].filter((did): did is string => typeof did === "string") 565 + ) 566 + ); 567 + 568 + 569 + if ( 570 + !topic || 571 + isQueryLoading || 572 + isOpLoading || 573 + isLastReplyAuthorLoading || 574 + !op || 575 + isReactionsLoading 576 + ) { 577 + return <TopicRowSkeleton />; 578 + } 579 + 580 + const rootAuthorProfile = op.profile; 581 + 582 + const lastPostAuthorDid = lastReply?.doc["$metadata.did"]; 583 + const lastPostTimestamp = lastReply?.doc["$metadata.indexedAt"]; 584 + const lastPostAuthorProfile = lastReplyAuthor; 585 + 586 + const lastPostAuthorAvatar = 587 + lastPostAuthorProfile?.profile?.avatar?.ref?.$link && 588 + lastPostAuthorProfile.pdsUrl 589 + ? `${lastPostAuthorProfile.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${lastPostAuthorDid}&cid=${lastPostAuthorProfile.profile.avatar.ref.$link}` 590 + : undefined; 591 + 592 + const post = topic.doc as PostDoc; 593 + 594 + return ( 595 + <tr 596 + onClick={() => 597 + navigate({ 598 + to: `/f/${forumHandle}/t/${post["$metadata.did"]}/${post["$metadata.rkey"]}`, 599 + }) 600 + } 601 + key={post["$metadata.uri"]} 602 + className="bg-gray-800 hover:bg-gray-700/50 rounded-lg cursor-pointer transition-colors duration-150 group relative" 603 + > 604 + <td className="px-4 py-3 text-white rounded-l-lg min-w-52"> 605 + <Link 606 + // @ts-ignore 607 + to={`/f/${forumHandle}/t/${post["$metadata.did"]}/${post["$metadata.rkey"]}`} 608 + className="stretched-link" 609 + > 610 + <span className="sr-only">View topic:</span> 611 + </Link> 612 + <div className="font-semibold text-gray-50 line-clamp-1"> 613 + {post.title} 614 + </div> 615 + <div className="text-sm text-gray-400"> 616 + by{" "} 617 + <span className="font-medium text-gray-300"> 618 + {op.handle ? `@${op.handle}` : op?.did.slice(4, 12)} 619 + </span> 620 + , {getRelativeTimeString(post["$metadata.indexedAt"])} 621 + </div> 622 + </td> 623 + <td className="px-4 py-3"> 624 + <div className="flex -space-x-2 justify-center"> 625 + {participants 626 + .filter(Boolean) 627 + .slice(0, 5) 628 + .map((did) => ( 629 + <Participant key={did} did={did} /> 630 + ))} 631 + </div> 632 + </td> 633 + <td className="px-4 py-3 text-center text-gray-100 font-medium"> 634 + {(repliesUris.length ?? 0) < 1 ? "-" : repliesUris.length} 635 + </td> 636 + <td className="px-4 py-3 text-center text-gray-300 font-medium"> 637 + {reactionUris ? <TopReactionc uris={reactionUris} /> : "-"} 638 + </td> 639 + <td className="px-4 py-3 text-gray-400 text-right rounded-r-lg"> 640 + <div className="flex items-center justify-end gap-2"> 641 + <div className="text-right"> 642 + <div className="text-sm font-semibold text-gray-100 line-clamp-1"> 643 + {lastPostAuthorProfile?.profile?.displayName || 644 + (lastPostAuthorProfile?.handle 645 + ? `@${lastPostAuthorProfile.handle}` 646 + : "...")} 647 + </div> 648 + <div className="text-xs"> 649 + {lastPostTimestamp && getRelativeTimeString(lastPostTimestamp)} 650 + </div> 651 + </div> 652 + {lastPostAuthorAvatar ? ( 653 + <img 654 + src={lastPostAuthorAvatar} 655 + alt={lastPostAuthorProfile?.profile?.displayName} 656 + className="w-8 h-8 rounded-full object-cover bg-gray-700 shrink-0" 657 + /> 658 + ) : ( 659 + <div className="w-8 h-8 rounded-full bg-gray-700 shrink-0" /> 660 + )} 661 + </div> 662 + </td> 663 + </tr> 664 + ); 665 + } 666 + 667 + function TopReactionc({ uris }: { uris: string[] }) { 668 + const resolvedReactions = useResolvedDocuments(uris); 669 + 670 + const didEmojiSet = new Map<string, Set<string>>(); 671 + const emojiCounts = new Map<string, number>(); 672 + 673 + Object.values(resolvedReactions).forEach((doc) => { 674 + if (!doc) return; 675 + 676 + const did = doc["$metadata.did"]; 677 + const emoji = doc.$raw?.reactionEmoji as string; 678 + if (!emoji) return; 679 + 680 + if (!didEmojiSet.has(did)) { 681 + didEmojiSet.set(did, new Set()); 682 + } 683 + 684 + const emojiSet = didEmojiSet.get(did)!; 685 + if (!emojiSet.has(emoji)) { 686 + emojiSet.add(emoji); 687 + emojiCounts.set(emoji, (emojiCounts.get(emoji) || 0) + 1); 688 + } 689 + }); 690 + 691 + // Step 2: Find top emoji 692 + let topEmoji: string | null = null; 693 + let topCount = 0; 694 + for (const [emoji, count] of emojiCounts) { 695 + if (count > topCount) { 696 + topEmoji = emoji; 697 + topCount = count; 698 + } 699 + } 700 + 701 + if (!topEmoji) return null; // No valid reactions 702 + 703 + return ( 704 + <div 705 + className="flex items-center justify-center gap-1.5" 706 + title={`${topCount} reactions`} 707 + > 708 + <span>{topEmoji}</span> 709 + <span className="text-sm font-normal">{topCount}</span> 710 + </div> 711 + ); 712 + } 713 + 714 + function Participant({ did }: { did: string }) { 715 + const [user, isloading] = useCachedProfileJotai(did); 716 + if (isloading || !user) { 717 + return ( 718 + <div 719 + key={did} 720 + className="w-6 h-6 rounded-full border-2 border-gray-800 bg-gray-700" 721 + /> 722 + ); 723 + } 724 + const avatarUrl = 725 + user.profile?.avatar?.ref?.$link && user.pdsUrl 726 + ? `${user.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${user.profile.avatar.ref.$link}` 727 + : undefined; 728 + return ( 729 + <img 730 + key={did} 731 + src={avatarUrl} 732 + alt={`@${user?.handle || did.slice(0, 8)}`} 733 + className="w-6 h-6 rounded-full border-2 border-gray-800 object-cover bg-gray-700" 734 + title={`@${user?.handle || did.slice(0, 8)}`} 735 + /> 736 + ); 737 + }
+274 -192
src/routes/f/$forumHandle/t/$userHandle/$topicRKey.tsx
··· 16 16 } from "@radix-ui/react-icons"; 17 17 import * as Popover from "@radix-ui/react-popover"; 18 18 import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query"; 19 + import { 20 + parseAtUri, 21 + useCachedProfileJotai, 22 + useEsavDocument, 23 + useEsavQuery, 24 + type Profile, 25 + } from "@/esav/hooks"; 19 26 20 27 type PostDoc = { 21 28 "$metadata.uri": string; ··· 58 65 59 66 const EMOJI_SELECTION = ["👍", "❤️", "😂", "🔥", "🤔", "🎉", "🙏", "🤯"]; 60 67 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."); 68 + // const topicQueryOptions = ( 69 + // queryClient: QueryClient, 70 + // userHandle: string, 71 + // topicRKey: string 72 + // ) => ({ 73 + // queryKey: ["topic", userHandle, topicRKey], 74 + // queryFn: async (): Promise<TopicData> => { 75 + // const authorIdentity = await queryClient.fetchQuery({ 76 + // queryKey: ["identity", userHandle], 77 + // queryFn: () => resolveIdentity({ didOrHandle: userHandle }), 78 + // staleTime: 1000 * 60 * 60 * 24, 79 + // }); 80 + // if (!authorIdentity) throw new Error("Could not find topic author."); 74 81 75 - const topicUri = `at://${authorIdentity.did}/com.example.ft.topic.post/${topicRKey}`; 82 + // const topicUri = `at://${authorIdentity.did}/party.whey.ft.topic.post/${topicRKey}`; 76 83 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 - ]); 84 + // const [postRes, repliesRes] = await Promise.all([ 85 + // esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({ 86 + // query: { term: { "$metadata.uri": topicUri } }, 87 + // size: 1, 88 + // }), 89 + // esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({ 90 + // query: { term: { root: topicUri } }, 91 + // sort: [{ "$metadata.indexedAt": "asc" }], 92 + // size: 100, 93 + // }), 94 + // ]); 88 95 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]; 96 + // if (postRes.hits.hits.length === 0) throw new Error("Topic not found."); 97 + // const mainPost = postRes.hits.hits[0]._source; 98 + // const fetchedReplies = repliesRes.hits.hits.map((h) => h._source); 99 + // const allPosts = [mainPost, ...fetchedReplies]; 93 100 94 - const postUris = allPosts.map((p) => p["$metadata.uri"]); 95 - const authorDids = [...new Set(allPosts.map((p) => p["$metadata.did"]))]; 101 + // const postUris = allPosts.map((p) => p["$metadata.uri"]); 102 + // const authorDids = [...new Set(allPosts.map((p) => p["$metadata.did"]))]; 96 103 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 - }); 104 + // const [reactionsRes, footersRes, pdsProfiles] = await Promise.all([ 105 + // esavQuery<{ hits: { hits: { _source: ReactionDoc }[] } }>({ 106 + // query: { 107 + // bool: { 108 + // must: [ 109 + // { 110 + // term: { 111 + // "$metadata.collection": "party.whey.ft.topic.reaction", 112 + // }, 113 + // }, 114 + // { terms: { reactionSubject: postUris } }, 115 + // ], 116 + // }, 117 + // }, 118 + // _source: ["reactionSubject", "reactionEmoji"], 119 + // size: 1000, 120 + // }), 121 + // esavQuery<{ 122 + // hits: { 123 + // hits: { _source: { "$metadata.did": string; footer: string } }[]; 124 + // }; 125 + // }>({ 126 + // query: { 127 + // bool: { 128 + // must: [ 129 + // { term: { $type: "party.whey.ft.user.profile" } }, 130 + // { terms: { "$metadata.did": authorDids } }, 131 + // ], 132 + // }, 133 + // }, 134 + // _source: ["$metadata.did", "footer"], 135 + // size: authorDids.length, 136 + // }), 137 + // Promise.all( 138 + // authorDids.map(async (did) => { 139 + // try { 140 + // const identity = await queryClient.fetchQuery({ 141 + // queryKey: ["identity", did], 142 + // queryFn: () => resolveIdentity({ didOrHandle: did }), 143 + // staleTime: 1000 * 60 * 60 * 24, 144 + // }); 138 145 139 - if (!identity?.pdsUrl) { 140 - console.warn( 141 - `Could not resolve PDS for ${did}, cannot fetch profile.` 142 - ); 143 - return { did, profile: null }; 144 - } 146 + // if (!identity?.pdsUrl) { 147 + // console.warn( 148 + // `Could not resolve PDS for ${did}, cannot fetch profile.` 149 + // ); 150 + // return { did, profile: null }; 151 + // } 145 152 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); 153 + // const profileUrl = `${identity.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.actor.profile&rkey=self`; 154 + // const profileRes = await fetch(profileUrl); 148 155 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 - } 156 + // if (!profileRes.ok) { 157 + // console.warn( 158 + // `Failed to fetch profile for ${did} from ${identity.pdsUrl}. Status: ${profileRes.status}` 159 + // ); 160 + // return { did, profile: null }; 161 + // } 155 162 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 - ]); 163 + // const profileData = await profileRes.json(); 164 + // return { did, profile: profileData.value }; 165 + // } catch (e) { 166 + // console.error( 167 + // `Error during decentralized profile fetch for ${did}:`, 168 + // e 169 + // ); 170 + // return { did, profile: null }; 171 + // } 172 + // }) 173 + // ), 174 + // ]); 168 175 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 - ); 176 + // const reactionsByPostUri = reactionsRes.hits.hits.reduce( 177 + // (acc, hit) => { 178 + // const reaction = hit._source; 179 + // (acc[reaction.reactionSubject] = 180 + // acc[reaction.reactionSubject] || []).push(reaction); 181 + // return acc; 182 + // }, 183 + // {} as Record<string, ReactionDoc[]> 184 + // ); 178 185 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 + // const footersByDid = footersRes.hits.hits.reduce( 187 + // (acc, hit) => { 188 + // acc[hit._source["$metadata.did"]] = hit._source.footer; 189 + // return acc; 190 + // }, 191 + // {} as Record<string, string> 192 + // ); 186 193 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 - ); 194 + // const authors: Record<string, AuthorInfo> = {}; 195 + // await Promise.all( 196 + // authorDids.map(async (did) => { 197 + // const identity = await queryClient.fetchQuery({ 198 + // queryKey: ["identity", did], 199 + // queryFn: () => resolveIdentity({ didOrHandle: did }), 200 + // staleTime: 1000 * 60 * 60 * 24, 201 + // }); 202 + // if (!identity) return; 203 + // const pdsProfile = pdsProfiles.find((p) => p.did === did)?.profile; 204 + // authors[did] = { 205 + // ...identity, 206 + // displayName: pdsProfile?.displayName, 207 + // avatarCid: pdsProfile?.avatar?.ref?.["$link"], 208 + // footer: footersByDid[did], 209 + // }; 210 + // }) 211 + // ); 212 + 213 + // return { posts: allPosts, authors, reactions: reactionsByPostUri }; 214 + // }, 215 + // }); 205 216 206 - return { posts: allPosts, authors, reactions: reactionsByPostUri }; 207 - }, 208 - }); 209 217 export const Route = createFileRoute( 210 218 "/f/$forumHandle/t/$userHandle/$topicRKey" 211 219 )({ 212 - loader: ({ context: { queryClient }, params }) => 213 - queryClient.ensureQueryData( 214 - topicQueryOptions( 215 - queryClient, 216 - decodeURIComponent(params.userHandle), 217 - params.topicRKey 218 - ) 219 - ), 220 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 - ), 227 221 }); 228 222 229 223 export function PostCardSkeleton() { ··· 276 270 ); 277 271 } 278 272 279 - function UserInfoColumn({ author }: { author: AuthorInfo | null }) { 273 + function UserInfoColumn({ author }: { author: Profile | null }) { 280 274 const avatarUrl = 281 - author?.avatarCid && author?.pdsUrl 282 - ? `${author.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${author.did}&cid=${author.avatarCid}` 275 + author?.profile.avatar?.ref.$link && author?.pdsUrl 276 + ? `${author.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${author.did}&cid=${author?.profile.avatar?.ref.$link}` 283 277 : undefined; 284 278 285 - const authorDisplayName = author?.displayName || author?.handle || "Unknown"; 279 + const authorDisplayName = author?.profile.displayName || author?.handle || "Unknown"; 286 280 const authorHandle = author?.handle ? `@${author.handle}` : "did:..."; 287 281 288 282 return ( ··· 302 296 {authorDisplayName} 303 297 </div> 304 298 <div className="break-words whitespace-normal">{authorHandle}</div> 305 - {author?.footer && ( 299 + {/* {author?.footer && ( 306 300 <div className="border-t border-gray-700/80 mt-4 pt-3 text-xs text-gray-500 text-left whitespace-pre-wrap break-words"> 307 301 {author.footer} 308 302 </div> 309 - )} 303 + )} */} 310 304 </div> 311 305 ); 312 306 } ··· 340 334 } 341 335 342 336 export function PostCard({ 337 + forumdid, 343 338 agent, 344 339 post, 345 - author, 346 - reactions, 340 + //author, 341 + //reactions, 347 342 index, 348 343 onSetReplyParent, 349 344 onNewReaction, 350 345 isCreatingReaction, 351 346 }: { 347 + forumdid: string; 352 348 agent: AtpAgent | null; 353 349 post: PostDoc; 354 - author: AuthorInfo | null; 355 - reactions: ReactionDoc[]; 350 + //author: AuthorInfo | null; 351 + //reactions: ReactionDoc[]; 356 352 index: number; 357 353 onSetReplyParent: (post: PostDoc) => void; 358 354 onNewReaction: (post: PostDoc, emoji: string) => Promise<void>; ··· 360 356 }) { 361 357 const postUri = post["$metadata.uri"]; 362 358 const postDate = new Date(post["$metadata.indexedAt"]); 359 + const [author, authorloading] = useCachedProfileJotai(post["$metadata.did"]); 360 + 361 + const reactionsquery = { 362 + query: { 363 + bool: { 364 + must: [ 365 + { 366 + term: { 367 + "$metadata.collection": "party.whey.ft.topic.reaction", 368 + }, 369 + }, 370 + { 371 + terms: { 372 + reactionSubject: [post["$metadata.uri"]] 373 + } 374 + }, 375 + ], 376 + }, 377 + }, 378 + sort: [{ "$metadata.indexedAt": { order: "asc" } }], 379 + }; 380 + 381 + const { uris: reactionUris = [], isLoading: isReactionsLoading } = 382 + useEsavQuery(`forumtest/${forumdid}/${post["$metadata.uri"]}/reactions`, reactionsquery!, { 383 + enabled: !!reactionsquery, 384 + }); 385 + 386 + function isReactionDoc(doc: unknown): doc is ReactionDoc { 387 + return ( 388 + typeof doc === 'object' && 389 + doc !== null && 390 + 'reactionEmoji' in doc && 391 + 'reactionSubject' in doc 392 + ); 393 + } 394 + 395 + const docsMap = useEsavDocument(reactionUris); 396 + const reactions = reactionUris 397 + .map((uri) => docsMap?.[uri]?.doc as unknown) 398 + .filter(isReactionDoc); 399 + 400 + if (!author || authorloading) { 401 + return ( 402 + <span> 403 + loading 404 + </span> 405 + ) 406 + } 363 407 364 408 return ( 365 409 <div ··· 441 485 const { forumHandle, userHandle, topicRKey } = useParams({ 442 486 from: "/f/$forumHandle/t/$userHandle/$topicRKey", 443 487 }); 488 + const [forum, isforumdidLoading] = useCachedProfileJotai(forumHandle); 489 + const [op, isOpdidLoading] = useCachedProfileJotai(userHandle); 490 + 491 + const uri = useMemo(() => { 492 + return `at://${op?.did}/party.whey.ft.topic.post/${topicRKey}`; 493 + }, [op?.did]); 444 494 const { agent, loading: authLoading } = useAuth(); 445 - const queryClient = useQueryClient(); 446 - const initialData = Route.useLoaderData(); 495 + //const topic = useEsavDocument(uri); 496 + //const parsed = parseAtUri(uri); 447 497 448 - const { data, isError, error } = useQuery({ 449 - ...topicQueryOptions(queryClient, userHandle, topicRKey), 450 - initialData, 451 - refetchInterval: 30 * 1000, // refresh every half minute 452 - }); 498 + const opQuery = { 499 + query: { 500 + term: { 501 + "$metadata.uri": uri, 502 + }, 503 + }, 504 + size: 1, 505 + sort: [{ "$metadata.indexedAt": { order: "asc" } }], 506 + }; 453 507 454 - const { posts, authors, reactions } = data; 508 + const fullRepliesQuery = { 509 + query: { 510 + bool: { must: [{ term: { root: uri } }] }, 511 + }, 512 + sort: [{ "$metadata.indexedAt": { order: "asc" } }], 513 + }; 514 + 515 + const { uris: opUris = [], isLoading: isopQueryLoading } = useEsavQuery( 516 + `forumtest/${op?.did}/${uri}`, 517 + opQuery!, 518 + { 519 + enabled: !!opQuery && !!op, 520 + } 521 + ); 522 + 523 + const { uris: repliesUris = [], isLoading: isQueryLoading } = useEsavQuery( 524 + `forumtest/${op?.did}/${uri}/replies`, 525 + fullRepliesQuery!, 526 + { 527 + enabled: !!fullRepliesQuery && !!op, 528 + } 529 + ); 530 + 531 + const oppost = useEsavDocument(uri); 532 + const docsMap = useEsavDocument(repliesUris); 533 + const posts = useMemo(() => { return [ 534 + oppost?.doc as PostDoc, 535 + ...repliesUris.map((uri) => docsMap?.[uri]?.doc as PostDoc), 536 + ].filter((doc): doc is PostDoc => !!doc); 537 + }, [oppost, docsMap]); 455 538 456 539 const [replyText, setReplyText] = useState(""); 457 540 const [isSubmitting, setIsSubmitting] = useState(false); ··· 462 545 const handleSetReplyParent = (post: PostDoc) => { 463 546 setReplyingTo(post); 464 547 document.getElementById("reply-box")?.focus(); 465 - }; 466 - 467 - const invalidateTopicQuery = () => { 468 - queryClient.invalidateQueries({ 469 - queryKey: ["topic", userHandle, topicRKey], 470 - }); 471 548 }; 472 549 473 550 const handleCreateReaction = async (post: PostDoc, emoji: string) => { ··· 477 554 try { 478 555 await agent.com.atproto.repo.createRecord({ 479 556 repo: agent.did, 480 - collection: "com.example.ft.topic.reaction", 557 + collection: "party.whey.ft.topic.reaction", 481 558 record: { 482 - $type: "com.example.ft.topic.reaction", 559 + $type: "party.whey.ft.topic.reaction", 483 560 reactionEmoji: emoji, 484 561 subject: post["$metadata.uri"], 485 562 createdAt: new Date().toISOString(), 486 563 }, 487 564 }); 488 - invalidateTopicQuery(); 565 + //invalidateTopicQuery(); 489 566 } catch (e) { 490 567 console.error("Failed to create reaction", e); 491 568 setMutationError("Failed to post reaction. Please try again."); ··· 502 579 try { 503 580 const rootPost = posts[0]; 504 581 const parentPost = replyingTo || rootPost; 505 - const identity = await queryClient.fetchQuery({ 506 - queryKey: ["identity", forumHandle], 507 - queryFn: () => resolveIdentity({ didOrHandle: forumHandle }), 508 - staleTime: 1000 * 60 * 60 * 24, 509 - }); 582 + const trimmed = forumHandle.startsWith("@") 583 + ? forumHandle.slice(1) 584 + : forumHandle; 585 + const identity = forum; 510 586 const forumDid = identity?.did; 511 587 if (!forumDid) { 512 588 throw new Error("Could not resolve forum handle to DID."); 513 589 } 514 590 await agent.com.atproto.repo.createRecord({ 515 591 repo: agent.did, 516 - collection: "com.example.ft.topic.post", 592 + collection: "party.whey.ft.topic.post", 517 593 record: { 518 - $type: "com.example.ft.topic.post", 594 + $type: "party.whey.ft.topic.post", 519 595 text: replyText, 520 596 forum: forumDid, 521 597 reply: { ··· 533 609 }); 534 610 setReplyText(""); 535 611 setReplyingTo(null); 536 - invalidateTopicQuery(); 612 + //invalidateTopicQuery(); 537 613 } catch (e) { 538 614 setMutationError(`Failed to post reply: ${(e as Error).message}`); 539 615 } finally { 540 616 setIsSubmitting(false); 541 617 } 542 618 }; 619 + if (!forum?.did || isOpdidLoading || isQueryLoading || isforumdidLoading || isopQueryLoading) { 620 + return ( 621 + <TopicPageSkeleton /> 622 + ) 623 + } 543 624 544 - if (isError) 545 - return ( 546 - <div className="text-red-500 p-8 text-center"> 547 - Error: {(error as Error).message} 548 - </div> 549 - ); 625 + // if (isError) 626 + // return ( 627 + // <div className="text-red-500 p-8 text-center"> 628 + // Error: {(error as Error).message} 629 + // </div> 630 + // ); 550 631 551 632 const topicPost = posts[0]; 552 633 const postIndexBeingRepliedTo = replyingTo ··· 574 655 575 656 {posts.map((post, index) => ( 576 657 <PostCard 658 + forumdid={forum?.did} 577 659 agent={agent} 578 660 key={post["$metadata.uri"]} 579 661 post={post} 580 - author={authors[post["$metadata.did"]]} 581 - reactions={reactions[post["$metadata.uri"]] || []} 662 + //author={authors[post["$metadata.did"]]} 663 + //reactions={reactions[post["$metadata.uri"]] || []} 582 664 index={index} 583 665 onSetReplyParent={handleSetReplyParent} 584 666 onNewReaction={handleCreateReaction}
+98 -80
src/routes/index.tsx
··· 3 3 import "../App.css"; 4 4 import { esavQuery } from "@/helpers/esquery"; 5 5 import { resolveIdentity } from "@/helpers/cachedidentityresolver"; 6 + import { useCachedProfileJotai, useEsavDocument, useEsavQuery } from "@/esav/hooks"; 7 + import type { QueryDoc } from "@/esav/types"; 6 8 7 9 type ForumDoc = { 8 10 "$metadata.uri": string; ··· 42 44 must: [ 43 45 { 44 46 term: { 45 - "$metadata.collection": "com.example.ft.forum.definition", 47 + "$metadata.collection": "party.whey.ft.forum.definition", 46 48 }, 47 49 }, 48 50 { term: { "$metadata.rkey": "self" } }, ··· 87 89 }); 88 90 89 91 export const Route = createFileRoute("/")({ 90 - loader: ({ context: { queryClient } }) => 91 - queryClient.ensureQueryData(forumsQueryOptions(queryClient)), 92 92 component: Home, 93 - pendingComponent: ForumGridSkeleton, 94 - errorComponent: ({ error }) => ( 95 - <div className="text-red-500 p-4">Error: {(error as Error).message}</div> 96 - ), 97 93 }); 98 94 99 95 function ForumGridSkeleton() { ··· 138 134 } 139 135 140 136 function Home() { 141 - const initialData = Route.useLoaderData(); 142 - const queryClient = useQueryClient(); 137 + const homeQuery = { 138 + query: { 139 + bool: { 140 + must: [ 141 + { 142 + term: { 143 + "$metadata.collection": "party.whey.ft.forum.definition", 144 + }, 145 + }, 146 + { term: { "$metadata.rkey": "self" } }, 147 + ], 148 + }, 149 + }, 150 + sort: [{ '$metadata.indexedAt': 'desc' }], 151 + size: 50, 152 + }; 153 + const { uris, isLoading } = useEsavQuery("forumtest", homeQuery); 143 154 144 - const { data: forums }: { data: ResolvedForum[] } = useQuery({ 145 - ...forumsQueryOptions(queryClient), 146 - initialData, 147 - }); 155 + if (isLoading) { 156 + return <ForumGridSkeleton /> 157 + } 148 158 149 159 return ( 150 160 <div className="w-full flex flex-col items-center"> ··· 155 165 </div> 156 166 157 167 <div className="mt-4 w-full forum-grid"> 158 - {forums.map((forum) => { 159 - const did = forum?.["$metadata.did"]; 160 - const { resolvedIdentity } = forum; 161 - if (!resolvedIdentity) return null; 168 + {uris.map((uri) => ( 169 + <ForumItem key={uri} uri={uri} /> 170 + ))} 171 + </div> 172 + </div> 173 + </div> 174 + </div> 175 + ); 176 + } 162 177 163 - const cidBanner = forum?.$raw?.banner?.ref?.$link; 164 - const cidAvatar = forum?.$raw?.avatar?.ref?.$link; 178 + function ForumItem({uri}:{uri:string}){ 179 + const data = useEsavDocument(uri); 180 + const did = data?.doc?.["$metadata.did"]; 181 + const [profile, isLoading] = useCachedProfileJotai(did); 182 + if (!data) return null 183 + const forum = data.doc; 184 + const resolvedIdentity = profile; 165 185 166 - const bannerUrl = 167 - cidBanner && resolvedIdentity 168 - ? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidBanner}` 169 - : null; 186 + const cidBanner = resolvedIdentity?.profile.banner?.ref?.$link; 187 + const cidAvatar = resolvedIdentity?.profile.avatar?.ref?.$link; 170 188 171 - const avatarUrl = 172 - cidAvatar && resolvedIdentity 173 - ? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidAvatar}` 174 - : null; 189 + const bannerUrl = 190 + cidBanner && resolvedIdentity 191 + ? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidBanner}` 192 + : null; 175 193 176 - return ( 177 - <Link 178 - // @ts-ignore 179 - to={`/f/@${resolvedIdentity.handle}`} 180 - className="block" 181 - key={forum?.$metadata?.uri} 182 - > 183 - <div 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" 186 - > 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} 200 - </div> 201 - )} 202 - <div className="text-white text-2xl font-bold leading-tight"> 203 - {forum.displayName || "Unnamed Forum"} 204 - </div> 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."} 217 - </div> 218 - <div className="text-xs text-gray-400 font-medium"> 219 - 0 members · ~0 topics · Active a while ago 220 - </div> 221 - </div> 222 - </div> 223 - </div> 224 - </Link> 225 - ); 226 - })} 194 + const avatarUrl = 195 + cidAvatar && resolvedIdentity 196 + ? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidAvatar}` 197 + : null; 198 + 199 + 200 + return ( 201 + <Link 202 + // @ts-expect-error force "@" instead of the encoded one 203 + to={`/f/@${resolvedIdentity?.handle}`} 204 + className="block" 205 + key={forum["$metadata.uri"]} 206 + > 207 + <div 208 + key={forum["$metadata.uri"]} 209 + 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" 210 + > 211 + {bannerUrl && ( 212 + <div 213 + className="absolute inset-0 bg-cover bg-center" 214 + style={{ backgroundImage: `url(${bannerUrl})` }} 215 + /> 216 + )} 217 + <div className="absolute inset-0 bg-black/60" /> 218 + <div className="relative z-10 flex flex-col justify-between h-full p-5"> 219 + <div className="flex justify-between items-start gap-4"> 220 + <div className="flex flex-col"> 221 + {resolvedIdentity?.handle && ( 222 + <div className="text-blue-300 text-base font-mono mb-1"> 223 + /f/@{resolvedIdentity.handle} 224 + </div> 225 + )} 226 + <div className="text-white text-2xl font-bold leading-tight"> 227 + {resolvedIdentity?.profile.displayName || "Unnamed Forum"} 228 + </div> 229 + </div> 230 + {avatarUrl && ( 231 + <img 232 + src={avatarUrl} 233 + alt="Avatar" 234 + className="w-12 h-12 rounded-full object-cover border border-zinc-700 flex-shrink-0" 235 + /> 236 + )} 237 + </div> 238 + <div className="flex flex-col gap-2 mt-4"> 239 + <div className="text-sm text-gray-200 line-clamp-2"> 240 + {String(forum.description || "No description available.")} 241 + </div> 242 + <div className="text-xs text-gray-400 font-medium"> 243 + 0 members · ~0 topics · Active a while ago 244 + </div> 227 245 </div> 228 246 </div> 229 247 </div> 230 - </div> 248 + </Link> 231 249 ); 232 - } 250 + }
+9 -7
src/routes/search.tsx
··· 18 18 PostCard, 19 19 PostCardSkeleton, 20 20 } from "@/routes/f/$forumHandle/t/$userHandle/$topicRKey"; 21 + import { useCachedProfileJotai } from "@/esav/hooks"; 21 22 22 23 type PostDoc = { 23 24 "$metadata.uri": string; ··· 71 72 function SearchResultCard({ post, ...rest }: SearchResultCardProps) { 72 73 const navigate = useNavigate(); 73 74 const [forumHandle, setForumHandle] = useState<string | undefined>(undefined); 75 + const [did, loadinger] = useCachedProfileJotai(forumHandle) 74 76 const { get, set } = usePersistentStore(); 75 77 76 78 const thing = post["forum"]// || new AtUripost["root"] ··· 162 164 )} 163 165 </div> 164 166 165 - <PostCard {...rest} post={post} onSetReplyParent={handleNavigateToPost} /> 167 + {did && (<PostCard forumdid={did.did} {...rest} post={post} onSetReplyParent={handleNavigateToPost} />)} 166 168 </div> 167 169 ); 168 170 } ··· 208 210 }, 209 211 filter: [ 210 212 { 211 - term: { "$metadata.collection": "com.example.ft.topic.post" }, 213 + term: { "$metadata.collection": "party.whey.ft.topic.post" }, 212 214 }, 213 215 ], 214 216 }, ··· 238 240 must: [ 239 241 { 240 242 term: { 241 - "$metadata.collection": "com.example.ft.topic.reaction", 243 + "$metadata.collection": "party.whey.ft.topic.reaction", 242 244 }, 243 245 }, 244 246 ], ··· 263 265 }>({ 264 266 query: { 265 267 bool: { 266 - must: [{ term: { $type: "com.example.ft.user.profile" } }], 268 + must: [{ term: { $type: "party.whey.ft.user.profile" } }], 267 269 filter: [{ terms: { "$metadata.did": allDids } }], 268 270 }, 269 271 }, ··· 349 351 const date = new Date().toISOString(); 350 352 const response = await agent.com.atproto.repo.createRecord({ 351 353 repo: agent.did, 352 - collection: "com.example.ft.topic.reaction", 354 + collection: "party.whey.ft.topic.reaction", 353 355 record: { 354 - $type: "com.example.ft.topic.reaction", 356 + $type: "party.whey.ft.topic.reaction", 355 357 reactionEmoji: emoji, 356 358 subject: postUri, 357 359 createdAt: date, ··· 359 361 }); 360 362 const uri = new AtUri(response.data.uri) 361 363 const newReaction: ReactionDoc = { 362 - "$metadata.collection": "com.example.ft.topic.reaction", 364 + "$metadata.collection": "party.whey.ft.topic.reaction", 363 365 "$metadata.uri": response.data.uri, 364 366 "$metadata.cid": response.data.cid, 365 367 "$metadata.did": agent.did,