An in-browser wisp.place site explorer
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}