ATProto forum built with ESAV
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}