at main 8.1 kB view raw
1import { useEffect, useState } from "react" 2 3interface GuestbookEntry { 4 uri: string 5 author: string 6 authorHandle?: string 7 message: string 8 createdAt: string 9} 10 11interface ConstellationRecord { 12 did: string 13 collection: string 14 rkey: string 15} 16 17const COLORS = [ 18 '#dc2626', // red 19 '#0d9488', // teal 20 '#059669', // emerald 21 '#84cc16', // lime 22 '#ec4899', // pink 23 '#3b82f6', // blue 24 '#8b5cf6', // violet 25] 26 27function getColorForIndex(index: number): string { 28 return COLORS[index % COLORS.length]! 29} 30 31interface GuestbookEntriesProps { 32 did: string 33 limit?: number 34 onRefresh?: (refresh: () => void) => void 35} 36 37export function GuestbookEntries({ did, limit = 50, onRefresh }: GuestbookEntriesProps) { 38 const [entries, setEntries] = useState<GuestbookEntry[]>([]) 39 const [loading, setLoading] = useState(true) 40 const [error, setError] = useState<string | null>(null) 41 42 const fetchEntries = async (signal: AbortSignal) => { 43 setLoading(true) 44 setError(null) 45 46 try { 47 const url = new URL('/xrpc/blue.microcosm.links.getBacklinks', 'https://constellation.microcosm.blue') 48 url.searchParams.set('subject', did) 49 url.searchParams.set('source', 'pet.nkp.guestbook.sign:subject') 50 url.searchParams.set('limit', limit.toString()) 51 52 const response = await fetch(url.toString(), { signal }) 53 if (!response.ok) throw new Error('Failed to fetch signatures') 54 55 const data = await response.json() 56 57 if (!data.records || !Array.isArray(data.records)) { 58 setEntries([]) 59 setLoading(false) 60 return 61 } 62 63 // Collect all entries first, then render once 64 const entryPromises = (data.records as ConstellationRecord[]).map(async (record) => { 65 try { 66 const recordUrl = new URL('/xrpc/com.atproto.repo.getRecord', 'https://slingshot.wisp.place') 67 recordUrl.searchParams.set('repo', record.did) 68 recordUrl.searchParams.set('collection', record.collection) 69 recordUrl.searchParams.set('rkey', record.rkey) 70 71 const recordResponse = await fetch(recordUrl.toString(), { signal }) 72 if (!recordResponse.ok) return null 73 74 const recordData = await recordResponse.json() 75 76 if ( 77 recordData.value && 78 recordData.value.$type === 'pet.nkp.guestbook.sign' && 79 typeof recordData.value.message === 'string' 80 ) { 81 return { 82 uri: recordData.uri, 83 author: record.did, 84 authorHandle: undefined, 85 message: recordData.value.message, 86 createdAt: recordData.value.createdAt, 87 } as GuestbookEntry 88 } 89 } catch (err) { 90 if (err instanceof Error && err.name === 'AbortError') throw err 91 } 92 return null 93 }) 94 95 const results = await Promise.all(entryPromises) 96 const validEntries = results.filter((e): e is GuestbookEntry => e !== null) 97 98 // Sort once and set all entries at once 99 validEntries.sort((a, b) => 100 new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 101 ) 102 103 setEntries(validEntries) 104 setLoading(false) 105 106 // Batch fetch profiles asynchronously 107 if (validEntries.length > 0) { 108 const uniqueDids = Array.from(new Set(validEntries.map(e => e.author))) 109 110 // Batch fetch profiles up to 25 at a time (API limit) 111 const profilePromises = [] 112 for (let i = 0; i < uniqueDids.length; i += 25) { 113 const batch = uniqueDids.slice(i, i + 25) 114 115 const profileUrl = new URL('/xrpc/app.bsky.actor.getProfiles', 'https://public.api.bsky.app') 116 batch.forEach(d => profileUrl.searchParams.append('actors', d)) 117 118 profilePromises.push( 119 fetch(profileUrl.toString(), { signal }) 120 .then(profileResponse => profileResponse.ok ? profileResponse.json() : null) 121 .then(profilesData => { 122 if (profilesData?.profiles && Array.isArray(profilesData.profiles)) { 123 const handles = new Map<string, string>() 124 profilesData.profiles.forEach((profile: any) => { 125 if (profile.handle) { 126 handles.set(profile.did, profile.handle) 127 } 128 }) 129 return handles 130 } 131 return new Map<string, string>() 132 }) 133 .catch((err) => { 134 if (err instanceof Error && err.name === 'AbortError') throw err 135 return new Map<string, string>() 136 }) 137 ) 138 } 139 140 // Wait for all profile batches, then update once 141 const handleMaps = await Promise.all(profilePromises) 142 const allHandles = new Map<string, string>() 143 handleMaps.forEach(map => { 144 map.forEach((handle, did) => allHandles.set(did, handle)) 145 }) 146 147 if (allHandles.size > 0) { 148 setEntries(prev => prev.map(entry => { 149 const handle = allHandles.get(entry.author) 150 return handle ? { ...entry, authorHandle: handle } : entry 151 })) 152 } 153 } 154 } catch (err) { 155 if (err instanceof Error && err.name === 'AbortError') return 156 setError(err instanceof Error ? err.message : 'Failed to load entries') 157 setLoading(false) 158 } 159 } 160 161 useEffect(() => { 162 const abortController = new AbortController() 163 fetchEntries(abortController.signal) 164 onRefresh?.(() => { 165 abortController.abort() 166 const newController = new AbortController() 167 fetchEntries(newController.signal) 168 }) 169 170 return () => abortController.abort() 171 }, [did, limit]) 172 173 const formatDate = (isoString: string) => { 174 const date = new Date(isoString) 175 return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric' }) 176 } 177 178 const shortenDid = (did: string) => { 179 if (did.startsWith('did:')) { 180 const afterPrefix = did.indexOf(':', 4) 181 if (afterPrefix !== -1) { 182 return `${did.slice(0, afterPrefix + 9)}...` 183 } 184 } 185 return did 186 } 187 188 if (loading) { 189 return ( 190 <div className="text-center py-12 text-gray-500"> 191 Loading entries... 192 </div> 193 ) 194 } 195 196 if (error) { 197 return ( 198 <div className="text-center py-12 text-red-500"> 199 {error} 200 </div> 201 ) 202 } 203 204 if (entries.length === 0) { 205 return ( 206 <div className="text-center py-12 text-gray-500"> 207 No entries yet. Be the first to sign! 208 </div> 209 ) 210 } 211 212 return ( 213 <div className="space-y-4"> 214 {entries.map((entry, index) => ( 215 <div 216 key={entry.uri} 217 className="bg-gray-100 rounded-lg p-4 border-l-4 transition-colors" 218 style={{ borderLeftColor: getColorForIndex(index) }} 219 > 220 <div className="flex justify-between items-start mb-1"> 221 <a 222 href={`https://bsky.app/profile/${entry.authorHandle || entry.author}`} 223 target="_blank" 224 rel="noopener noreferrer" 225 className="font-semibold text-gray-900 hover:underline" 226 > 227 {entry.authorHandle || shortenDid(entry.author)} 228 </a> 229 <a 230 href={`https://bsky.app/profile/${entry.authorHandle || entry.author}`} 231 target="_blank" 232 rel="noopener noreferrer" 233 className="text-gray-400 hover:text-gray-600" 234 style={{ color: getColorForIndex(index) }} 235 > 236 <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}> 237 <path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /> 238 </svg> 239 </a> 240 </div> 241 <p className="text-gray-800 mb-2"> 242 {entry.message} 243 </p> 244 <span className="text-sm text-gray-500"> 245 {formatDate(entry.createdAt)} 246 </span> 247 </div> 248 ))} 249 </div> 250 ) 251} 252