A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.

change dark mode to be a slated dark blue theme from the harsh dark blue that twitter and bluesky use, add teal.fm components

+701
CLAUDE.md
···
···
+
# AtReact Hooks Deep Dive
+
+
## Overview
+
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.
+
+
---
+
+
## Core Architecture Principles
+
+
### 1. **Three-Tier Caching Strategy**
+
All data flows through three cache layers:
+
- **DidCache** - DID documents, handle mappings, PDS endpoints
+
- **BlobCache** - Media/image blobs with reference counting
+
- **RecordCache** - AT Protocol records with deduplication
+
+
### 2. **Concurrent Request Deduplication**
+
When multiple components request the same data, only one network request is made. Uses reference counting to manage in-flight requests.
+
+
### 3. **Stable Reference Pattern**
+
Caches use memoized snapshots to prevent unnecessary re-renders:
+
```typescript
+
// Only creates new snapshot if data actually changed
+
if (existing && existing.did === did && existing.handle === handle) {
+
return toSnapshot(existing); // Reuse existing
+
}
+
```
+
+
### 4. **Three-Tier Fallback for Bluesky**
+
For `app.bsky.*` collections:
+
1. Try Bluesky appview API (fastest, public)
+
2. Fall back to Slingshot (microcosm service)
+
3. Finally query PDS directly
+
+
---
+
+
## Hook Catalog
+
+
## 1. `useDidResolution`
+
**Purpose:** Resolves handles to DIDs or fetches DID documents
+
+
### Key Features:
+
- **Bidirectional:** Works with handles OR DIDs
+
- **Smart Caching:** Only fetches if not in cache
+
- **Dual Resolution Paths:**
+
- Handle → DID: Uses Slingshot first, then appview
+
- DID → Document: Fetches full DID document for handle extraction
+
+
### State Flow:
+
```typescript
+
Input: "alice.bsky.social" or "did:plc:xxx"
+
+
Check didCache
+
+
If handle: ensureHandle(resolver, handle) → DID
+
If DID: ensureDidDoc(resolver, did) → DID doc + handle from alsoKnownAs
+
+
Return: { did, handle, loading, error }
+
```
+
+
### Critical Implementation Details:
+
- **Normalizes input** to lowercase for handles
+
- **Memoizes input** to prevent effect re-runs
+
- **Stabilizes error references** - only updates if message changes
+
- **Cleanup:** Cancellation token prevents stale updates
+
+
---
+
+
## 2. `usePdsEndpoint`
+
**Purpose:** Discovers the PDS endpoint for a DID
+
+
### Key Features:
+
- **Depends on DID resolution** (implicit dependency)
+
- **Extracts from DID document** if already cached
+
- **Lazy fetching** - only when endpoint not in cache
+
+
### State Flow:
+
```typescript
+
Input: DID
+
+
Check didCache.getByDid(did).pdsEndpoint
+
+
If missing: ensurePdsEndpoint(resolver, did)
+
├─ Tries to get from existing DID doc
+
└─ Falls back to resolver.pdsEndpointForDid()
+
+
Return: { endpoint, loading, error }
+
```
+
+
### Service Discovery:
+
Looks for `AtprotoPersonalDataServer` service in DID document:
+
```json
+
{
+
"service": [{
+
"type": "AtprotoPersonalDataServer",
+
"serviceEndpoint": "https://pds.example.com"
+
}]
+
}
+
```
+
+
---
+
+
## 3. `useAtProtoRecord`
+
**Purpose:** Fetches a single AT Protocol record with smart routing
+
+
### Key Features:
+
- **Collection-aware routing:** Bluesky vs other protocols
+
- **RecordCache deduplication:** Multiple components = one fetch
+
- **Cleanup with reference counting**
+
+
### State Flow:
+
```typescript
+
Input: { did, collection, rkey }
+
+
If collection.startsWith("app.bsky."):
+
└─ useBlueskyAppview() → Three-tier fallback
+
Else:
+
├─ useDidResolution(did)
+
├─ usePdsEndpoint(resolved.did)
+
└─ recordCache.ensure() → Fetch from PDS
+
+
Return: { record, loading, error }
+
```
+
+
### RecordCache Deduplication:
+
```typescript
+
// First component calling this
+
const { promise, release } = recordCache.ensure(did, collection, rkey, loader)
+
// refCount = 1
+
+
// Second component calling same record
+
const { promise, release } = recordCache.ensure(...) // Same promise!
+
// refCount = 2
+
+
// On cleanup, both call release()
+
// Only aborts when refCount reaches 0
+
```
+
+
---
+
+
## 4. `useBlueskyAppview`
+
**Purpose:** Fetches Bluesky records with appview optimization
+
+
### Key Features:
+
- **Collection-aware endpoints:**
+
- `app.bsky.actor.profile` → `app.bsky.actor.getProfile`
+
- `app.bsky.feed.post` → `app.bsky.feed.getPostThread`
+
- **CDN URL extraction:** Parses CDN URLs to extract CIDs
+
- **Atomic state updates:** Uses reducer for complex state
+
+
### Three-Tier Fallback with Source Tracking:
+
```typescript
+
async function fetchWithFallback() {
+
// Tier 1: Appview (if endpoint mapped)
+
try {
+
const result = await fetchFromAppview(did, collection, rkey);
+
return { record: result, source: "appview" };
+
} catch {}
+
+
// Tier 2: Slingshot
+
try {
+
const result = await fetchFromSlingshot(did, collection, rkey);
+
return { record: result, source: "slingshot" };
+
} catch {}
+
+
// Tier 3: PDS
+
try {
+
const result = await fetchFromPds(did, collection, rkey);
+
return { record: result, source: "pds" };
+
} catch {}
+
+
// All tiers failed - provide helpful error for banned Bluesky accounts
+
if (pdsEndpoint.includes('.bsky.network')) {
+
throw new Error('Record unavailable. The Bluesky PDS may be unreachable or the account may be banned.');
+
}
+
+
throw new Error('Failed to fetch record from all sources');
+
}
+
```
+
+
The `source` field in the result accurately indicates which tier successfully fetched the data, enabling debugging and analytics.
+
+
### CDN URL Handling:
+
Appview returns CDN URLs like:
+
```
+
https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg
+
```
+
+
Hook extracts CID (`bafkreixxx`) and creates standard Blob object:
+
```typescript
+
{
+
$type: "blob",
+
ref: { $link: "bafkreixxx" },
+
mimeType: "image/jpeg",
+
size: 0,
+
cdnUrl: "https://cdn.bsky.app/..." // Preserved for fast rendering
+
}
+
```
+
+
### Reducer Pattern:
+
```typescript
+
type Action =
+
| { type: "SET_LOADING"; loading: boolean }
+
| { type: "SET_SUCCESS"; record: T; source: "appview" | "slingshot" | "pds" }
+
| { type: "SET_ERROR"; error: Error }
+
| { type: "RESET" };
+
+
// Atomic state updates, no race conditions
+
dispatch({ type: "SET_SUCCESS", record, source });
+
```
+
+
---
+
+
## 5. `useLatestRecord`
+
**Purpose:** Fetches the most recent record from a collection
+
+
### Key Features:
+
- **Timestamp validation:** Skips records before 2023 (pre-ATProto)
+
- **PDS-only:** Slingshot doesn't support `listRecords`
+
- **Smart fetching:** Gets 3 records to handle invalid timestamps
+
+
### State Flow:
+
```typescript
+
Input: { did, collection }
+
+
useDidResolution(did)
+
usePdsEndpoint(did)
+
+
callListRecords(endpoint, did, collection, limit: 3)
+
+
Filter: isValidTimestamp(record) → year >= 2023
+
+
Return first valid record: { record, rkey, loading, error, empty }
+
```
+
+
### Timestamp Validation:
+
```typescript
+
function isValidTimestamp(record: unknown): boolean {
+
const timestamp = record.createdAt || record.indexedAt;
+
if (!timestamp) return true; // No timestamp, assume valid
+
+
const date = new Date(timestamp);
+
return date.getFullYear() >= 2023; // ATProto created in 2023
+
}
+
```
+
+
---
+
+
## 6. `usePaginatedRecords`
+
**Purpose:** Cursor-based pagination with prefetching
+
+
### Key Features:
+
- **Dual fetching modes:**
+
- Author feed (appview) - for Bluesky posts with filters
+
- Direct PDS - for all other collections
+
- **Smart prefetching:** Loads next page in background
+
- **Invalid timestamp filtering:** Same as `useLatestRecord`
+
- **Request sequencing:** Prevents race conditions with `requestSeq`
+
+
### State Management:
+
```typescript
+
// Pages stored as array
+
pages: [
+
{ records: [...], cursor: "abc" }, // page 0
+
{ records: [...], cursor: "def" }, // page 1
+
{ records: [...], cursor: undefined } // page 2 (last)
+
]
+
pageIndex: 1 // Currently viewing page 1
+
```
+
+
### Prefetch Logic:
+
```typescript
+
useEffect(() => {
+
const cursor = pages[pageIndex]?.cursor;
+
if (!cursor || pages[pageIndex + 1]) return; // No cursor or already loaded
+
+
// Prefetch next page in background
+
fetchPage(identity, cursor, pageIndex + 1, "prefetch");
+
}, [pageIndex, pages]);
+
```
+
+
### Author Feed vs PDS:
+
```typescript
+
if (preferAuthorFeed && collection === "app.bsky.feed.post") {
+
// Use app.bsky.feed.getAuthorFeed
+
const res = await callAppviewRpc("app.bsky.feed.getAuthorFeed", {
+
actor: handle || did,
+
filter: "posts_with_media", // Optional filter
+
includePins: true
+
});
+
} else {
+
// Use com.atproto.repo.listRecords
+
const res = await callListRecords(pdsEndpoint, did, collection, limit);
+
}
+
```
+
+
### Race Condition Prevention:
+
```typescript
+
const requestSeq = useRef(0);
+
+
// On identity change
+
resetState();
+
requestSeq.current += 1; // Invalidate in-flight requests
+
+
// In fetch callback
+
const token = requestSeq.current;
+
// ... do async work ...
+
if (token !== requestSeq.current) return; // Stale request, abort
+
```
+
+
---
+
+
## 7. `useBlob`
+
**Purpose:** Fetches and caches media blobs with object URL management
+
+
### Key Features:
+
- **Automatic cleanup:** Revokes object URLs on unmount
+
- **BlobCache deduplication:** Same blob = one fetch
+
- **Reference counting:** Safe concurrent access
+
+
### State Flow:
+
```typescript
+
Input: { did, cid }
+
+
useDidResolution(did)
+
usePdsEndpoint(did)
+
+
Check blobCache.get(did, cid)
+
+
If missing: blobCache.ensure() → Fetch from PDS
+
├─ GET /xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}
+
└─ Store in cache
+
+
Create object URL: URL.createObjectURL(blob)
+
+
Return: { url, loading, error }
+
+
Cleanup: URL.revokeObjectURL(url)
+
```
+
+
### Object URL Management:
+
```typescript
+
const objectUrlRef = useRef<string>();
+
+
// On successful fetch
+
const nextUrl = URL.createObjectURL(blob);
+
const prevUrl = objectUrlRef.current;
+
objectUrlRef.current = nextUrl;
+
if (prevUrl) URL.revokeObjectURL(prevUrl); // Clean up old URL
+
+
// On unmount
+
useEffect(() => () => {
+
if (objectUrlRef.current) {
+
URL.revokeObjectURL(objectUrlRef.current);
+
}
+
}, []);
+
```
+
+
---
+
+
## 8. `useBlueskyProfile`
+
**Purpose:** Wrapper around `useBlueskyAppview` for profile records
+
+
### Key Features:
+
- **Simplified interface:** Just pass DID
+
- **Type conversion:** Converts ProfileRecord to BlueskyProfileData
+
- **CID extraction:** Extracts avatar/banner CIDs from blobs
+
+
### Implementation:
+
```typescript
+
export function useBlueskyProfile(did: string | undefined) {
+
const { record, loading, error } = useBlueskyAppview<ProfileRecord>({
+
did,
+
collection: "app.bsky.actor.profile",
+
rkey: "self",
+
});
+
+
const data = record ? {
+
did: did || "",
+
handle: "", // Populated by caller
+
displayName: record.displayName,
+
description: record.description,
+
avatar: extractCidFromBlob(record.avatar),
+
banner: extractCidFromBlob(record.banner),
+
createdAt: record.createdAt,
+
} : undefined;
+
+
return { data, loading, error };
+
}
+
```
+
+
---
+
+
## 9. `useBacklinks`
+
**Purpose:** Fetches backlinks from Microcosm Constellation API
+
+
### Key Features:
+
- **Specialized use case:** Tangled stars, etc.
+
- **Abort controller:** Cancels in-flight requests
+
- **Refetch support:** Manual refresh capability
+
+
### State Flow:
+
```typescript
+
Input: { subject: "at://did:plc:xxx/sh.tangled.repo/yyy", source: "sh.tangled.feed.star:subject" }
+
+
GET https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks
+
?subject={subject}&source={source}&limit={limit}
+
+
Return: { backlinks: [...], total, loading, error, refetch }
+
```
+
+
---
+
+
## 10. `useRepoLanguages`
+
**Purpose:** Fetches language statistics from Tangled knot server
+
+
### Key Features:
+
- **Branch fallback:** Tries "main", then "master"
+
- **Knot server query:** For repository analysis
+
+
### State Flow:
+
```typescript
+
Input: { knot: "knot.gaze.systems", did, repoName, branch }
+
+
GET https://{knot}/xrpc/sh.tangled.repo.languages
+
?repo={did}/{repoName}&ref={branch}
+
+
If 404: Try fallback branch
+
+
Return: { data: { languages: {...} }, loading, error }
+
```
+
+
---
+
+
## Cache Implementation Deep Dive
+
+
### DidCache
+
**Purpose:** Cache DID documents, handle mappings, PDS endpoints
+
+
```typescript
+
class DidCache {
+
private byHandle = new Map<string, DidCacheEntry>();
+
private byDid = new Map<string, DidCacheEntry>();
+
private handlePromises = new Map<string, Promise<...>>();
+
private docPromises = new Map<string, Promise<...>>();
+
private pdsPromises = new Map<string, Promise<...>>();
+
+
// Memoized snapshots prevent re-renders
+
private toSnapshot(entry): DidCacheSnapshot {
+
if (entry.snapshot) return entry.snapshot; // Reuse
+
entry.snapshot = { did, handle, doc, pdsEndpoint };
+
return entry.snapshot;
+
}
+
}
+
```
+
+
**Key methods:**
+
- `getByHandle(handle)` - Instant cache lookup
+
- `getByDid(did)` - Instant cache lookup
+
- `ensureHandle(resolver, handle)` - Deduplicated resolution
+
- `ensureDidDoc(resolver, did)` - Deduplicated doc fetch
+
- `ensurePdsEndpoint(resolver, did)` - Deduplicated PDS discovery
+
+
**Snapshot stability:**
+
```typescript
+
memoize(entry) {
+
const existing = this.byDid.get(did);
+
+
// Data unchanged? Reuse snapshot (same reference)
+
if (existing && existing.did === did &&
+
existing.handle === handle && ...) {
+
return toSnapshot(existing); // Prevents re-render!
+
}
+
+
// Data changed, create new entry
+
const merged = { did, handle, doc, pdsEndpoint, snapshot: undefined };
+
this.byDid.set(did, merged);
+
return toSnapshot(merged);
+
}
+
```
+
+
### BlobCache
+
**Purpose:** Cache media blobs with reference counting
+
+
```typescript
+
class BlobCache {
+
private store = new Map<string, BlobCacheEntry>();
+
private inFlight = new Map<string, InFlightBlobEntry>();
+
+
ensure(did, cid, loader) {
+
// Already cached?
+
const cached = this.get(did, cid);
+
if (cached) return { promise: Promise.resolve(cached), release: noop };
+
+
// In-flight request?
+
const existing = this.inFlight.get(key);
+
if (existing) {
+
existing.refCount++; // Multiple consumers
+
return { promise: existing.promise, release: () => this.release(key) };
+
}
+
+
// New request
+
const { promise, abort } = loader();
+
this.inFlight.set(key, { promise, abort, refCount: 1 });
+
return { promise, release: () => this.release(key) };
+
}
+
+
private release(key) {
+
const entry = this.inFlight.get(key);
+
entry.refCount--;
+
if (entry.refCount <= 0) {
+
this.inFlight.delete(key);
+
entry.abort(); // Cancel fetch
+
}
+
}
+
}
+
```
+
+
### RecordCache
+
**Purpose:** Cache AT Protocol records with deduplication
+
+
Identical structure to BlobCache but for record data.
+
+
---
+
+
## Common Patterns
+
+
### 1. Cancellation Pattern
+
```typescript
+
useEffect(() => {
+
let cancelled = false;
+
+
const assignState = (next) => {
+
if (cancelled) return; // Don't update unmounted component
+
setState(prev => ({ ...prev, ...next }));
+
};
+
+
// ... async work ...
+
+
return () => {
+
cancelled = true; // Mark as cancelled
+
release?.(); // Decrement refCount
+
};
+
}, [deps]);
+
```
+
+
### 2. Error Stabilization Pattern
+
```typescript
+
setError(prevError =>
+
prevError?.message === newError.message
+
? prevError // Reuse same reference
+
: newError // New error
+
);
+
```
+
+
### 3. Identity Tracking Pattern
+
```typescript
+
const identityRef = useRef<string>();
+
const identity = did && endpoint ? `${did}::${endpoint}` : undefined;
+
+
useEffect(() => {
+
if (identityRef.current !== identity) {
+
identityRef.current = identity;
+
resetState(); // Clear stale data
+
}
+
// ...
+
}, [identity]);
+
```
+
+
### 4. Dual-Mode Resolution
+
```typescript
+
const isDid = input.startsWith("did:");
+
const normalizedHandle = !isDid ? input.toLowerCase() : undefined;
+
+
// Different code paths
+
if (isDid) {
+
snapshot = await didCache.ensureDidDoc(resolver, input);
+
} else {
+
snapshot = await didCache.ensureHandle(resolver, normalizedHandle);
+
}
+
```
+
+
---
+
+
## Performance Optimizations
+
+
### 1. **Memoized Snapshots**
+
Caches return stable references when data unchanged → prevents re-renders
+
+
### 2. **Reference Counting**
+
Multiple components requesting same data share one fetch
+
+
### 3. **Prefetching**
+
`usePaginatedRecords` loads next page in background
+
+
### 4. **CDN URLs**
+
Bluesky appview returns CDN URLs → skip blob fetching for images
+
+
### 5. **Smart Routing**
+
Bluesky collections use fast appview → non-Bluesky goes direct to PDS
+
+
### 6. **Request Deduplication**
+
In-flight request maps prevent duplicate fetches
+
+
### 7. **Timestamp Validation**
+
Skip invalid records early (before 2023) → fewer wasted cycles
+
+
---
+
+
## Error Handling Strategy
+
+
### 1. **Fallback Chains**
+
Never fail on first attempt → try multiple sources
+
+
### 2. **Graceful Degradation**
+
```typescript
+
// Slingshot failed? Try appview
+
try {
+
return await fetchFromSlingshot();
+
} catch (slingshotError) {
+
try {
+
return await fetchFromAppview();
+
} catch (appviewError) {
+
// Combine errors for better debugging
+
throw new Error(`${appviewError.message}; Slingshot: ${slingshotError.message}`);
+
}
+
}
+
```
+
+
### 3. **Component Isolation**
+
Errors in one component don't crash others (via error boundaries recommended)
+
+
### 4. **Abort Handling**
+
```typescript
+
try {
+
await fetch(url, { signal });
+
} catch (err) {
+
if (err.name === "AbortError") return; // Expected, ignore
+
throw err;
+
}
+
```
+
+
### 5. **Banned Bluesky Account Detection**
+
When all three tiers fail and the PDS is a `.bsky.network` endpoint, provide a helpful error:
+
```typescript
+
// All tiers failed - check if it's a banned Bluesky account
+
if (pdsEndpoint.includes('.bsky.network')) {
+
throw new Error(
+
'Record unavailable. The Bluesky PDS may be unreachable or the account may be banned.'
+
);
+
}
+
```
+
+
This helps users understand why data is unavailable instead of showing generic fetch errors. Applies to both `useBlueskyAppview` and `useAtProtoRecord` hooks.
+
+
---
+
+
## Testing Considerations
+
+
### Key scenarios to test:
+
1. **Concurrent requests:** Multiple components requesting same data
+
2. **Race conditions:** Component unmounting mid-fetch
+
3. **Cache invalidation:** Identity changes during fetch
+
4. **Error fallbacks:** Slingshot down → appview works
+
5. **Timestamp filtering:** Records before 2023 skipped
+
6. **Reference counting:** Proper cleanup on unmount
+
7. **Prefetching:** Background loads don't interfere with active loads
+
+
---
+
+
## Common Gotchas
+
+
### 1. **React Rules of Hooks**
+
All hooks called unconditionally, even if results not used:
+
```typescript
+
// Always call, conditionally use results
+
const blueskyResult = useBlueskyAppview({
+
did: isBlueskyCollection ? handleOrDid : undefined, // Pass undefined to skip
+
collection: isBlueskyCollection ? collection : undefined,
+
rkey: isBlueskyCollection ? rkey : undefined,
+
});
+
```
+
+
### 2. **Cleanup Order Matters**
+
```typescript
+
return () => {
+
cancelled = true; // 1. Prevent state updates
+
release?.(); // 2. Decrement refCount
+
revokeObjectURL(...); // 3. Free resources
+
};
+
```
+
+
### 3. **Snapshot Reuse**
+
Don't modify cached snapshots! They're shared across components.
+
+
### 4. **CDN URL Extraction**
+
Bluesky CDN URLs must be parsed carefully:
+
```
+
https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg
+
^^^^^^^^^^^^ ^^^^^^
+
DID CID
+
```
+125
lib/components/CurrentlyPlaying.tsx
···
···
+
import React from "react";
+
import { AtProtoRecord } from "../core/AtProtoRecord";
+
import { CurrentlyPlayingRenderer } from "../renderers/CurrentlyPlayingRenderer";
+
import { useDidResolution } from "../hooks/useDidResolution";
+
import type { TealActorStatusRecord } from "../types/teal";
+
+
/**
+
* Props for rendering teal.fm currently playing status.
+
*/
+
export interface CurrentlyPlayingProps {
+
/** DID of the user whose currently playing status to display. */
+
did: string;
+
/** Record key within the `fm.teal.alpha.actor.status` collection (usually "self"). */
+
rkey?: string;
+
/** Prefetched teal.fm status record. When provided, skips fetching from the network. */
+
record?: TealActorStatusRecord;
+
/** Optional renderer override for custom presentation. */
+
renderer?: React.ComponentType<CurrentlyPlayingRendererInjectedProps>;
+
/** Fallback node displayed before loading begins. */
+
fallback?: React.ReactNode;
+
/** Indicator node shown while data is loading. */
+
loadingIndicator?: React.ReactNode;
+
/** Preferred color scheme for theming. */
+
colorScheme?: "light" | "dark" | "system";
+
/** Auto-refresh music data and album art every 15 seconds. Defaults to true. */
+
autoRefresh?: boolean;
+
}
+
+
/**
+
* Values injected into custom currently playing renderer implementations.
+
*/
+
export type CurrentlyPlayingRendererInjectedProps = {
+
/** Loaded teal.fm status record value. */
+
record: TealActorStatusRecord;
+
/** Indicates whether the record is currently loading. */
+
loading: boolean;
+
/** Fetch error, if any. */
+
error?: Error;
+
/** Preferred color scheme for downstream components. */
+
colorScheme?: "light" | "dark" | "system";
+
/** DID associated with the record. */
+
did: string;
+
/** Record key for the status. */
+
rkey: string;
+
/** Auto-refresh music data and album art every 15 seconds. */
+
autoRefresh?: boolean;
+
/** Label to display. */
+
label?: string;
+
/** Refresh interval in milliseconds. */
+
refreshInterval?: number;
+
/** Handle to display in not listening state */
+
handle?: string;
+
};
+
+
/** NSID for teal.fm actor status records. */
+
export const CURRENTLY_PLAYING_COLLECTION = "fm.teal.alpha.actor.status";
+
+
/**
+
* Displays the currently playing track from teal.fm with auto-refresh.
+
*
+
* @param did - DID whose currently playing status should be fetched.
+
* @param rkey - Record key within the teal.fm status collection (defaults to "self").
+
* @param renderer - Optional component override that will receive injected props.
+
* @param fallback - Node rendered before the first load begins.
+
* @param loadingIndicator - Node rendered while the status is loading.
+
* @param colorScheme - Preferred color scheme for theming the renderer.
+
* @param autoRefresh - When true (default), refreshes album art and streaming platform links every 15 seconds.
+
* @returns A JSX subtree representing the currently playing track with loading states handled.
+
*/
+
export const CurrentlyPlaying: React.FC<CurrentlyPlayingProps> = React.memo(({
+
did,
+
rkey = "self",
+
record,
+
renderer,
+
fallback,
+
loadingIndicator,
+
colorScheme,
+
autoRefresh = true,
+
}) => {
+
// Resolve handle from DID
+
const { handle } = useDidResolution(did);
+
+
const Comp: React.ComponentType<CurrentlyPlayingRendererInjectedProps> =
+
renderer ?? ((props) => <CurrentlyPlayingRenderer {...props} />);
+
const Wrapped: React.FC<{
+
record: TealActorStatusRecord;
+
loading: boolean;
+
error?: Error;
+
}> = (props) => (
+
<Comp
+
{...props}
+
colorScheme={colorScheme}
+
did={did}
+
rkey={rkey}
+
autoRefresh={autoRefresh}
+
label="CURRENTLY PLAYING"
+
refreshInterval={15000}
+
handle={handle}
+
/>
+
);
+
+
if (record !== undefined) {
+
return (
+
<AtProtoRecord<TealActorStatusRecord>
+
record={record}
+
renderer={Wrapped}
+
fallback={fallback}
+
loadingIndicator={loadingIndicator}
+
/>
+
);
+
}
+
+
return (
+
<AtProtoRecord<TealActorStatusRecord>
+
did={did}
+
collection={CURRENTLY_PLAYING_COLLECTION}
+
rkey={rkey}
+
renderer={Wrapped}
+
fallback={fallback}
+
loadingIndicator={loadingIndicator}
+
/>
+
);
+
});
+
+
export default CurrentlyPlaying;
+156
lib/components/LastPlayed.tsx
···
···
+
import React, { useMemo } from "react";
+
import { useLatestRecord } from "../hooks/useLatestRecord";
+
import { useDidResolution } from "../hooks/useDidResolution";
+
import { CurrentlyPlayingRenderer } from "../renderers/CurrentlyPlayingRenderer";
+
import type { TealFeedPlayRecord } from "../types/teal";
+
+
/**
+
* Props for rendering the last played track from teal.fm feed.
+
*/
+
export interface LastPlayedProps {
+
/** DID of the user whose last played track to display. */
+
did: string;
+
/** Optional renderer override for custom presentation. */
+
renderer?: React.ComponentType<LastPlayedRendererInjectedProps>;
+
/** Fallback node displayed before loading begins. */
+
fallback?: React.ReactNode;
+
/** Indicator node shown while data is loading. */
+
loadingIndicator?: React.ReactNode;
+
/** Preferred color scheme for theming. */
+
colorScheme?: "light" | "dark" | "system";
+
/** Auto-refresh music data and album art. Defaults to false for last played. */
+
autoRefresh?: boolean;
+
/** Refresh interval in milliseconds. Defaults to 60000 (60 seconds). */
+
refreshInterval?: number;
+
}
+
+
/**
+
* Values injected into custom last played renderer implementations.
+
*/
+
export type LastPlayedRendererInjectedProps = {
+
/** Loaded teal.fm feed play record value. */
+
record: TealFeedPlayRecord;
+
/** Indicates whether the record is currently loading. */
+
loading: boolean;
+
/** Fetch error, if any. */
+
error?: Error;
+
/** Preferred color scheme for downstream components. */
+
colorScheme?: "light" | "dark" | "system";
+
/** DID associated with the record. */
+
did: string;
+
/** Record key for the play record. */
+
rkey: string;
+
/** Auto-refresh music data and album art. */
+
autoRefresh?: boolean;
+
/** Refresh interval in milliseconds. */
+
refreshInterval?: number;
+
/** Handle to display in not listening state */
+
handle?: string;
+
};
+
+
/** NSID for teal.fm feed play records. */
+
export const LAST_PLAYED_COLLECTION = "fm.teal.alpha.feed.play";
+
+
/**
+
* Displays the last played track from teal.fm feed.
+
*
+
* @param did - DID whose last played track should be fetched.
+
* @param renderer - Optional component override that will receive injected props.
+
* @param fallback - Node rendered before the first load begins.
+
* @param loadingIndicator - Node rendered while the data is loading.
+
* @param colorScheme - Preferred color scheme for theming the renderer.
+
* @param autoRefresh - When true, refreshes album art and streaming platform links at the specified interval. Defaults to false.
+
* @param refreshInterval - Refresh interval in milliseconds. Defaults to 60000 (60 seconds).
+
* @returns A JSX subtree representing the last played track with loading states handled.
+
*/
+
export const LastPlayed: React.FC<LastPlayedProps> = React.memo(({
+
did,
+
renderer,
+
fallback,
+
loadingIndicator,
+
colorScheme,
+
autoRefresh = false,
+
refreshInterval = 60000,
+
}) => {
+
// Resolve handle from DID
+
const { handle } = useDidResolution(did);
+
+
const { record, rkey, loading, error, empty } = useLatestRecord<TealFeedPlayRecord>(
+
did,
+
LAST_PLAYED_COLLECTION
+
);
+
+
// Normalize TealFeedPlayRecord to match TealActorStatusRecord structure
+
// Use useMemo to prevent creating new object on every render
+
// MUST be called before any conditional returns (Rules of Hooks)
+
const normalizedRecord = useMemo(() => {
+
if (!record) return null;
+
+
return {
+
$type: "fm.teal.alpha.actor.status" as const,
+
item: {
+
artists: record.artists,
+
originUrl: record.originUrl,
+
trackName: record.trackName,
+
playedTime: record.playedTime,
+
releaseName: record.releaseName,
+
recordingMbId: record.recordingMbId,
+
releaseMbId: record.releaseMbId,
+
submissionClientAgent: record.submissionClientAgent,
+
musicServiceBaseDomain: record.musicServiceBaseDomain,
+
isrc: record.isrc,
+
duration: record.duration,
+
},
+
time: new Date(record.playedTime).getTime().toString(),
+
expiry: undefined,
+
};
+
}, [record]);
+
+
const Comp = renderer ?? CurrentlyPlayingRenderer;
+
+
// Now handle conditional returns after all hooks
+
if (error) {
+
return (
+
<div style={{ padding: 8, color: "var(--atproto-color-error)" }}>
+
Failed to load last played track.
+
</div>
+
);
+
}
+
+
if (loading && !record) {
+
return loadingIndicator ? (
+
<>{loadingIndicator}</>
+
) : (
+
<div style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}>
+
Loading…
+
</div>
+
);
+
}
+
+
if (empty || !record || !normalizedRecord) {
+
return fallback ? (
+
<>{fallback}</>
+
) : (
+
<div style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}>
+
No plays found.
+
</div>
+
);
+
}
+
+
return (
+
<Comp
+
record={normalizedRecord}
+
loading={loading}
+
error={error}
+
colorScheme={colorScheme}
+
did={did}
+
rkey={rkey || "unknown"}
+
autoRefresh={autoRefresh}
+
label="LAST PLAYED"
+
refreshInterval={refreshInterval}
+
handle={handle}
+
/>
+
);
+
});
+
+
export default LastPlayed;
+30 -20
lib/hooks/useAtProtoRecord.ts
···
const controller = new AbortController();
const fetchPromise = (async () => {
-
const { rpc } = await createAtprotoClient({
-
service: endpoint,
-
});
-
const res = await (
-
rpc as unknown as {
-
get: (
-
nsid: string,
-
opts: {
-
params: {
-
repo: string;
-
collection: string;
-
rkey: string;
-
};
-
},
-
) => Promise<{ ok: boolean; data: { value: T } }>;
}
-
).get("com.atproto.repo.getRecord", {
-
params: { repo: did, collection, rkey },
-
});
-
if (!res.ok) throw new Error("Failed to load record");
-
return (res.data as { value: T }).value;
})();
return {
···
const controller = new AbortController();
const fetchPromise = (async () => {
+
try {
+
const { rpc } = await createAtprotoClient({
+
service: endpoint,
+
});
+
const res = await (
+
rpc as unknown as {
+
get: (
+
nsid: string,
+
opts: {
+
params: {
+
repo: string;
+
collection: string;
+
rkey: string;
+
};
+
},
+
) => Promise<{ ok: boolean; data: { value: T } }>;
+
}
+
).get("com.atproto.repo.getRecord", {
+
params: { repo: did, collection, rkey },
+
});
+
if (!res.ok) throw new Error("Failed to load record");
+
return (res.data as { value: T }).value;
+
} catch (err) {
+
// Provide helpful error for banned/unreachable Bluesky PDSes
+
if (endpoint.includes('.bsky.network')) {
+
throw new Error(
+
`Record unavailable. The Bluesky PDS (${endpoint}) may be unreachable or the account may be banned.`
+
);
}
+
throw err;
+
}
})();
return {
+14 -8
lib/hooks/useBlueskyAppview.ts
···
dispatch({ type: "SET_LOADING", loading: true });
// Use recordCache.ensure for deduplication and caching
-
const { promise, release } = recordCache.ensure<T>(
did,
collection,
rkey,
() => {
const controller = new AbortController();
-
const fetchPromise = (async () => {
let lastError: Error | undefined;
// Tier 1: Try Bluesky appview API
···
effectiveAppviewService,
);
if (result) {
-
return result;
}
} catch (err) {
lastError = err as Error;
···
const slingshotUrl = resolver.getSlingshotUrl();
const result = await fetchFromSlingshot<T>(did, collection, rkey, slingshotUrl);
if (result) {
-
return result;
}
} catch (err) {
lastError = err as Error;
···
pdsEndpoint,
);
if (result) {
-
return result;
}
} catch (err) {
lastError = err as Error;
}
-
// All tiers failed
throw lastError ?? new Error("Failed to fetch record from all sources");
})();
···
releaseRef.current = release;
promise
-
.then((record) => {
if (!cancelled) {
dispatch({
type: "SET_SUCCESS",
record,
-
source: "appview",
});
}
})
···
dispatch({ type: "SET_LOADING", loading: true });
// Use recordCache.ensure for deduplication and caching
+
const { promise, release } = recordCache.ensure<{ record: T; source: "appview" | "slingshot" | "pds" }>(
did,
collection,
rkey,
() => {
const controller = new AbortController();
+
const fetchPromise = (async (): Promise<{ record: T; source: "appview" | "slingshot" | "pds" }> => {
let lastError: Error | undefined;
// Tier 1: Try Bluesky appview API
···
effectiveAppviewService,
);
if (result) {
+
return { record: result, source: "appview" };
}
} catch (err) {
lastError = err as Error;
···
const slingshotUrl = resolver.getSlingshotUrl();
const result = await fetchFromSlingshot<T>(did, collection, rkey, slingshotUrl);
if (result) {
+
return { record: result, source: "slingshot" };
}
} catch (err) {
lastError = err as Error;
···
pdsEndpoint,
);
if (result) {
+
return { record: result, source: "pds" };
}
} catch (err) {
lastError = err as Error;
}
+
// All tiers failed - provide helpful error for banned/unreachable Bluesky PDSes
+
if (pdsEndpoint.includes('.bsky.network')) {
+
throw new Error(
+
`Record unavailable. The Bluesky PDS (${pdsEndpoint}) may be unreachable or the account may be banned.`
+
);
+
}
+
throw lastError ?? new Error("Failed to fetch record from all sources");
})();
···
releaseRef.current = release;
promise
+
.then(({ record, source }) => {
if (!cancelled) {
dispatch({
type: "SET_SUCCESS",
record,
+
source,
});
}
})
+4
lib/index.ts
···
export * from "./components/LeafletDocument";
export * from "./components/TangledRepo";
export * from "./components/TangledString";
// Hooks
export * from "./hooks/useAtProtoRecord";
···
export * from "./renderers/LeafletDocumentRenderer";
export * from "./renderers/TangledRepoRenderer";
export * from "./renderers/TangledStringRenderer";
// Types
export * from "./types/bluesky";
export * from "./types/grain";
export * from "./types/leaflet";
export * from "./types/tangled";
export * from "./types/theme";
// Utilities
···
export * from "./components/LeafletDocument";
export * from "./components/TangledRepo";
export * from "./components/TangledString";
+
export * from "./components/CurrentlyPlaying";
+
export * from "./components/LastPlayed";
// Hooks
export * from "./hooks/useAtProtoRecord";
···
export * from "./renderers/LeafletDocumentRenderer";
export * from "./renderers/TangledRepoRenderer";
export * from "./renderers/TangledStringRenderer";
+
export * from "./renderers/CurrentlyPlayingRenderer";
// Types
export * from "./types/bluesky";
export * from "./types/grain";
export * from "./types/leaflet";
export * from "./types/tangled";
+
export * from "./types/teal";
export * from "./types/theme";
// Utilities
+701
lib/renderers/CurrentlyPlayingRenderer.tsx
···
···
+
import React, { useState, useEffect } from "react";
+
import type { TealActorStatusRecord } from "../types/teal";
+
+
export interface CurrentlyPlayingRendererProps {
+
record: TealActorStatusRecord;
+
error?: Error;
+
loading: boolean;
+
did: string;
+
rkey: string;
+
colorScheme?: "light" | "dark" | "system";
+
autoRefresh?: boolean;
+
/** Label to display (e.g., "CURRENTLY PLAYING", "LAST PLAYED"). Defaults to "CURRENTLY PLAYING". */
+
label?: string;
+
/** Refresh interval in milliseconds. Defaults to 15000 (15 seconds). */
+
refreshInterval?: number;
+
/** Handle to display in not listening state */
+
handle?: string;
+
}
+
+
interface SonglinkPlatform {
+
url: string;
+
entityUniqueId: string;
+
nativeAppUriMobile?: string;
+
nativeAppUriDesktop?: string;
+
}
+
+
interface SonglinkResponse {
+
linksByPlatform: {
+
[platform: string]: SonglinkPlatform;
+
};
+
entitiesByUniqueId: {
+
[id: string]: {
+
thumbnailUrl?: string;
+
title?: string;
+
artistName?: string;
+
};
+
};
+
}
+
+
export const CurrentlyPlayingRenderer: React.FC<CurrentlyPlayingRendererProps> = ({
+
record,
+
error,
+
loading,
+
autoRefresh = true,
+
label = "CURRENTLY PLAYING",
+
refreshInterval = 15000,
+
handle,
+
}) => {
+
const [albumArt, setAlbumArt] = useState<string | undefined>(undefined);
+
const [artworkLoading, setArtworkLoading] = useState(true);
+
const [songlinkData, setSonglinkData] = useState<SonglinkResponse | undefined>(undefined);
+
const [showPlatformModal, setShowPlatformModal] = useState(false);
+
const [refreshKey, setRefreshKey] = useState(0);
+
+
// Auto-refresh interval
+
useEffect(() => {
+
if (!autoRefresh) return;
+
+
const interval = setInterval(() => {
+
// Reset loading state before refresh
+
setArtworkLoading(true);
+
setRefreshKey((prev) => prev + 1);
+
}, refreshInterval);
+
+
return () => clearInterval(interval);
+
}, [autoRefresh, refreshInterval]);
+
+
useEffect(() => {
+
if (!record) return;
+
+
const { item } = record;
+
const artistName = item.artists[0]?.artistName;
+
const trackName = item.trackName;
+
+
if (!artistName || !trackName) {
+
setArtworkLoading(false);
+
return;
+
}
+
+
// Reset loading state at start of fetch
+
if (refreshKey > 0) {
+
setArtworkLoading(true);
+
}
+
+
let cancelled = false;
+
+
const fetchMusicData = async () => {
+
try {
+
// Step 1: Check if we have an ISRC - Songlink supports this directly
+
if (item.isrc) {
+
console.log(`[teal.fm] Attempting ISRC lookup for ${trackName} by ${artistName}`, { isrc: item.isrc });
+
const response = await fetch(
+
`https://api.song.link/v1-alpha.1/links?platform=isrc&type=song&id=${encodeURIComponent(item.isrc)}&songIfSingle=true`
+
);
+
if (cancelled) return;
+
if (response.ok) {
+
const data = await response.json();
+
setSonglinkData(data);
+
+
// Extract album art from Songlink data
+
const entityId = data.entityUniqueId;
+
const entity = data.entitiesByUniqueId?.[entityId];
+
if (entity?.thumbnailUrl) {
+
console.log(`[teal.fm] ✓ Found album art via ISRC lookup`);
+
setAlbumArt(entity.thumbnailUrl);
+
} else {
+
console.warn(`[teal.fm] ISRC lookup succeeded but no thumbnail found`);
+
}
+
setArtworkLoading(false);
+
return;
+
} else {
+
console.warn(`[teal.fm] ISRC lookup failed with status ${response.status}`);
+
}
+
}
+
+
// Step 2: Search iTunes Search API to find the track (single request for both artwork and links)
+
console.log(`[teal.fm] Attempting iTunes search for: "${trackName}" by "${artistName}"`);
+
const iTunesSearchUrl = `https://itunes.apple.com/search?term=${encodeURIComponent(
+
`${trackName} ${artistName}`
+
)}&media=music&entity=song&limit=1`;
+
+
const iTunesResponse = await fetch(iTunesSearchUrl);
+
+
if (cancelled) return;
+
+
if (iTunesResponse.ok) {
+
const iTunesData = await iTunesResponse.json();
+
+
if (iTunesData.results && iTunesData.results.length > 0) {
+
const match = iTunesData.results[0];
+
const iTunesId = match.trackId;
+
+
// Set album artwork immediately (600x600 for high quality)
+
const artworkUrl = match.artworkUrl100?.replace('100x100', '600x600') || match.artworkUrl100;
+
if (artworkUrl) {
+
console.log(`[teal.fm] ✓ Found album art via iTunes search`, { url: artworkUrl });
+
setAlbumArt(artworkUrl);
+
} else {
+
console.warn(`[teal.fm] iTunes match found but no artwork URL`);
+
}
+
setArtworkLoading(false);
+
+
// Step 3: Use iTunes ID with Songlink to get all platform links
+
console.log(`[teal.fm] Fetching platform links via Songlink (iTunes ID: ${iTunesId})`);
+
const songlinkResponse = await fetch(
+
`https://api.song.link/v1-alpha.1/links?platform=itunes&type=song&id=${iTunesId}&songIfSingle=true`
+
);
+
+
if (cancelled) return;
+
+
if (songlinkResponse.ok) {
+
const songlinkData = await songlinkResponse.json();
+
console.log(`[teal.fm] ✓ Got platform links from Songlink`);
+
setSonglinkData(songlinkData);
+
return;
+
} else {
+
console.warn(`[teal.fm] Songlink request failed with status ${songlinkResponse.status}`);
+
}
+
} else {
+
console.warn(`[teal.fm] No iTunes results found for "${trackName}" by "${artistName}"`);
+
setArtworkLoading(false);
+
}
+
} else {
+
console.warn(`[teal.fm] iTunes search failed with status ${iTunesResponse.status}`);
+
}
+
+
// Step 4: Fallback - if originUrl is from a supported platform, try it directly
+
if (item.originUrl && (
+
item.originUrl.includes('spotify.com') ||
+
item.originUrl.includes('apple.com') ||
+
item.originUrl.includes('youtube.com') ||
+
item.originUrl.includes('tidal.com')
+
)) {
+
console.log(`[teal.fm] Attempting Songlink lookup via originUrl`, { url: item.originUrl });
+
const songlinkResponse = await fetch(
+
`https://api.song.link/v1-alpha.1/links?url=${encodeURIComponent(item.originUrl)}&songIfSingle=true`
+
);
+
+
if (cancelled) return;
+
+
if (songlinkResponse.ok) {
+
const data = await songlinkResponse.json();
+
console.log(`[teal.fm] ✓ Got data from Songlink via originUrl`);
+
setSonglinkData(data);
+
+
// Try to get artwork from Songlink if we don't have it yet
+
if (!albumArt) {
+
const entityId = data.entityUniqueId;
+
const entity = data.entitiesByUniqueId?.[entityId];
+
if (entity?.thumbnailUrl) {
+
console.log(`[teal.fm] ✓ Found album art via Songlink originUrl lookup`);
+
setAlbumArt(entity.thumbnailUrl);
+
} else {
+
console.warn(`[teal.fm] Songlink lookup succeeded but no thumbnail found`);
+
}
+
}
+
} else {
+
console.warn(`[teal.fm] Songlink originUrl lookup failed with status ${songlinkResponse.status}`);
+
}
+
}
+
+
if (!albumArt) {
+
console.warn(`[teal.fm] ✗ All album art fetch methods failed for "${trackName}" by "${artistName}"`);
+
}
+
+
setArtworkLoading(false);
+
} catch (err) {
+
console.error(`[teal.fm] ✗ Error fetching music data for "${trackName}" by "${artistName}":`, err);
+
setArtworkLoading(false);
+
}
+
};
+
+
fetchMusicData();
+
+
return () => {
+
cancelled = true;
+
};
+
}, [record, refreshKey]); // Add refreshKey to trigger refetch
+
+
if (error)
+
return (
+
<div style={{ padding: 8, color: "var(--atproto-color-error)" }}>
+
Failed to load status.
+
</div>
+
);
+
if (loading && !record)
+
return (
+
<div style={{ padding: 8, color: "var(--atproto-color-text-secondary)" }}>
+
Loading…
+
</div>
+
);
+
+
const { item } = record;
+
+
// Check if user is not listening to anything
+
const isNotListening = !item.trackName || item.artists.length === 0;
+
+
// Show "not listening" state
+
if (isNotListening) {
+
const displayHandle = handle || "User";
+
return (
+
<div style={styles.notListeningContainer}>
+
<div style={styles.notListeningIcon}>
+
<svg
+
width="80"
+
height="80"
+
viewBox="0 0 24 24"
+
fill="none"
+
stroke="currentColor"
+
strokeWidth="1.5"
+
strokeLinecap="round"
+
strokeLinejoin="round"
+
>
+
<path d="M9 18V5l12-2v13" />
+
<circle cx="6" cy="18" r="3" />
+
<circle cx="18" cy="16" r="3" />
+
</svg>
+
</div>
+
<div style={styles.notListeningTitle}>
+
{displayHandle} isn't listening to anything
+
</div>
+
<div style={styles.notListeningSubtitle}>Check back soon</div>
+
</div>
+
);
+
}
+
+
const artistNames = item.artists.map((a) => a.artistName).join(", ");
+
+
const platformConfig: Record<string, { name: string; icon: string; color: string }> = {
+
spotify: { name: "Spotify", icon: "♫", color: "#1DB954" },
+
appleMusic: { name: "Apple Music", icon: "🎵", color: "#FA243C" },
+
youtube: { name: "YouTube", icon: "▶", color: "#FF0000" },
+
youtubeMusic: { name: "YouTube Music", icon: "▶", color: "#FF0000" },
+
tidal: { name: "Tidal", icon: "🌊", color: "#00FFFF" },
+
bandcamp: { name: "Bandcamp", icon: "△", color: "#1DA0C3" },
+
};
+
+
const availablePlatforms = songlinkData
+
? Object.keys(platformConfig).filter((platform) =>
+
songlinkData.linksByPlatform[platform]
+
)
+
: [];
+
+
return (
+
<>
+
<div style={styles.container}>
+
{/* Album Artwork */}
+
<div style={styles.artworkContainer}>
+
{artworkLoading ? (
+
<div style={styles.artworkPlaceholder}>
+
<div style={styles.loadingSpinner} />
+
</div>
+
) : albumArt ? (
+
<img
+
src={albumArt}
+
alt={`${item.releaseName || "Album"} cover`}
+
style={styles.artwork}
+
onError={(e) => {
+
console.error("Failed to load album art:", {
+
url: albumArt,
+
track: item.trackName,
+
artist: item.artists[0]?.artistName,
+
error: "Image load error"
+
});
+
e.currentTarget.style.display = "none";
+
}}
+
/>
+
) : (
+
<div style={styles.artworkPlaceholder}>
+
<svg
+
width="64"
+
height="64"
+
viewBox="0 0 24 24"
+
fill="none"
+
stroke="currentColor"
+
strokeWidth="1.5"
+
>
+
<circle cx="12" cy="12" r="10" />
+
<circle cx="12" cy="12" r="3" />
+
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
+
</svg>
+
</div>
+
)}
+
</div>
+
+
{/* Content */}
+
<div style={styles.content}>
+
<div style={styles.label}>{label}</div>
+
<h2 style={styles.trackName}>{item.trackName}</h2>
+
<div style={styles.artistName}>{artistNames}</div>
+
{item.releaseName && (
+
<div style={styles.releaseName}>from {item.releaseName}</div>
+
)}
+
+
{/* Listen Button */}
+
{availablePlatforms.length > 0 ? (
+
<button
+
onClick={() => setShowPlatformModal(true)}
+
style={styles.listenButton}
+
data-teal-listen-button="true"
+
>
+
<span>Listen with your Streaming Client</span>
+
<svg
+
width="16"
+
height="16"
+
viewBox="0 0 24 24"
+
fill="none"
+
stroke="currentColor"
+
strokeWidth="2"
+
strokeLinecap="round"
+
strokeLinejoin="round"
+
>
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
+
<polyline points="15 3 21 3 21 9" />
+
<line x1="10" y1="14" x2="21" y2="3" />
+
</svg>
+
</button>
+
) : item.originUrl ? (
+
<a
+
href={item.originUrl}
+
target="_blank"
+
rel="noopener noreferrer"
+
style={styles.listenButton}
+
data-teal-listen-button="true"
+
>
+
<span>Listen on Last.fm</span>
+
<svg
+
width="16"
+
height="16"
+
viewBox="0 0 24 24"
+
fill="none"
+
stroke="currentColor"
+
strokeWidth="2"
+
strokeLinecap="round"
+
strokeLinejoin="round"
+
>
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
+
<polyline points="15 3 21 3 21 9" />
+
<line x1="10" y1="14" x2="21" y2="3" />
+
</svg>
+
</a>
+
) : null}
+
</div>
+
</div>
+
+
{/* Platform Selection Modal */}
+
{showPlatformModal && songlinkData && (
+
<div style={styles.modalOverlay} onClick={() => setShowPlatformModal(false)}>
+
<div style={styles.modalContent} onClick={(e) => e.stopPropagation()}>
+
<div style={styles.modalHeader}>
+
<h3 style={styles.modalTitle}>Choose your streaming service</h3>
+
<button
+
style={styles.closeButton}
+
onClick={() => setShowPlatformModal(false)}
+
data-teal-close="true"
+
>
+
×
+
</button>
+
</div>
+
<div style={styles.platformList}>
+
{availablePlatforms.map((platform) => {
+
const config = platformConfig[platform];
+
const link = songlinkData.linksByPlatform[platform];
+
return (
+
<a
+
key={platform}
+
href={link.url}
+
target="_blank"
+
rel="noopener noreferrer"
+
style={{
+
...styles.platformItem,
+
borderLeft: `4px solid ${config.color}`,
+
}}
+
onClick={() => setShowPlatformModal(false)}
+
data-teal-platform="true"
+
>
+
<span style={styles.platformIcon}>{config.icon}</span>
+
<span style={styles.platformName}>{config.name}</span>
+
<svg
+
width="20"
+
height="20"
+
viewBox="0 0 24 24"
+
fill="none"
+
stroke="currentColor"
+
strokeWidth="2"
+
style={styles.platformArrow}
+
>
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
+
<polyline points="15 3 21 3 21 9" />
+
<line x1="10" y1="14" x2="21" y2="3" />
+
</svg>
+
</a>
+
);
+
})}
+
</div>
+
</div>
+
</div>
+
)}
+
</>
+
);
+
};
+
+
const styles: Record<string, React.CSSProperties> = {
+
container: {
+
fontFamily: "system-ui, -apple-system, sans-serif",
+
display: "flex",
+
flexDirection: "column",
+
background: "var(--atproto-color-bg)",
+
borderRadius: 16,
+
overflow: "hidden",
+
maxWidth: 420,
+
color: "var(--atproto-color-text)",
+
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)",
+
border: "1px solid var(--atproto-color-border)",
+
},
+
artworkContainer: {
+
width: "100%",
+
aspectRatio: "1 / 1",
+
position: "relative",
+
overflow: "hidden",
+
},
+
artwork: {
+
width: "100%",
+
height: "100%",
+
objectFit: "cover",
+
display: "block",
+
},
+
artworkPlaceholder: {
+
width: "100%",
+
height: "100%",
+
display: "flex",
+
alignItems: "center",
+
justifyContent: "center",
+
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
+
color: "rgba(255, 255, 255, 0.5)",
+
},
+
loadingSpinner: {
+
width: 40,
+
height: 40,
+
border: "3px solid var(--atproto-color-border)",
+
borderTop: "3px solid var(--atproto-color-primary)",
+
borderRadius: "50%",
+
animation: "spin 1s linear infinite",
+
},
+
content: {
+
padding: "24px",
+
display: "flex",
+
flexDirection: "column",
+
gap: "8px",
+
},
+
label: {
+
fontSize: 11,
+
fontWeight: 600,
+
letterSpacing: "0.1em",
+
textTransform: "uppercase",
+
color: "var(--atproto-color-text-secondary)",
+
marginBottom: "4px",
+
},
+
trackName: {
+
fontSize: 28,
+
fontWeight: 700,
+
margin: 0,
+
lineHeight: 1.2,
+
color: "var(--atproto-color-text)",
+
},
+
artistName: {
+
fontSize: 16,
+
color: "var(--atproto-color-text-secondary)",
+
marginTop: "4px",
+
},
+
releaseName: {
+
fontSize: 14,
+
color: "var(--atproto-color-text-secondary)",
+
marginTop: "2px",
+
},
+
listenButton: {
+
display: "inline-flex",
+
alignItems: "center",
+
gap: "8px",
+
marginTop: "16px",
+
padding: "12px 20px",
+
background: "var(--atproto-color-bg-elevated)",
+
border: "1px solid var(--atproto-color-border)",
+
borderRadius: 24,
+
color: "var(--atproto-color-text)",
+
fontSize: 14,
+
fontWeight: 600,
+
textDecoration: "none",
+
cursor: "pointer",
+
transition: "all 0.2s ease",
+
alignSelf: "flex-start",
+
},
+
modalOverlay: {
+
position: "fixed",
+
top: 0,
+
left: 0,
+
right: 0,
+
bottom: 0,
+
backgroundColor: "rgba(0, 0, 0, 0.85)",
+
display: "flex",
+
alignItems: "center",
+
justifyContent: "center",
+
zIndex: 9999,
+
backdropFilter: "blur(4px)",
+
},
+
modalContent: {
+
background: "var(--atproto-color-bg)",
+
borderRadius: 16,
+
padding: 0,
+
maxWidth: 450,
+
width: "90%",
+
maxHeight: "80vh",
+
overflow: "auto",
+
boxShadow: "0 20px 60px rgba(0, 0, 0, 0.8)",
+
border: "1px solid var(--atproto-color-border)",
+
},
+
modalHeader: {
+
display: "flex",
+
justifyContent: "space-between",
+
alignItems: "center",
+
padding: "24px 24px 16px 24px",
+
borderBottom: "1px solid var(--atproto-color-border)",
+
},
+
modalTitle: {
+
margin: 0,
+
fontSize: 20,
+
fontWeight: 700,
+
color: "var(--atproto-color-text)",
+
},
+
closeButton: {
+
background: "transparent",
+
border: "none",
+
color: "var(--atproto-color-text-secondary)",
+
fontSize: 32,
+
cursor: "pointer",
+
padding: 0,
+
width: 32,
+
height: 32,
+
display: "flex",
+
alignItems: "center",
+
justifyContent: "center",
+
borderRadius: "50%",
+
transition: "all 0.2s ease",
+
lineHeight: 1,
+
},
+
platformList: {
+
padding: "16px",
+
display: "flex",
+
flexDirection: "column",
+
gap: "8px",
+
},
+
platformItem: {
+
display: "flex",
+
alignItems: "center",
+
gap: "16px",
+
padding: "16px",
+
background: "var(--atproto-color-bg-hover)",
+
borderRadius: 12,
+
textDecoration: "none",
+
color: "var(--atproto-color-text)",
+
transition: "all 0.2s ease",
+
cursor: "pointer",
+
border: "1px solid var(--atproto-color-border)",
+
},
+
platformIcon: {
+
fontSize: 24,
+
width: 32,
+
height: 32,
+
display: "flex",
+
alignItems: "center",
+
justifyContent: "center",
+
},
+
platformName: {
+
flex: 1,
+
fontSize: 16,
+
fontWeight: 600,
+
},
+
platformArrow: {
+
opacity: 0.5,
+
transition: "opacity 0.2s ease",
+
},
+
notListeningContainer: {
+
fontFamily: "system-ui, -apple-system, sans-serif",
+
display: "flex",
+
flexDirection: "column",
+
alignItems: "center",
+
justifyContent: "center",
+
background: "var(--atproto-color-bg)",
+
borderRadius: 16,
+
padding: "80px 40px",
+
maxWidth: 420,
+
color: "var(--atproto-color-text-secondary)",
+
border: "1px solid var(--atproto-color-border)",
+
textAlign: "center",
+
},
+
notListeningIcon: {
+
width: 120,
+
height: 120,
+
borderRadius: "50%",
+
background: "var(--atproto-color-bg-elevated)",
+
display: "flex",
+
alignItems: "center",
+
justifyContent: "center",
+
marginBottom: 24,
+
color: "var(--atproto-color-text-muted)",
+
},
+
notListeningTitle: {
+
fontSize: 18,
+
fontWeight: 600,
+
color: "var(--atproto-color-text)",
+
marginBottom: 8,
+
},
+
notListeningSubtitle: {
+
fontSize: 14,
+
color: "var(--atproto-color-text-secondary)",
+
},
+
};
+
+
// Add keyframes and hover styles
+
if (typeof document !== "undefined") {
+
const styleId = "teal-status-styles";
+
if (!document.getElementById(styleId)) {
+
const styleElement = document.createElement("style");
+
styleElement.id = styleId;
+
styleElement.textContent = `
+
@keyframes spin {
+
0% { transform: rotate(0deg); }
+
100% { transform: rotate(360deg); }
+
}
+
+
button[data-teal-listen-button]:hover:not(:disabled),
+
a[data-teal-listen-button]:hover {
+
background: var(--atproto-color-bg-pressed) !important;
+
border-color: var(--atproto-color-border-hover) !important;
+
transform: translateY(-2px);
+
}
+
+
button[data-teal-listen-button]:disabled {
+
opacity: 0.5;
+
cursor: not-allowed;
+
}
+
+
button[data-teal-close]:hover {
+
background: var(--atproto-color-bg-hover) !important;
+
color: var(--atproto-color-text) !important;
+
}
+
+
a[data-teal-platform]:hover {
+
background: var(--atproto-color-bg-pressed) !important;
+
transform: translateX(4px);
+
}
+
+
a[data-teal-platform]:hover svg {
+
opacity: 1 !important;
+
}
+
`;
+
document.head.appendChild(styleElement);
+
}
+
}
+
+
export default CurrentlyPlayingRenderer;
+4 -4
lib/renderers/TangledRepoRenderer.tsx
···
<div
style={{
...base.container,
-
background: `var(--atproto-color-bg-elevated)`,
borderWidth: "1px",
borderStyle: "solid",
borderColor: `var(--atproto-color-border)`,
···
<div
style={{
...base.header,
-
background: `var(--atproto-color-bg-elevated)`,
}}
>
<div style={base.headerTop}>
···
<div
style={{
...base.description,
-
background: `var(--atproto-color-bg-elevated)`,
color: `var(--atproto-color-text-secondary)`,
}}
>
···
<div
style={{
...base.languageSection,
-
background: `var(--atproto-color-bg-elevated)`,
}}
>
{/* Languages */}
···
<div
style={{
...base.container,
+
background: `var(--atproto-color-bg)`,
borderWidth: "1px",
borderStyle: "solid",
borderColor: `var(--atproto-color-border)`,
···
<div
style={{
...base.header,
+
background: `var(--atproto-color-bg)`,
}}
>
<div style={base.headerTop}>
···
<div
style={{
...base.description,
+
background: `var(--atproto-color-bg)`,
color: `var(--atproto-color-text-secondary)`,
}}
>
···
<div
style={{
...base.languageSection,
+
background: `var(--atproto-color-bg)`,
}}
>
{/* Languages */}
+59 -47
lib/styles.css
···
:root {
/* Light theme colors (default) */
-
--atproto-color-bg: #ffffff;
-
--atproto-color-bg-elevated: #f8fafc;
-
--atproto-color-bg-secondary: #f1f5f9;
--atproto-color-text: #0f172a;
--atproto-color-text-secondary: #475569;
--atproto-color-text-muted: #64748b;
-
--atproto-color-border: #e2e8f0;
-
--atproto-color-border-subtle: #cbd5e1;
--atproto-color-link: #2563eb;
--atproto-color-link-hover: #1d4ed8;
--atproto-color-error: #dc2626;
-
--atproto-color-button-bg: #f1f5f9;
-
--atproto-color-button-hover: #e2e8f0;
--atproto-color-button-text: #0f172a;
-
--atproto-color-code-bg: #f1f5f9;
-
--atproto-color-code-border: #e2e8f0;
-
--atproto-color-blockquote-border: #cbd5e1;
-
--atproto-color-blockquote-bg: #f8fafc;
-
--atproto-color-hr: #e2e8f0;
-
--atproto-color-image-bg: #f1f5f9;
--atproto-color-highlight: #fef08a;
}
/* Dark theme - can be applied via [data-theme="dark"] or .dark class */
[data-theme="dark"],
.dark {
-
--atproto-color-bg: #0f172a;
-
--atproto-color-bg-elevated: #1e293b;
-
--atproto-color-bg-secondary: #0b1120;
-
--atproto-color-text: #e2e8f0;
-
--atproto-color-text-secondary: #94a3b8;
-
--atproto-color-text-muted: #64748b;
-
--atproto-color-border: #1e293b;
-
--atproto-color-border-subtle: #334155;
--atproto-color-link: #60a5fa;
--atproto-color-link-hover: #93c5fd;
--atproto-color-error: #ef4444;
-
--atproto-color-button-bg: #1e293b;
-
--atproto-color-button-hover: #334155;
-
--atproto-color-button-text: #e2e8f0;
-
--atproto-color-code-bg: #0b1120;
-
--atproto-color-code-border: #1e293b;
-
--atproto-color-blockquote-border: #334155;
-
--atproto-color-blockquote-bg: #1e293b;
-
--atproto-color-hr: #334155;
-
--atproto-color-image-bg: #1e293b;
--atproto-color-highlight: #854d0e;
}
···
@media (prefers-color-scheme: dark) {
:root:not([data-theme]),
:root[data-theme="system"] {
-
--atproto-color-bg: #0f172a;
-
--atproto-color-bg-elevated: #1e293b;
-
--atproto-color-bg-secondary: #0b1120;
-
--atproto-color-text: #e2e8f0;
-
--atproto-color-text-secondary: #94a3b8;
-
--atproto-color-text-muted: #64748b;
-
--atproto-color-border: #1e293b;
-
--atproto-color-border-subtle: #334155;
--atproto-color-link: #60a5fa;
--atproto-color-link-hover: #93c5fd;
--atproto-color-error: #ef4444;
-
--atproto-color-button-bg: #1e293b;
-
--atproto-color-button-hover: #334155;
-
--atproto-color-button-text: #e2e8f0;
-
--atproto-color-code-bg: #0b1120;
-
--atproto-color-code-border: #1e293b;
-
--atproto-color-blockquote-border: #334155;
-
--atproto-color-blockquote-bg: #1e293b;
-
--atproto-color-hr: #334155;
-
--atproto-color-image-bg: #1e293b;
--atproto-color-highlight: #854d0e;
}
}
···
:root {
/* Light theme colors (default) */
+
--atproto-color-bg: #f5f7f9;
+
--atproto-color-bg-elevated: #f8f9fb;
+
--atproto-color-bg-secondary: #edf1f5;
--atproto-color-text: #0f172a;
--atproto-color-text-secondary: #475569;
--atproto-color-text-muted: #64748b;
+
--atproto-color-border: #d6dce3;
+
--atproto-color-border-subtle: #c1cad4;
+
--atproto-color-border-hover: #94a3b8;
--atproto-color-link: #2563eb;
--atproto-color-link-hover: #1d4ed8;
--atproto-color-error: #dc2626;
+
--atproto-color-primary: #2563eb;
+
--atproto-color-button-bg: #edf1f5;
+
--atproto-color-button-hover: #e3e9ef;
--atproto-color-button-text: #0f172a;
+
--atproto-color-bg-hover: #f0f3f6;
+
--atproto-color-bg-pressed: #e3e9ef;
+
--atproto-color-code-bg: #edf1f5;
+
--atproto-color-code-border: #d6dce3;
+
--atproto-color-blockquote-border: #c1cad4;
+
--atproto-color-blockquote-bg: #f0f3f6;
+
--atproto-color-hr: #d6dce3;
+
--atproto-color-image-bg: #edf1f5;
--atproto-color-highlight: #fef08a;
}
/* Dark theme - can be applied via [data-theme="dark"] or .dark class */
[data-theme="dark"],
.dark {
+
--atproto-color-bg: #141b22;
+
--atproto-color-bg-elevated: #1a222a;
+
--atproto-color-bg-secondary: #0f161c;
+
--atproto-color-text: #fafafa;
+
--atproto-color-text-secondary: #a1a1aa;
+
--atproto-color-text-muted: #71717a;
+
--atproto-color-border: #1f2933;
+
--atproto-color-border-subtle: #2d3748;
+
--atproto-color-border-hover: #4a5568;
--atproto-color-link: #60a5fa;
--atproto-color-link-hover: #93c5fd;
--atproto-color-error: #ef4444;
+
--atproto-color-primary: #3b82f6;
+
--atproto-color-button-bg: #1a222a;
+
--atproto-color-button-hover: #243039;
+
--atproto-color-button-text: #fafafa;
+
--atproto-color-bg-hover: #1a222a;
+
--atproto-color-bg-pressed: #243039;
+
--atproto-color-code-bg: #0f161c;
+
--atproto-color-code-border: #1f2933;
+
--atproto-color-blockquote-border: #2d3748;
+
--atproto-color-blockquote-bg: #1a222a;
+
--atproto-color-hr: #243039;
+
--atproto-color-image-bg: #1a222a;
--atproto-color-highlight: #854d0e;
}
···
@media (prefers-color-scheme: dark) {
:root:not([data-theme]),
:root[data-theme="system"] {
+
--atproto-color-bg: #141b22;
+
--atproto-color-bg-elevated: #1a222a;
+
--atproto-color-bg-secondary: #0f161c;
+
--atproto-color-text: #fafafa;
+
--atproto-color-text-secondary: #a1a1aa;
+
--atproto-color-text-muted: #71717a;
+
--atproto-color-border: #1f2933;
+
--atproto-color-border-subtle: #2d3748;
+
--atproto-color-border-hover: #4a5568;
--atproto-color-link: #60a5fa;
--atproto-color-link-hover: #93c5fd;
--atproto-color-error: #ef4444;
+
--atproto-color-primary: #3b82f6;
+
--atproto-color-button-bg: #1a222a;
+
--atproto-color-button-hover: #243039;
+
--atproto-color-button-text: #fafafa;
+
--atproto-color-bg-hover: #1a222a;
+
--atproto-color-bg-pressed: #243039;
+
--atproto-color-code-bg: #0f161c;
+
--atproto-color-code-border: #1f2933;
+
--atproto-color-blockquote-border: #2d3748;
+
--atproto-color-blockquote-bg: #1a222a;
+
--atproto-color-hr: #243039;
+
--atproto-color-image-bg: #1a222a;
--atproto-color-highlight: #854d0e;
}
}
+40
lib/types/teal.ts
···
···
+
/**
+
* teal.fm record types for music listening history
+
* Specification: fm.teal.alpha.actor.status and fm.teal.alpha.feed.play
+
*/
+
+
export interface TealArtist {
+
artistName: string;
+
artistMbId?: string;
+
}
+
+
export interface TealPlayItem {
+
artists: TealArtist[];
+
originUrl?: string;
+
trackName: string;
+
playedTime: string;
+
releaseName?: string;
+
recordingMbId?: string;
+
releaseMbId?: string;
+
submissionClientAgent?: string;
+
musicServiceBaseDomain?: string;
+
isrc?: string;
+
duration?: number;
+
}
+
+
/**
+
* fm.teal.alpha.actor.status - The last played song
+
*/
+
export interface TealActorStatusRecord {
+
$type: "fm.teal.alpha.actor.status";
+
item: TealPlayItem;
+
time: string;
+
expiry?: string;
+
}
+
+
/**
+
* fm.teal.alpha.feed.play - A single play record
+
*/
+
export interface TealFeedPlayRecord extends TealPlayItem {
+
$type: "fm.teal.alpha.feed.play";
+
}
+32
src/App.tsx
···
import { BlueskyPostList } from "../lib/components/BlueskyPostList";
import { BlueskyQuotePost } from "../lib/components/BlueskyQuotePost";
import { GrainGallery } from "../lib/components/GrainGallery";
import { useDidResolution } from "../lib/hooks/useDidResolution";
import { useLatestRecord } from "../lib/hooks/useLatestRecord";
import type { FeedPostRecord } from "../lib/types/bluesky";
···
did="kat.meangirls.online"
rkey="3m2e2qikseq2f"
/>
</section>
</div>
<div style={columnStackStyle}>
···
import { BlueskyPostList } from "../lib/components/BlueskyPostList";
import { BlueskyQuotePost } from "../lib/components/BlueskyQuotePost";
import { GrainGallery } from "../lib/components/GrainGallery";
+
import { CurrentlyPlaying } from "../lib/components/CurrentlyPlaying";
+
import { LastPlayed } from "../lib/components/LastPlayed";
import { useDidResolution } from "../lib/hooks/useDidResolution";
import { useLatestRecord } from "../lib/hooks/useLatestRecord";
import type { FeedPostRecord } from "../lib/types/bluesky";
···
did="kat.meangirls.online"
rkey="3m2e2qikseq2f"
/>
+
</section>
+
<section style={panelStyle}>
+
<h3 style={sectionHeaderStyle}>
+
teal.fm Currently Playing
+
</h3>
+
<p
+
style={{
+
fontSize: 12,
+
color: `var(--demo-text-secondary)`,
+
margin: "0 0 8px",
+
}}
+
>
+
Currently playing track from teal.fm (refreshes every 15s)
+
</p>
+
<CurrentlyPlaying did="nekomimi.pet" />
+
</section>
+
<section style={panelStyle}>
+
<h3 style={sectionHeaderStyle}>
+
teal.fm Last Played
+
</h3>
+
<p
+
style={{
+
fontSize: 12,
+
color: `var(--demo-text-secondary)`,
+
margin: "0 0 8px",
+
}}
+
>
+
Most recent play from teal.fm feed
+
</p>
+
<LastPlayed did="nekomimi.pet" />
</section>
</div>
<div style={columnStackStyle}>