ATProto forum built with ESAV
at main 9.8 kB view raw
1import { useAtom, useAtomValue, useSetAtom } from 'jotai'; 2import { useEffect, useMemo, useRef, useState } from 'react'; 3import { 4 activeSubscriptionsAtom, 5 documentsAtom, 6 queryStateFamily, 7 websocketAtom, 8 websocketStatusAtom, 9 addLogEntryAtom, 10 queryCacheAtom 11} from './atoms'; 12import type { EsavDocument, QueryDoc, SubscribeMessage, UnsubscribeMessage } from './types'; 13import { atomWithStorage } from 'jotai/utils'; 14 15interface UseEsavQueryOptions { 16 enabled?: boolean; 17} 18 19/** 20 * The primary hook for subscribing to a live query and getting its results. 21 * Manages sending subscribe/unsubscribe messages automatically. 22 * 23 * @param queryId A unique ID for this query. 24 * @param esQuery The full Elasticsearch query object. 25 * @param options Hook options, like `enabled`. 26 * @returns The hydrated query results and loading status. 27 */ 28export function useEsavQuery( 29 queryId: string, 30 esQuery: Record<string, any>, 31 options: UseEsavQueryOptions = { enabled: true } 32) { 33 // @ts-expect-error intended 34 const [activeSubscriptions, setActiveSubscriptions] = useAtom(activeSubscriptionsAtom); 35 const ws = useAtomValue(websocketAtom); 36 const addLog = useSetAtom(addLogEntryAtom); 37 const wsStatus = useAtomValue(websocketStatusAtom); 38 //const queryState = useAtomValue(queryStateFamily(queryId)); 39 const liveQueryState = useAtomValue(queryStateFamily(queryId)); 40 const [cache, setCache] = useAtom(queryCacheAtom); 41 const cachedQueryState = cache[queryId]; 42 const queryState = liveQueryState ?? cachedQueryState; 43 useEffect(() => { 44 // If we receive valid new data from the live query, update our cache. 45 if (liveQueryState?.result) { 46 setCache((prevCache) => { 47 // Avoid unnecessary updates if the data is identical 48 if (prevCache[queryId] === liveQueryState) { 49 return prevCache; 50 } 51 return { 52 ...prevCache, 53 [queryId]: liveQueryState, 54 }; 55 }); 56 } 57 }, [liveQueryState, queryId, setCache]); 58 59 const allDocuments = useAtomValue(documentsAtom); 60 61 const { enabled = true } = options; 62 const stringifiedEsQuery = useMemo(() => JSON.stringify(esQuery), [esQuery]); 63 64 const esQueryRef = useRef(esQuery); 65 const queryStateRef = useRef(queryState); 66 useEffect(() => { 67 esQueryRef.current = esQuery; 68 queryStateRef.current = queryState; 69 }); 70 71 useEffect(() => { 72 if (!enabled || wsStatus !== 'open' || !ws) { 73 return; 74 } 75 76 const currentQuery = esQueryRef.current; 77 78 setActiveSubscriptions((prev) => { 79 const count = prev[queryId]?.count ?? 0; 80 if (count === 0) { 81 console.log(`[ESAV] Subscribing to ${queryId}`); 82 const message: SubscribeMessage = { 83 type: 'subscribe', 84 queryId, 85 esquery: currentQuery, 86 ecid: queryStateRef.current?.ecid, 87 }; 88 addLog({ type: 'outgoing', payload: message }); 89 ws.send(JSON.stringify(message)); 90 } 91 return { ...prev, [queryId]: { count: count + 1, esQuery: currentQuery } }; 92 }); 93 94 return () => { 95 setActiveSubscriptions((prev) => { 96 const count = prev[queryId]?.count ?? 1; 97 if (count <= 1) { 98 console.log(`[ESAV] Unsubscribing from ${queryId}`); 99 if (ws.readyState === WebSocket.OPEN) { 100 const message: UnsubscribeMessage = { type: 'unsubscribe', queryId }; 101 addLog({ type: 'outgoing', payload: message }); 102 ws.send(JSON.stringify(message)); 103 } 104 const { [queryId]: _, ...rest } = prev; 105 return rest; 106 } else { 107 return { ...prev, [queryId]: { ...prev[queryId], count: count - 1 } }; 108 } 109 }); 110 }; 111 }, [queryId, stringifiedEsQuery, enabled, ws, wsStatus, setActiveSubscriptions, addLog]); 112 113 114 const hydratedData = useMemo(() => { 115 if (!queryState?.result) return []; 116 return queryState.result 117 .map((uri) => allDocuments[uri]) 118 .filter(Boolean); 119 }, [queryState?.result, allDocuments]); 120 121 //const isLoading = wsStatus !== 'open' || queryState === null; 122 const isLoading = !queryState; 123 124 return { 125 data: hydratedData, 126 uris: queryState?.result ?? [], 127 ecid: queryState?.ecid, 128 isLoading, 129 status: wsStatus, 130 }; 131} 132 133type DocumentMap = Record<string, EsavDocument | undefined>; 134 135/** 136 * A simple hook to get a single document from the global cache. 137 * @param uri The at:// URI of the document. 138 */ 139export function useEsavDocument(uri: string): EsavDocument | undefined; 140export function useEsavDocument(uri: string[]): DocumentMap; 141export function useEsavDocument(uri: undefined): undefined; 142export function useEsavDocument(uri: string | string[] | undefined): EsavDocument | undefined | DocumentMap { 143 const allDocuments = useAtomValue(documentsAtom); 144 145 if (typeof uri === 'string') { 146 return allDocuments[uri]; 147 } 148 149 if (Array.isArray(uri)) { 150 return uri.reduce<DocumentMap>((acc, key) => { 151 acc[key] = allDocuments[key]; 152 return acc; 153 }, {}); 154 } 155 156 return undefined; 157} 158 159 160export interface Profile { 161 did: string; 162 handle: string; 163 pdsUrl: string; 164 profile: { 165 "$type": "app.bsky.actor.profile", 166 "avatar"?: { 167 "$type": "blob", 168 "ref": { 169 "$link": string 170 }, 171 "mimeType": string, 172 "size": number 173 }, 174 "banner"?: { 175 "$type": "blob", 176 "ref": { 177 "$link": string 178 }, 179 "mimeType": string, 180 "size": number 181 }, 182 "createdAt": string, 183 "description": string, 184 "displayName": string 185 }; 186} 187 188/** 189 * A persistent atom to store the mapping from a user's handle to their DID. 190 * This avoids re-resolving handles we've already seen. 191 * 192 * Stored in localStorage under the key 'handleToDidCache'. 193 */ 194const handleToDidAtom = atomWithStorage<Record<string, string>>( 195 'handleToDidCache', 196 {} 197); 198 199/** 200 * A persistent atom to store the full profile document, keyed by the user's DID. 201 * This is the primary cache for profile data. 202 * 203 * Stored in localStorage under the key 'didToProfileCache'. 204 */ 205const didToProfileAtom = atomWithStorage<Record<string, Profile>>( 206 'didToProfileCache', 207 {} 208); 209 210/** 211 * Get a cached Profile document using Jotai persistent atoms. 212 * It will first check the cache, and if the profile is not found, 213 * it will fetch it from the network and update the cache. 214 * 215 * @param input The user's did or handle (with or without the @) 216 * @returns A tuple containing the Profile (or null) and a boolean indicating if it's loading. 217 */ 218export const useCachedProfileJotai = (input?: string | null): [Profile | null, boolean] => { 219 const [handleToDidCache, setHandleToDidCache] = useAtom(handleToDidAtom); 220 const [didToProfileCache, setDidToProfileCache] = useAtom(didToProfileAtom); 221 222 const [profile, setProfile] = useState<Profile | null>(null); 223 const [isLoading, setIsLoading] = useState(false); 224 225 useEffect(() => { 226 const resolveAndFetchProfile = async () => { 227 if (!input) { 228 setProfile(null); 229 return; 230 } 231 232 setIsLoading(true); 233 234 const normalizedInput = normalizeHandle(input); 235 const type = classifyIdentifier(normalizedInput); 236 237 if (type === "unknown") { 238 console.error("Invalid identifier provided:", input); 239 setProfile(null); 240 setIsLoading(false); 241 return; 242 } 243 244 let didFromCache: string | undefined; 245 if (type === 'handle') { 246 didFromCache = handleToDidCache[normalizedInput]; 247 } else { 248 didFromCache = normalizedInput; 249 } 250 251 if (didFromCache && didToProfileCache[didFromCache]) { 252 setProfile(didToProfileCache[didFromCache]); 253 setIsLoading(false); 254 return; 255 } 256 257 try { 258 const queryParam = type === "handle" ? "handle" : "did"; 259 const res = await fetch( 260 `https://esav.whey.party/xrpc/party.whey.esav.resolveIdentity?${queryParam}=${normalizedInput}&includeBskyProfile=true` 261 ); 262 263 if (!res.ok) { 264 throw new Error(`Failed to fetch profile for ${input}`); 265 } 266 267 const newProfile: Profile = await res.json(); 268 269 setDidToProfileCache(prev => ({ ...prev, [newProfile.did]: newProfile })); 270 setHandleToDidCache(prev => ({ ...prev, [newProfile.handle]: newProfile.did })); 271 272 setProfile(newProfile); 273 274 } catch (error) { 275 console.error(error); 276 setProfile(null); 277 } finally { 278 setIsLoading(false); 279 } 280 }; 281 282 resolveAndFetchProfile(); 283 284 }, [input, handleToDidCache, didToProfileCache, setHandleToDidCache, setDidToProfileCache]); 285 286 return [profile, isLoading]; 287}; 288 289export type IdentifierType = "did" | "handle" | "unknown"; 290 291function classifyIdentifier(input: string | null | undefined): IdentifierType { 292 if (!input) return "unknown"; 293 if (/^did:[a-z0-9]+:[\w.-]+$/i.test(input)) return "did"; 294 if (/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(input)) return "handle"; 295 return "unknown"; 296} 297 298function normalizeHandle(input: string): string { 299 if (!input) return ''; 300 return input.startsWith('@') ? input.slice(1) : input; 301} 302 303 304 305type AtUriParts = { 306 did: string; 307 collection: string; 308 rkey: string; 309}; 310 311export function parseAtUri(uri: string): AtUriParts | null { 312 if (!uri.startsWith('at://')) return null; 313 314 const parts = uri.slice(5).split('/'); 315 if (parts.length < 3) return null; 316 317 const [did, collection, ...rest] = parts; 318 const rkey = rest.join('/'); // in case rkey includes slashes (rare, but allowed) 319 320 return { did, collection, rkey }; 321} 322/** 323 * use useEsavDocument instead its nicer 324 * @deprecated 325 * @param uris 326 * @returns 327 */ 328export function useResolvedDocuments(uris: string[]) { 329 const allDocuments = useAtomValue(documentsAtom); 330 331 return uris.reduce<Record<string, QueryDoc | undefined>>((acc, uri) => { 332 acc[uri] = allDocuments[uri].doc; 333 return acc; 334 }, {}); 335}