personal website
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