Live video on the AT Protocol

profile cache context with batching if needed

+76 -36
+65 -6
js/components/src/context/profile-cache.tsx
··· 1 1 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 2 - import { createContext, useContext, useRef, useState } from "react"; 2 + import { 3 + createContext, 4 + useCallback, 5 + useContext, 6 + useRef, 7 + useState, 8 + } from "react"; 9 + import { useUnauthenticatedBlueskyAppViewAgent } from "../streamplace-store"; 3 10 4 11 interface ProfileCacheContextValue { 5 12 profiles: Record<string, ProfileViewDetailed>; 6 - inFlight: React.MutableRefObject<Set<string>>; 7 - addProfiles: (profiles: Record<string, ProfileViewDetailed>) => void; 13 + requestProfiles: (dids: string[]) => void; 8 14 } 9 15 10 16 export const ProfileCacheContext = ··· 15 21 }: { 16 22 children: React.ReactNode; 17 23 }) { 24 + const agent = useUnauthenticatedBlueskyAppViewAgent(); 18 25 const [profiles, setProfiles] = useState<Record<string, ProfileViewDetailed>>( 19 26 {}, 20 27 ); 28 + const agentRef = useRef(agent); 29 + agentRef.current = agent; 30 + const profilesRef = useRef(profiles); 31 + profilesRef.current = profiles; 21 32 const inFlight = useRef<Set<string>>(new Set()); 33 + const pending = useRef<Set<string>>(new Set()); 34 + const timer = useRef<ReturnType<typeof setTimeout> | null>(null); 22 35 23 - const addProfiles = (newProfiles: Record<string, ProfileViewDetailed>) => 24 - setProfiles((prev) => ({ ...prev, ...newProfiles })); 36 + const flush = useCallback(() => { 37 + timer.current = null; 38 + if (!agentRef.current) return; 39 + 40 + const toFetch = [...pending.current] 41 + .filter((d) => !(d in profilesRef.current) && !inFlight.current.has(d)) 42 + .slice(0, 25); 43 + toFetch.forEach((d) => pending.current.delete(d)); 44 + 45 + if (toFetch.length === 0) return; 46 + 47 + // If there are more beyond the batch limit, schedule the remainder 48 + if (pending.current.size > 0) { 49 + timer.current = setTimeout(flush, 0); 50 + } 51 + 52 + toFetch.forEach((d) => inFlight.current.add(d)); 53 + 54 + agentRef.current 55 + .getProfiles({ actors: toFetch }) 56 + .then((result) => { 57 + const newProfiles: Record<string, ProfileViewDetailed> = {}; 58 + result.data.profiles.forEach((p) => { 59 + newProfiles[p.did] = p; 60 + }); 61 + setProfiles((prev) => ({ ...prev, ...newProfiles })); 62 + }) 63 + .catch((e) => { 64 + console.error("Failed to fetch profiles", e); 65 + }) 66 + .finally(() => { 67 + toFetch.forEach((d) => inFlight.current.delete(d)); 68 + }); 69 + }, []); 70 + 71 + const requestProfiles = useCallback( 72 + (dids: string[]) => { 73 + const toQueue = dids.filter( 74 + (d) => !(d in profilesRef.current) && !inFlight.current.has(d), 75 + ); 76 + if (toQueue.length === 0) return; 77 + toQueue.forEach((d) => pending.current.add(d)); 78 + if (!timer.current) { 79 + timer.current = setTimeout(flush, 50); 80 + } 81 + }, 82 + [flush], 83 + ); 25 84 26 85 return ( 27 - <ProfileCacheContext.Provider value={{ profiles, inFlight, addProfiles }}> 86 + <ProfileCacheContext.Provider value={{ profiles, requestProfiles }}> 28 87 {children} 29 88 </ProfileCacheContext.Provider> 30 89 );
+3 -30
js/components/src/hooks/useAvatars.tsx
··· 1 1 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 2 2 import { useEffect, useMemo } from "react"; 3 3 import { useProfileCache } from "../context/profile-cache"; 4 - import { usePDSAgent } from "../streamplace-store/xrpc"; 5 4 6 5 export function useAvatars( 7 6 dids: string[], 8 7 ): Record<string, ProfileViewDetailed> { 9 - const agent = usePDSAgent(); 10 - const { profiles, inFlight, addProfiles } = useProfileCache(); 11 - 12 - const missingDids = useMemo( 13 - () => 14 - dids.filter((did) => !(did in profiles) && !inFlight.current.has(did)), 15 - [dids, profiles, inFlight], 16 - ); 8 + const { profiles, requestProfiles } = useProfileCache(); 17 9 18 10 useEffect(() => { 19 - if (missingDids.length === 0 || !agent) return; 20 - const toFetch = missingDids.slice(0, 25); 21 - toFetch.forEach((did) => inFlight.current.add(did)); 22 - 23 - const fetchProfiles = async () => { 24 - try { 25 - const result = await agent.getProfiles({ actors: toFetch }); 26 - const newProfiles: Record<string, ProfileViewDetailed> = {}; 27 - result.data.profiles.forEach((p) => { 28 - newProfiles[p.did] = p; 29 - }); 30 - addProfiles(newProfiles); 31 - } catch (e) { 32 - console.error("Failed to fetch profiles", e); 33 - } finally { 34 - toFetch.forEach((did) => inFlight.current.delete(did)); 35 - } 36 - }; 37 - 38 - fetchProfiles(); 39 - }, [missingDids, agent]); 11 + requestProfiles(dids); 12 + }, [dids, requestProfiles]); 40 13 41 14 return useMemo( 42 15 () =>
+8
js/components/src/streamplace-store/xrpc.tsx
··· 37 37 return new StreamplaceAgent(oauthSession); 38 38 }, [oauthSession]); 39 39 } 40 + 41 + // always returns an unauthenticated agent pointed at the public bluesky API 42 + // probably should not be used in most places, but in case we have a bug it may be useful 43 + export function useUnauthenticatedBlueskyAppViewAgent(): StreamplaceAgent { 44 + return useMemo(() => { 45 + return new StreamplaceAgent("https://public.api.bsky.app"); 46 + }, []); 47 + }