···11+# AtReact Hooks Deep Dive
22+33+## Overview
44+The AtReact hooks system provides a robust, cache-optimized layer for fetching AT Protocol data. All hooks follow React best practices with proper cleanup, cancellation, and stable references.
55+66+---
77+88+## Core Architecture Principles
99+1010+### 1. **Three-Tier Caching Strategy**
1111+All data flows through three cache layers:
1212+- **DidCache** - DID documents, handle mappings, PDS endpoints
1313+- **BlobCache** - Media/image blobs with reference counting
1414+- **RecordCache** - AT Protocol records with deduplication
1515+1616+### 2. **Concurrent Request Deduplication**
1717+When multiple components request the same data, only one network request is made. Uses reference counting to manage in-flight requests.
1818+1919+### 3. **Stable Reference Pattern**
2020+Caches use memoized snapshots to prevent unnecessary re-renders:
2121+```typescript
2222+// Only creates new snapshot if data actually changed
2323+if (existing && existing.did === did && existing.handle === handle) {
2424+ return toSnapshot(existing); // Reuse existing
2525+}
2626+```
2727+2828+### 4. **Three-Tier Fallback for Bluesky**
2929+For `app.bsky.*` collections:
3030+1. Try Bluesky appview API (fastest, public)
3131+2. Fall back to Slingshot (microcosm service)
3232+3. Finally query PDS directly
3333+3434+---
3535+3636+## Hook Catalog
3737+3838+## 1. `useDidResolution`
3939+**Purpose:** Resolves handles to DIDs or fetches DID documents
4040+4141+### Key Features:
4242+- **Bidirectional:** Works with handles OR DIDs
4343+- **Smart Caching:** Only fetches if not in cache
4444+- **Dual Resolution Paths:**
4545+ - Handle → DID: Uses Slingshot first, then appview
4646+ - DID → Document: Fetches full DID document for handle extraction
4747+4848+### State Flow:
4949+```typescript
5050+Input: "alice.bsky.social" or "did:plc:xxx"
5151+ ↓
5252+Check didCache
5353+ ↓
5454+If handle: ensureHandle(resolver, handle) → DID
5555+If DID: ensureDidDoc(resolver, did) → DID doc + handle from alsoKnownAs
5656+ ↓
5757+Return: { did, handle, loading, error }
5858+```
5959+6060+### Critical Implementation Details:
6161+- **Normalizes input** to lowercase for handles
6262+- **Memoizes input** to prevent effect re-runs
6363+- **Stabilizes error references** - only updates if message changes
6464+- **Cleanup:** Cancellation token prevents stale updates
6565+6666+---
6767+6868+## 2. `usePdsEndpoint`
6969+**Purpose:** Discovers the PDS endpoint for a DID
7070+7171+### Key Features:
7272+- **Depends on DID resolution** (implicit dependency)
7373+- **Extracts from DID document** if already cached
7474+- **Lazy fetching** - only when endpoint not in cache
7575+7676+### State Flow:
7777+```typescript
7878+Input: DID
7979+ ↓
8080+Check didCache.getByDid(did).pdsEndpoint
8181+ ↓
8282+If missing: ensurePdsEndpoint(resolver, did)
8383+ ├─ Tries to get from existing DID doc
8484+ └─ Falls back to resolver.pdsEndpointForDid()
8585+ ↓
8686+Return: { endpoint, loading, error }
8787+```
8888+8989+### Service Discovery:
9090+Looks for `AtprotoPersonalDataServer` service in DID document:
9191+```json
9292+{
9393+ "service": [{
9494+ "type": "AtprotoPersonalDataServer",
9595+ "serviceEndpoint": "https://pds.example.com"
9696+ }]
9797+}
9898+```
9999+100100+---
101101+102102+## 3. `useAtProtoRecord`
103103+**Purpose:** Fetches a single AT Protocol record with smart routing
104104+105105+### Key Features:
106106+- **Collection-aware routing:** Bluesky vs other protocols
107107+- **RecordCache deduplication:** Multiple components = one fetch
108108+- **Cleanup with reference counting**
109109+110110+### State Flow:
111111+```typescript
112112+Input: { did, collection, rkey }
113113+ ↓
114114+If collection.startsWith("app.bsky."):
115115+ └─ useBlueskyAppview() → Three-tier fallback
116116+Else:
117117+ ├─ useDidResolution(did)
118118+ ├─ usePdsEndpoint(resolved.did)
119119+ └─ recordCache.ensure() → Fetch from PDS
120120+ ↓
121121+Return: { record, loading, error }
122122+```
123123+124124+### RecordCache Deduplication:
125125+```typescript
126126+// First component calling this
127127+const { promise, release } = recordCache.ensure(did, collection, rkey, loader)
128128+// refCount = 1
129129+130130+// Second component calling same record
131131+const { promise, release } = recordCache.ensure(...) // Same promise!
132132+// refCount = 2
133133+134134+// On cleanup, both call release()
135135+// Only aborts when refCount reaches 0
136136+```
137137+138138+---
139139+140140+## 4. `useBlueskyAppview`
141141+**Purpose:** Fetches Bluesky records with appview optimization
142142+143143+### Key Features:
144144+- **Collection-aware endpoints:**
145145+ - `app.bsky.actor.profile` → `app.bsky.actor.getProfile`
146146+ - `app.bsky.feed.post` → `app.bsky.feed.getPostThread`
147147+- **CDN URL extraction:** Parses CDN URLs to extract CIDs
148148+- **Atomic state updates:** Uses reducer for complex state
149149+150150+### Three-Tier Fallback with Source Tracking:
151151+```typescript
152152+async function fetchWithFallback() {
153153+ // Tier 1: Appview (if endpoint mapped)
154154+ try {
155155+ const result = await fetchFromAppview(did, collection, rkey);
156156+ return { record: result, source: "appview" };
157157+ } catch {}
158158+159159+ // Tier 2: Slingshot
160160+ try {
161161+ const result = await fetchFromSlingshot(did, collection, rkey);
162162+ return { record: result, source: "slingshot" };
163163+ } catch {}
164164+165165+ // Tier 3: PDS
166166+ try {
167167+ const result = await fetchFromPds(did, collection, rkey);
168168+ return { record: result, source: "pds" };
169169+ } catch {}
170170+171171+ // All tiers failed - provide helpful error for banned Bluesky accounts
172172+ if (pdsEndpoint.includes('.bsky.network')) {
173173+ throw new Error('Record unavailable. The Bluesky PDS may be unreachable or the account may be banned.');
174174+ }
175175+176176+ throw new Error('Failed to fetch record from all sources');
177177+}
178178+```
179179+180180+The `source` field in the result accurately indicates which tier successfully fetched the data, enabling debugging and analytics.
181181+182182+### CDN URL Handling:
183183+Appview returns CDN URLs like:
184184+```
185185+https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg
186186+```
187187+188188+Hook extracts CID (`bafkreixxx`) and creates standard Blob object:
189189+```typescript
190190+{
191191+ $type: "blob",
192192+ ref: { $link: "bafkreixxx" },
193193+ mimeType: "image/jpeg",
194194+ size: 0,
195195+ cdnUrl: "https://cdn.bsky.app/..." // Preserved for fast rendering
196196+}
197197+```
198198+199199+### Reducer Pattern:
200200+```typescript
201201+type Action =
202202+ | { type: "SET_LOADING"; loading: boolean }
203203+ | { type: "SET_SUCCESS"; record: T; source: "appview" | "slingshot" | "pds" }
204204+ | { type: "SET_ERROR"; error: Error }
205205+ | { type: "RESET" };
206206+207207+// Atomic state updates, no race conditions
208208+dispatch({ type: "SET_SUCCESS", record, source });
209209+```
210210+211211+---
212212+213213+## 5. `useLatestRecord`
214214+**Purpose:** Fetches the most recent record from a collection
215215+216216+### Key Features:
217217+- **Timestamp validation:** Skips records before 2023 (pre-ATProto)
218218+- **PDS-only:** Slingshot doesn't support `listRecords`
219219+- **Smart fetching:** Gets 3 records to handle invalid timestamps
220220+221221+### State Flow:
222222+```typescript
223223+Input: { did, collection }
224224+ ↓
225225+useDidResolution(did)
226226+usePdsEndpoint(did)
227227+ ↓
228228+callListRecords(endpoint, did, collection, limit: 3)
229229+ ↓
230230+Filter: isValidTimestamp(record) → year >= 2023
231231+ ↓
232232+Return first valid record: { record, rkey, loading, error, empty }
233233+```
234234+235235+### Timestamp Validation:
236236+```typescript
237237+function isValidTimestamp(record: unknown): boolean {
238238+ const timestamp = record.createdAt || record.indexedAt;
239239+ if (!timestamp) return true; // No timestamp, assume valid
240240+241241+ const date = new Date(timestamp);
242242+ return date.getFullYear() >= 2023; // ATProto created in 2023
243243+}
244244+```
245245+246246+---
247247+248248+## 6. `usePaginatedRecords`
249249+**Purpose:** Cursor-based pagination with prefetching
250250+251251+### Key Features:
252252+- **Dual fetching modes:**
253253+ - Author feed (appview) - for Bluesky posts with filters
254254+ - Direct PDS - for all other collections
255255+- **Smart prefetching:** Loads next page in background
256256+- **Invalid timestamp filtering:** Same as `useLatestRecord`
257257+- **Request sequencing:** Prevents race conditions with `requestSeq`
258258+259259+### State Management:
260260+```typescript
261261+// Pages stored as array
262262+pages: [
263263+ { records: [...], cursor: "abc" }, // page 0
264264+ { records: [...], cursor: "def" }, // page 1
265265+ { records: [...], cursor: undefined } // page 2 (last)
266266+]
267267+pageIndex: 1 // Currently viewing page 1
268268+```
269269+270270+### Prefetch Logic:
271271+```typescript
272272+useEffect(() => {
273273+ const cursor = pages[pageIndex]?.cursor;
274274+ if (!cursor || pages[pageIndex + 1]) return; // No cursor or already loaded
275275+276276+ // Prefetch next page in background
277277+ fetchPage(identity, cursor, pageIndex + 1, "prefetch");
278278+}, [pageIndex, pages]);
279279+```
280280+281281+### Author Feed vs PDS:
282282+```typescript
283283+if (preferAuthorFeed && collection === "app.bsky.feed.post") {
284284+ // Use app.bsky.feed.getAuthorFeed
285285+ const res = await callAppviewRpc("app.bsky.feed.getAuthorFeed", {
286286+ actor: handle || did,
287287+ filter: "posts_with_media", // Optional filter
288288+ includePins: true
289289+ });
290290+} else {
291291+ // Use com.atproto.repo.listRecords
292292+ const res = await callListRecords(pdsEndpoint, did, collection, limit);
293293+}
294294+```
295295+296296+### Race Condition Prevention:
297297+```typescript
298298+const requestSeq = useRef(0);
299299+300300+// On identity change
301301+resetState();
302302+requestSeq.current += 1; // Invalidate in-flight requests
303303+304304+// In fetch callback
305305+const token = requestSeq.current;
306306+// ... do async work ...
307307+if (token !== requestSeq.current) return; // Stale request, abort
308308+```
309309+310310+---
311311+312312+## 7. `useBlob`
313313+**Purpose:** Fetches and caches media blobs with object URL management
314314+315315+### Key Features:
316316+- **Automatic cleanup:** Revokes object URLs on unmount
317317+- **BlobCache deduplication:** Same blob = one fetch
318318+- **Reference counting:** Safe concurrent access
319319+320320+### State Flow:
321321+```typescript
322322+Input: { did, cid }
323323+ ↓
324324+useDidResolution(did)
325325+usePdsEndpoint(did)
326326+ ↓
327327+Check blobCache.get(did, cid)
328328+ ↓
329329+If missing: blobCache.ensure() → Fetch from PDS
330330+ ├─ GET /xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}
331331+ └─ Store in cache
332332+ ↓
333333+Create object URL: URL.createObjectURL(blob)
334334+ ↓
335335+Return: { url, loading, error }
336336+ ↓
337337+Cleanup: URL.revokeObjectURL(url)
338338+```
339339+340340+### Object URL Management:
341341+```typescript
342342+const objectUrlRef = useRef<string>();
343343+344344+// On successful fetch
345345+const nextUrl = URL.createObjectURL(blob);
346346+const prevUrl = objectUrlRef.current;
347347+objectUrlRef.current = nextUrl;
348348+if (prevUrl) URL.revokeObjectURL(prevUrl); // Clean up old URL
349349+350350+// On unmount
351351+useEffect(() => () => {
352352+ if (objectUrlRef.current) {
353353+ URL.revokeObjectURL(objectUrlRef.current);
354354+ }
355355+}, []);
356356+```
357357+358358+---
359359+360360+## 8. `useBlueskyProfile`
361361+**Purpose:** Wrapper around `useBlueskyAppview` for profile records
362362+363363+### Key Features:
364364+- **Simplified interface:** Just pass DID
365365+- **Type conversion:** Converts ProfileRecord to BlueskyProfileData
366366+- **CID extraction:** Extracts avatar/banner CIDs from blobs
367367+368368+### Implementation:
369369+```typescript
370370+export function useBlueskyProfile(did: string | undefined) {
371371+ const { record, loading, error } = useBlueskyAppview<ProfileRecord>({
372372+ did,
373373+ collection: "app.bsky.actor.profile",
374374+ rkey: "self",
375375+ });
376376+377377+ const data = record ? {
378378+ did: did || "",
379379+ handle: "", // Populated by caller
380380+ displayName: record.displayName,
381381+ description: record.description,
382382+ avatar: extractCidFromBlob(record.avatar),
383383+ banner: extractCidFromBlob(record.banner),
384384+ createdAt: record.createdAt,
385385+ } : undefined;
386386+387387+ return { data, loading, error };
388388+}
389389+```
390390+391391+---
392392+393393+## 9. `useBacklinks`
394394+**Purpose:** Fetches backlinks from Microcosm Constellation API
395395+396396+### Key Features:
397397+- **Specialized use case:** Tangled stars, etc.
398398+- **Abort controller:** Cancels in-flight requests
399399+- **Refetch support:** Manual refresh capability
400400+401401+### State Flow:
402402+```typescript
403403+Input: { subject: "at://did:plc:xxx/sh.tangled.repo/yyy", source: "sh.tangled.feed.star:subject" }
404404+ ↓
405405+GET https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks
406406+ ?subject={subject}&source={source}&limit={limit}
407407+ ↓
408408+Return: { backlinks: [...], total, loading, error, refetch }
409409+```
410410+411411+---
412412+413413+## 10. `useRepoLanguages`
414414+**Purpose:** Fetches language statistics from Tangled knot server
415415+416416+### Key Features:
417417+- **Branch fallback:** Tries "main", then "master"
418418+- **Knot server query:** For repository analysis
419419+420420+### State Flow:
421421+```typescript
422422+Input: { knot: "knot.gaze.systems", did, repoName, branch }
423423+ ↓
424424+GET https://{knot}/xrpc/sh.tangled.repo.languages
425425+ ?repo={did}/{repoName}&ref={branch}
426426+ ↓
427427+If 404: Try fallback branch
428428+ ↓
429429+Return: { data: { languages: {...} }, loading, error }
430430+```
431431+432432+---
433433+434434+## Cache Implementation Deep Dive
435435+436436+### DidCache
437437+**Purpose:** Cache DID documents, handle mappings, PDS endpoints
438438+439439+```typescript
440440+class DidCache {
441441+ private byHandle = new Map<string, DidCacheEntry>();
442442+ private byDid = new Map<string, DidCacheEntry>();
443443+ private handlePromises = new Map<string, Promise<...>>();
444444+ private docPromises = new Map<string, Promise<...>>();
445445+ private pdsPromises = new Map<string, Promise<...>>();
446446+447447+ // Memoized snapshots prevent re-renders
448448+ private toSnapshot(entry): DidCacheSnapshot {
449449+ if (entry.snapshot) return entry.snapshot; // Reuse
450450+ entry.snapshot = { did, handle, doc, pdsEndpoint };
451451+ return entry.snapshot;
452452+ }
453453+}
454454+```
455455+456456+**Key methods:**
457457+- `getByHandle(handle)` - Instant cache lookup
458458+- `getByDid(did)` - Instant cache lookup
459459+- `ensureHandle(resolver, handle)` - Deduplicated resolution
460460+- `ensureDidDoc(resolver, did)` - Deduplicated doc fetch
461461+- `ensurePdsEndpoint(resolver, did)` - Deduplicated PDS discovery
462462+463463+**Snapshot stability:**
464464+```typescript
465465+memoize(entry) {
466466+ const existing = this.byDid.get(did);
467467+468468+ // Data unchanged? Reuse snapshot (same reference)
469469+ if (existing && existing.did === did &&
470470+ existing.handle === handle && ...) {
471471+ return toSnapshot(existing); // Prevents re-render!
472472+ }
473473+474474+ // Data changed, create new entry
475475+ const merged = { did, handle, doc, pdsEndpoint, snapshot: undefined };
476476+ this.byDid.set(did, merged);
477477+ return toSnapshot(merged);
478478+}
479479+```
480480+481481+### BlobCache
482482+**Purpose:** Cache media blobs with reference counting
483483+484484+```typescript
485485+class BlobCache {
486486+ private store = new Map<string, BlobCacheEntry>();
487487+ private inFlight = new Map<string, InFlightBlobEntry>();
488488+489489+ ensure(did, cid, loader) {
490490+ // Already cached?
491491+ const cached = this.get(did, cid);
492492+ if (cached) return { promise: Promise.resolve(cached), release: noop };
493493+494494+ // In-flight request?
495495+ const existing = this.inFlight.get(key);
496496+ if (existing) {
497497+ existing.refCount++; // Multiple consumers
498498+ return { promise: existing.promise, release: () => this.release(key) };
499499+ }
500500+501501+ // New request
502502+ const { promise, abort } = loader();
503503+ this.inFlight.set(key, { promise, abort, refCount: 1 });
504504+ return { promise, release: () => this.release(key) };
505505+ }
506506+507507+ private release(key) {
508508+ const entry = this.inFlight.get(key);
509509+ entry.refCount--;
510510+ if (entry.refCount <= 0) {
511511+ this.inFlight.delete(key);
512512+ entry.abort(); // Cancel fetch
513513+ }
514514+ }
515515+}
516516+```
517517+518518+### RecordCache
519519+**Purpose:** Cache AT Protocol records with deduplication
520520+521521+Identical structure to BlobCache but for record data.
522522+523523+---
524524+525525+## Common Patterns
526526+527527+### 1. Cancellation Pattern
528528+```typescript
529529+useEffect(() => {
530530+ let cancelled = false;
531531+532532+ const assignState = (next) => {
533533+ if (cancelled) return; // Don't update unmounted component
534534+ setState(prev => ({ ...prev, ...next }));
535535+ };
536536+537537+ // ... async work ...
538538+539539+ return () => {
540540+ cancelled = true; // Mark as cancelled
541541+ release?.(); // Decrement refCount
542542+ };
543543+}, [deps]);
544544+```
545545+546546+### 2. Error Stabilization Pattern
547547+```typescript
548548+setError(prevError =>
549549+ prevError?.message === newError.message
550550+ ? prevError // Reuse same reference
551551+ : newError // New error
552552+);
553553+```
554554+555555+### 3. Identity Tracking Pattern
556556+```typescript
557557+const identityRef = useRef<string>();
558558+const identity = did && endpoint ? `${did}::${endpoint}` : undefined;
559559+560560+useEffect(() => {
561561+ if (identityRef.current !== identity) {
562562+ identityRef.current = identity;
563563+ resetState(); // Clear stale data
564564+ }
565565+ // ...
566566+}, [identity]);
567567+```
568568+569569+### 4. Dual-Mode Resolution
570570+```typescript
571571+const isDid = input.startsWith("did:");
572572+const normalizedHandle = !isDid ? input.toLowerCase() : undefined;
573573+574574+// Different code paths
575575+if (isDid) {
576576+ snapshot = await didCache.ensureDidDoc(resolver, input);
577577+} else {
578578+ snapshot = await didCache.ensureHandle(resolver, normalizedHandle);
579579+}
580580+```
581581+582582+---
583583+584584+## Performance Optimizations
585585+586586+### 1. **Memoized Snapshots**
587587+Caches return stable references when data unchanged → prevents re-renders
588588+589589+### 2. **Reference Counting**
590590+Multiple components requesting same data share one fetch
591591+592592+### 3. **Prefetching**
593593+`usePaginatedRecords` loads next page in background
594594+595595+### 4. **CDN URLs**
596596+Bluesky appview returns CDN URLs → skip blob fetching for images
597597+598598+### 5. **Smart Routing**
599599+Bluesky collections use fast appview → non-Bluesky goes direct to PDS
600600+601601+### 6. **Request Deduplication**
602602+In-flight request maps prevent duplicate fetches
603603+604604+### 7. **Timestamp Validation**
605605+Skip invalid records early (before 2023) → fewer wasted cycles
606606+607607+---
608608+609609+## Error Handling Strategy
610610+611611+### 1. **Fallback Chains**
612612+Never fail on first attempt → try multiple sources
613613+614614+### 2. **Graceful Degradation**
615615+```typescript
616616+// Slingshot failed? Try appview
617617+try {
618618+ return await fetchFromSlingshot();
619619+} catch (slingshotError) {
620620+ try {
621621+ return await fetchFromAppview();
622622+ } catch (appviewError) {
623623+ // Combine errors for better debugging
624624+ throw new Error(`${appviewError.message}; Slingshot: ${slingshotError.message}`);
625625+ }
626626+}
627627+```
628628+629629+### 3. **Component Isolation**
630630+Errors in one component don't crash others (via error boundaries recommended)
631631+632632+### 4. **Abort Handling**
633633+```typescript
634634+try {
635635+ await fetch(url, { signal });
636636+} catch (err) {
637637+ if (err.name === "AbortError") return; // Expected, ignore
638638+ throw err;
639639+}
640640+```
641641+642642+### 5. **Banned Bluesky Account Detection**
643643+When all three tiers fail and the PDS is a `.bsky.network` endpoint, provide a helpful error:
644644+```typescript
645645+// All tiers failed - check if it's a banned Bluesky account
646646+if (pdsEndpoint.includes('.bsky.network')) {
647647+ throw new Error(
648648+ 'Record unavailable. The Bluesky PDS may be unreachable or the account may be banned.'
649649+ );
650650+}
651651+```
652652+653653+This helps users understand why data is unavailable instead of showing generic fetch errors. Applies to both `useBlueskyAppview` and `useAtProtoRecord` hooks.
654654+655655+---
656656+657657+## Testing Considerations
658658+659659+### Key scenarios to test:
660660+1. **Concurrent requests:** Multiple components requesting same data
661661+2. **Race conditions:** Component unmounting mid-fetch
662662+3. **Cache invalidation:** Identity changes during fetch
663663+4. **Error fallbacks:** Slingshot down → appview works
664664+5. **Timestamp filtering:** Records before 2023 skipped
665665+6. **Reference counting:** Proper cleanup on unmount
666666+7. **Prefetching:** Background loads don't interfere with active loads
667667+668668+---
669669+670670+## Common Gotchas
671671+672672+### 1. **React Rules of Hooks**
673673+All hooks called unconditionally, even if results not used:
674674+```typescript
675675+// Always call, conditionally use results
676676+const blueskyResult = useBlueskyAppview({
677677+ did: isBlueskyCollection ? handleOrDid : undefined, // Pass undefined to skip
678678+ collection: isBlueskyCollection ? collection : undefined,
679679+ rkey: isBlueskyCollection ? rkey : undefined,
680680+});
681681+```
682682+683683+### 2. **Cleanup Order Matters**
684684+```typescript
685685+return () => {
686686+ cancelled = true; // 1. Prevent state updates
687687+ release?.(); // 2. Decrement refCount
688688+ revokeObjectURL(...); // 3. Free resources
689689+};
690690+```
691691+692692+### 3. **Snapshot Reuse**
693693+Don't modify cached snapshots! They're shared across components.
694694+695695+### 4. **CDN URL Extraction**
696696+Bluesky CDN URLs must be parsed carefully:
697697+```
698698+https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg
699699+ ^^^^^^^^^^^^ ^^^^^^
700700+ DID CID
701701+```
+125
lib/components/CurrentlyPlaying.tsx
···11+import React from "react";
22+import { AtProtoRecord } from "../core/AtProtoRecord";
33+import { CurrentlyPlayingRenderer } from "../renderers/CurrentlyPlayingRenderer";
44+import { useDidResolution } from "../hooks/useDidResolution";
55+import type { TealActorStatusRecord } from "../types/teal";
66+77+/**
88+ * Props for rendering teal.fm currently playing status.
99+ */
1010+export interface CurrentlyPlayingProps {
1111+ /** DID of the user whose currently playing status to display. */
1212+ did: string;
1313+ /** Record key within the `fm.teal.alpha.actor.status` collection (usually "self"). */
1414+ rkey?: string;
1515+ /** Prefetched teal.fm status record. When provided, skips fetching from the network. */
1616+ record?: TealActorStatusRecord;
1717+ /** Optional renderer override for custom presentation. */
1818+ renderer?: React.ComponentType<CurrentlyPlayingRendererInjectedProps>;
1919+ /** Fallback node displayed before loading begins. */
2020+ fallback?: React.ReactNode;
2121+ /** Indicator node shown while data is loading. */
2222+ loadingIndicator?: React.ReactNode;
2323+ /** Preferred color scheme for theming. */
2424+ colorScheme?: "light" | "dark" | "system";
2525+ /** Auto-refresh music data and album art every 15 seconds. Defaults to true. */
2626+ autoRefresh?: boolean;
2727+}
2828+2929+/**
3030+ * Values injected into custom currently playing renderer implementations.
3131+ */
3232+export type CurrentlyPlayingRendererInjectedProps = {
3333+ /** Loaded teal.fm status record value. */
3434+ record: TealActorStatusRecord;
3535+ /** Indicates whether the record is currently loading. */
3636+ loading: boolean;
3737+ /** Fetch error, if any. */
3838+ error?: Error;
3939+ /** Preferred color scheme for downstream components. */
4040+ colorScheme?: "light" | "dark" | "system";
4141+ /** DID associated with the record. */
4242+ did: string;
4343+ /** Record key for the status. */
4444+ rkey: string;
4545+ /** Auto-refresh music data and album art every 15 seconds. */
4646+ autoRefresh?: boolean;
4747+ /** Label to display. */
4848+ label?: string;
4949+ /** Refresh interval in milliseconds. */
5050+ refreshInterval?: number;
5151+ /** Handle to display in not listening state */
5252+ handle?: string;
5353+};
5454+5555+/** NSID for teal.fm actor status records. */
5656+export const CURRENTLY_PLAYING_COLLECTION = "fm.teal.alpha.actor.status";
5757+5858+/**
5959+ * Displays the currently playing track from teal.fm with auto-refresh.
6060+ *
6161+ * @param did - DID whose currently playing status should be fetched.
6262+ * @param rkey - Record key within the teal.fm status collection (defaults to "self").
6363+ * @param renderer - Optional component override that will receive injected props.
6464+ * @param fallback - Node rendered before the first load begins.
6565+ * @param loadingIndicator - Node rendered while the status is loading.
6666+ * @param colorScheme - Preferred color scheme for theming the renderer.
6767+ * @param autoRefresh - When true (default), refreshes album art and streaming platform links every 15 seconds.
6868+ * @returns A JSX subtree representing the currently playing track with loading states handled.
6969+ */
7070+export const CurrentlyPlaying: React.FC<CurrentlyPlayingProps> = React.memo(({
7171+ did,
7272+ rkey = "self",
7373+ record,
7474+ renderer,
7575+ fallback,
7676+ loadingIndicator,
7777+ colorScheme,
7878+ autoRefresh = true,
7979+}) => {
8080+ // Resolve handle from DID
8181+ const { handle } = useDidResolution(did);
8282+8383+ const Comp: React.ComponentType<CurrentlyPlayingRendererInjectedProps> =
8484+ renderer ?? ((props) => <CurrentlyPlayingRenderer {...props} />);
8585+ const Wrapped: React.FC<{
8686+ record: TealActorStatusRecord;
8787+ loading: boolean;
8888+ error?: Error;
8989+ }> = (props) => (
9090+ <Comp
9191+ {...props}
9292+ colorScheme={colorScheme}
9393+ did={did}
9494+ rkey={rkey}
9595+ autoRefresh={autoRefresh}
9696+ label="CURRENTLY PLAYING"
9797+ refreshInterval={15000}
9898+ handle={handle}
9999+ />
100100+ );
101101+102102+ if (record !== undefined) {
103103+ return (
104104+ <AtProtoRecord<TealActorStatusRecord>
105105+ record={record}
106106+ renderer={Wrapped}
107107+ fallback={fallback}
108108+ loadingIndicator={loadingIndicator}
109109+ />
110110+ );
111111+ }
112112+113113+ return (
114114+ <AtProtoRecord<TealActorStatusRecord>
115115+ did={did}
116116+ collection={CURRENTLY_PLAYING_COLLECTION}
117117+ rkey={rkey}
118118+ renderer={Wrapped}
119119+ fallback={fallback}
120120+ loadingIndicator={loadingIndicator}
121121+ />
122122+ );
123123+});
124124+125125+export default CurrentlyPlaying;
+156
lib/components/LastPlayed.tsx
···11+import React, { useMemo } from "react";
22+import { useLatestRecord } from "../hooks/useLatestRecord";
33+import { useDidResolution } from "../hooks/useDidResolution";
44+import { CurrentlyPlayingRenderer } from "../renderers/CurrentlyPlayingRenderer";
55+import type { TealFeedPlayRecord } from "../types/teal";
66+77+/**
88+ * Props for rendering the last played track from teal.fm feed.
99+ */
1010+export interface LastPlayedProps {
1111+ /** DID of the user whose last played track to display. */
1212+ did: string;
1313+ /** Optional renderer override for custom presentation. */
1414+ renderer?: React.ComponentType<LastPlayedRendererInjectedProps>;
1515+ /** Fallback node displayed before loading begins. */
1616+ fallback?: React.ReactNode;
1717+ /** Indicator node shown while data is loading. */
1818+ loadingIndicator?: React.ReactNode;
1919+ /** Preferred color scheme for theming. */
2020+ colorScheme?: "light" | "dark" | "system";
2121+ /** Auto-refresh music data and album art. Defaults to false for last played. */
2222+ autoRefresh?: boolean;
2323+ /** Refresh interval in milliseconds. Defaults to 60000 (60 seconds). */
2424+ refreshInterval?: number;
2525+}
2626+2727+/**
2828+ * Values injected into custom last played renderer implementations.
2929+ */
3030+export type LastPlayedRendererInjectedProps = {
3131+ /** Loaded teal.fm feed play record value. */
3232+ record: TealFeedPlayRecord;
3333+ /** Indicates whether the record is currently loading. */
3434+ loading: boolean;
3535+ /** Fetch error, if any. */
3636+ error?: Error;
3737+ /** Preferred color scheme for downstream components. */
3838+ colorScheme?: "light" | "dark" | "system";
3939+ /** DID associated with the record. */
4040+ did: string;
4141+ /** Record key for the play record. */
4242+ rkey: string;
4343+ /** Auto-refresh music data and album art. */
4444+ autoRefresh?: boolean;
4545+ /** Refresh interval in milliseconds. */
4646+ refreshInterval?: number;
4747+ /** Handle to display in not listening state */
4848+ handle?: string;
4949+};
5050+5151+/** NSID for teal.fm feed play records. */
5252+export const LAST_PLAYED_COLLECTION = "fm.teal.alpha.feed.play";
5353+5454+/**
5555+ * Displays the last played track from teal.fm feed.
5656+ *
5757+ * @param did - DID whose last played track should be fetched.
5858+ * @param renderer - Optional component override that will receive injected props.
5959+ * @param fallback - Node rendered before the first load begins.
6060+ * @param loadingIndicator - Node rendered while the data is loading.
6161+ * @param colorScheme - Preferred color scheme for theming the renderer.
6262+ * @param autoRefresh - When true, refreshes album art and streaming platform links at the specified interval. Defaults to false.
6363+ * @param refreshInterval - Refresh interval in milliseconds. Defaults to 60000 (60 seconds).
6464+ * @returns A JSX subtree representing the last played track with loading states handled.
6565+ */
6666+export const LastPlayed: React.FC<LastPlayedProps> = React.memo(({
6767+ did,
6868+ renderer,
6969+ fallback,
7070+ loadingIndicator,
7171+ colorScheme,
7272+ autoRefresh = false,
7373+ refreshInterval = 60000,
7474+}) => {
7575+ // Resolve handle from DID
7676+ const { handle } = useDidResolution(did);
7777+7878+ const { record, rkey, loading, error, empty } = useLatestRecord<TealFeedPlayRecord>(
7979+ did,
8080+ LAST_PLAYED_COLLECTION
8181+ );
8282+8383+ // Normalize TealFeedPlayRecord to match TealActorStatusRecord structure
8484+ // Use useMemo to prevent creating new object on every render
8585+ // MUST be called before any conditional returns (Rules of Hooks)
8686+ const normalizedRecord = useMemo(() => {
8787+ if (!record) return null;
8888+8989+ return {
9090+ $type: "fm.teal.alpha.actor.status" as const,
9191+ item: {
9292+ artists: record.artists,
9393+ originUrl: record.originUrl,
9494+ trackName: record.trackName,
9595+ playedTime: record.playedTime,
9696+ releaseName: record.releaseName,
9797+ recordingMbId: record.recordingMbId,
9898+ releaseMbId: record.releaseMbId,
9999+ submissionClientAgent: record.submissionClientAgent,
100100+ musicServiceBaseDomain: record.musicServiceBaseDomain,
101101+ isrc: record.isrc,
102102+ duration: record.duration,
103103+ },
104104+ time: new Date(record.playedTime).getTime().toString(),
105105+ expiry: undefined,
106106+ };
107107+ }, [record]);
108108+109109+ const Comp = renderer ?? CurrentlyPlayingRenderer;
110110+111111+ // Now handle conditional returns after all hooks
112112+ if (error) {
113113+ return (
114114+ <div style={{ padding: 8, color: "var(--atproto-color-error)" }}>
115115+ Failed to load last played track.
116116+ </div>
117117+ );
118118+ }
119119+120120+ if (loading && !record) {
121121+ return loadingIndicator ? (
122122+ <>{loadingIndicator}</>
123123+ ) : (
124124+ <div style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}>
125125+ Loading…
126126+ </div>
127127+ );
128128+ }
129129+130130+ if (empty || !record || !normalizedRecord) {
131131+ return fallback ? (
132132+ <>{fallback}</>
133133+ ) : (
134134+ <div style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}>
135135+ No plays found.
136136+ </div>
137137+ );
138138+ }
139139+140140+ return (
141141+ <Comp
142142+ record={normalizedRecord}
143143+ loading={loading}
144144+ error={error}
145145+ colorScheme={colorScheme}
146146+ did={did}
147147+ rkey={rkey || "unknown"}
148148+ autoRefresh={autoRefresh}
149149+ label="LAST PLAYED"
150150+ refreshInterval={refreshInterval}
151151+ handle={handle}
152152+ />
153153+ );
154154+});
155155+156156+export default LastPlayed;
+30-20
lib/hooks/useAtProtoRecord.ts
···142142 const controller = new AbortController();
143143144144 const fetchPromise = (async () => {
145145- const { rpc } = await createAtprotoClient({
146146- service: endpoint,
147147- });
148148- const res = await (
149149- rpc as unknown as {
150150- get: (
151151- nsid: string,
152152- opts: {
153153- params: {
154154- repo: string;
155155- collection: string;
156156- rkey: string;
157157- };
158158- },
159159- ) => Promise<{ ok: boolean; data: { value: T } }>;
145145+ try {
146146+ const { rpc } = await createAtprotoClient({
147147+ service: endpoint,
148148+ });
149149+ const res = await (
150150+ rpc as unknown as {
151151+ get: (
152152+ nsid: string,
153153+ opts: {
154154+ params: {
155155+ repo: string;
156156+ collection: string;
157157+ rkey: string;
158158+ };
159159+ },
160160+ ) => Promise<{ ok: boolean; data: { value: T } }>;
161161+ }
162162+ ).get("com.atproto.repo.getRecord", {
163163+ params: { repo: did, collection, rkey },
164164+ });
165165+ if (!res.ok) throw new Error("Failed to load record");
166166+ return (res.data as { value: T }).value;
167167+ } catch (err) {
168168+ // Provide helpful error for banned/unreachable Bluesky PDSes
169169+ if (endpoint.includes('.bsky.network')) {
170170+ throw new Error(
171171+ `Record unavailable. The Bluesky PDS (${endpoint}) may be unreachable or the account may be banned.`
172172+ );
160173 }
161161- ).get("com.atproto.repo.getRecord", {
162162- params: { repo: did, collection, rkey },
163163- });
164164- if (!res.ok) throw new Error("Failed to load record");
165165- return (res.data as { value: T }).value;
174174+ throw err;
175175+ }
166176 })();
167177168178 return {
+14-8
lib/hooks/useBlueskyAppview.ts
···308308 dispatch({ type: "SET_LOADING", loading: true });
309309310310 // Use recordCache.ensure for deduplication and caching
311311- const { promise, release } = recordCache.ensure<T>(
311311+ const { promise, release } = recordCache.ensure<{ record: T; source: "appview" | "slingshot" | "pds" }>(
312312 did,
313313 collection,
314314 rkey,
315315 () => {
316316 const controller = new AbortController();
317317318318- const fetchPromise = (async () => {
318318+ const fetchPromise = (async (): Promise<{ record: T; source: "appview" | "slingshot" | "pds" }> => {
319319 let lastError: Error | undefined;
320320321321 // Tier 1: Try Bluesky appview API
···328328 effectiveAppviewService,
329329 );
330330 if (result) {
331331- return result;
331331+ return { record: result, source: "appview" };
332332 }
333333 } catch (err) {
334334 lastError = err as Error;
···341341 const slingshotUrl = resolver.getSlingshotUrl();
342342 const result = await fetchFromSlingshot<T>(did, collection, rkey, slingshotUrl);
343343 if (result) {
344344- return result;
344344+ return { record: result, source: "slingshot" };
345345 }
346346 } catch (err) {
347347 lastError = err as Error;
···357357 pdsEndpoint,
358358 );
359359 if (result) {
360360- return result;
360360+ return { record: result, source: "pds" };
361361 }
362362 } catch (err) {
363363 lastError = err as Error;
364364 }
365365366366- // All tiers failed
366366+ // All tiers failed - provide helpful error for banned/unreachable Bluesky PDSes
367367+ if (pdsEndpoint.includes('.bsky.network')) {
368368+ throw new Error(
369369+ `Record unavailable. The Bluesky PDS (${pdsEndpoint}) may be unreachable or the account may be banned.`
370370+ );
371371+ }
372372+367373 throw lastError ?? new Error("Failed to fetch record from all sources");
368374 })();
369375···377383 releaseRef.current = release;
378384379385 promise
380380- .then((record) => {
386386+ .then(({ record, source }) => {
381387 if (!cancelled) {
382388 dispatch({
383389 type: "SET_SUCCESS",
384390 record,
385385- source: "appview",
391391+ source,
386392 });
387393 }
388394 })
+4
lib/index.ts
···1616export * from "./components/LeafletDocument";
1717export * from "./components/TangledRepo";
1818export * from "./components/TangledString";
1919+export * from "./components/CurrentlyPlaying";
2020+export * from "./components/LastPlayed";
19212022// Hooks
2123export * from "./hooks/useAtProtoRecord";
···3638export * from "./renderers/LeafletDocumentRenderer";
3739export * from "./renderers/TangledRepoRenderer";
3840export * from "./renderers/TangledStringRenderer";
4141+export * from "./renderers/CurrentlyPlayingRenderer";
39424043// Types
4144export * from "./types/bluesky";
4245export * from "./types/grain";
4346export * from "./types/leaflet";
4447export * from "./types/tangled";
4848+export * from "./types/teal";
4549export * from "./types/theme";
46504751// Utilities