An in-browser wisp.place site explorer
at main 207 lines 5.5 kB view raw
1/** 2 * React hook for ATProto handle/DID resolution 3 * 4 * This hook handles the complete resolution chain: 5 * - Handle → DID (via PLC Directory) 6 * - DID → PDS endpoint (via DID document) 7 * - Caching with sessionStorage 8 */ 9 10import { useState, useEffect, useCallback } from 'react'; 11import { resolveHandleToDid, getPdsEndpoint } from '../utils/atproto'; 12import { withRetry } from '../utils/retry'; 13import type { ResolutionResult } from '../types/atproto'; 14 15export interface ResolverState { 16 data: ResolutionResult | null; 17 loading: boolean; 18 error: string | null; 19} 20 21const CACHE_KEY_PREFIX = 'wisp_resolver_'; 22const CACHE_TTL = 60 * 60 * 1000; // 1 hour 23 24/** 25 * Parse input to determine if it's a handle or DID 26 */ 27function parseInput(input: string): { type: 'handle' | 'did'; value: string } { 28 const trimmed = input.trim(); 29 30 // Check if it's a DID (starts with did:plc or did:web) 31 if (trimmed.startsWith('did:plc:') || trimmed.startsWith('did:web:')) { 32 return { type: 'did', value: trimmed }; 33 } 34 35 // Remove @ prefix if present 36 const value = trimmed.startsWith('@') ? trimmed.slice(1) : trimmed; 37 38 // Check for handle format (basic validation) 39 if (value.includes('.') && value.length > 0) { 40 return { type: 'handle', value }; 41 } 42 43 // Default to treating as handle 44 return { type: 'handle', value }; 45} 46 47/** 48 * Get cache key for a handle or DID 49 */ 50function getCacheKey(handleOrDid: string): string { 51 return `${CACHE_KEY_PREFIX}${handleOrDid}`; 52} 53 54/** 55 * Load from sessionStorage cache 56 */ 57function loadFromCache(handleOrDid: string): ResolutionResult | null { 58 try { 59 const cached = sessionStorage.getItem(getCacheKey(handleOrDid)); 60 if (!cached) { 61 return null; 62 } 63 64 const entry = JSON.parse(cached); 65 const now = Date.now(); 66 67 // Check if expired 68 if (now - entry.timestamp > CACHE_TTL) { 69 sessionStorage.removeItem(getCacheKey(handleOrDid)); 70 return null; 71 } 72 73 return entry.data; 74 } catch (error) { 75 console.warn('Failed to load from cache:', error); 76 return null; 77 } 78} 79 80/** 81 * Save to sessionStorage cache 82 */ 83function saveToCache(handleOrDid: string, data: ResolutionResult): void { 84 try { 85 const entry = { 86 data, 87 timestamp: Date.now(), 88 }; 89 sessionStorage.setItem(getCacheKey(handleOrDid), JSON.stringify(entry)); 90 } catch (error) { 91 console.warn('Failed to save to cache:', error); 92 } 93} 94 95/** 96 * Clear cache for a specific handle or DID 97 */ 98export function clearResolverCache(handleOrDid?: string): void { 99 if (handleOrDid) { 100 sessionStorage.removeItem(getCacheKey(handleOrDid)); 101 } else { 102 // Clear all wisp resolver cache 103 const keys = Object.keys(sessionStorage); 104 for (const key of keys) { 105 if (key.startsWith(CACHE_KEY_PREFIX)) { 106 sessionStorage.removeItem(key); 107 } 108 } 109 } 110} 111 112/** 113 * Custom hook for ATProto resolution 114 */ 115export function useATProtoResolver(input: string | null): ResolverState { 116 const [state, setState] = useState<ResolverState>({ 117 data: null, 118 loading: false, 119 error: null, 120 }); 121 122 const resolve = useCallback(async (value: string) => { 123 const parsed = parseInput(value); 124 125 // Try cache first 126 const cached = loadFromCache(parsed.value); 127 if (cached) { 128 setState({ data: cached, loading: false, error: null }); 129 return; 130 } 131 132 setState({ data: null, loading: true, error: null }); 133 134 try { 135 let did: string; 136 let handle: string | undefined; 137 138 if (parsed.type === 'handle') { 139 handle = parsed.value; 140 did = await withRetry(() => resolveHandleToDid(handle!)); 141 } else { 142 did = parsed.value; 143 } 144 145 const pdsUrl = await withRetry(() => getPdsEndpoint(did)); 146 147 const result: ResolutionResult = { 148 handle, 149 did, 150 pdsUrl, 151 }; 152 153 // Cache the result 154 saveToCache(parsed.value, result); 155 156 setState({ data: result, loading: false, error: null }); 157 } catch (error) { 158 let errorMessage = 'Failed to resolve handle or DID'; 159 160 if (error instanceof Error) { 161 errorMessage = error.message; 162 163 // Add specific error messages 164 if (error.message.includes('Failed to resolve handle')) { 165 errorMessage = `Handle '${parsed.value}' not found or does not exist`; 166 } else if (error.message.includes('Could not find PDS endpoint')) { 167 errorMessage = `No PDS found for this account`; 168 } else if (error.message.includes('Failed to fetch')) { 169 errorMessage = `Network error. Please check your connection and try again`; 170 } 171 } 172 173 setState({ data: null, loading: false, error: errorMessage }); 174 } 175 }, []); 176 177 // Resolve when input changes 178 useEffect(() => { 179 if (!input || !input.trim()) { 180 setState({ data: null, loading: false, error: null }); 181 return; 182 } 183 184 resolve(input); 185 }, [input, resolve]); 186 187 return state; 188} 189 190/** 191 * Hook that also provides manual resolve and clear functions 192 */ 193export function useATProtoResolverManual(input: string | null) { 194 const state = useATProtoResolver(input); 195 196 return { 197 ...state, 198 resolve: async (_value: string) => { 199 // This will trigger a re-render via the input prop 200 // For manual usage, you'd typically update the input state 201 throw new Error( 202 'Use the input prop instead. See useATProtoResolver for automatic resolution.' 203 ); 204 }, 205 clear: () => clearResolverCache(input || undefined), 206 }; 207}