···
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
42
-
const fetchEntries = async () => {
42
+
const fetchEntries = async (signal: AbortSignal) => {
···
url.searchParams.set('source', 'pet.nkp.guestbook.sign:subject')
url.searchParams.set('limit', limit.toString())
52
-
const response = await fetch(url.toString())
52
+
const response = await fetch(url.toString(), { signal })
if (!response.ok) throw new Error('Failed to fetch signatures')
const data = await response.json()
if (!data.records || !Array.isArray(data.records)) {
63
-
const fetchedEntries: GuestbookEntry[] = []
64
-
const recordMap = new Map<string, any>()
65
-
const authorDids: string[] = []
67
-
// First pass: fetch all records and collect author DIDs
68
-
for (const record of data.records as ConstellationRecord[]) {
63
+
// Collect all entries first, then render once
64
+
const entryPromises = (data.records as ConstellationRecord[]).map(async (record) => {
const recordUrl = new URL('/xrpc/com.atproto.repo.getRecord', 'https://slingshot.wisp.place')
recordUrl.searchParams.set('repo', record.did)
recordUrl.searchParams.set('collection', record.collection)
recordUrl.searchParams.set('rkey', record.rkey)
75
-
const recordResponse = await fetch(recordUrl.toString())
76
-
if (!recordResponse.ok) continue
71
+
const recordResponse = await fetch(recordUrl.toString(), { signal })
72
+
if (!recordResponse.ok) return null
const recordData = await recordResponse.json()
···
recordData.value.$type === 'pet.nkp.guestbook.sign' &&
typeof recordData.value.message === 'string'
85
-
recordMap.set(record.did, recordData)
86
-
authorDids.push(record.did)
82
+
uri: recordData.uri,
84
+
authorHandle: undefined,
85
+
message: recordData.value.message,
86
+
createdAt: recordData.value.createdAt,
90
+
if (err instanceof Error && err.name === 'AbortError') throw err
91
-
// Second pass: batch fetch all profiles at once
92
-
const authorHandles = new Map<string, string>()
93
-
if (authorDids.length > 0) {
95
-
// Batch fetch profiles up to 25 at a time (API limit)
96
-
for (let i = 0; i < authorDids.length; i += 25) {
97
-
const batch = authorDids.slice(i, i + 25)
98
-
const profileUrl = new URL('/xrpc/app.bsky.actor.getProfiles', 'https://public.api.bsky.app')
99
-
batch.forEach(did => profileUrl.searchParams.append('actors', did))
95
+
const results = await Promise.all(entryPromises)
96
+
const validEntries = results.filter((e): e is GuestbookEntry => e !== null)
101
-
const profileResponse = await fetch(profileUrl.toString())
102
-
if (profileResponse.ok) {
103
-
const profilesData = await profileResponse.json()
104
-
if (profilesData.profiles && Array.isArray(profilesData.profiles)) {
105
-
profilesData.profiles.forEach((profile: any) => {
106
-
if (profile.handle) {
107
-
authorHandles.set(profile.did, profile.handle)
116
-
// Third pass: create entries with fetched profile data
117
-
for (const [did, recordData] of recordMap) {
118
-
const authorHandle = authorHandles.get(did)
119
-
fetchedEntries.push({
120
-
uri: recordData.uri,
123
-
message: recordData.value.message,
124
-
createdAt: recordData.value.createdAt,
128
-
// Sort by date, newest first
129
-
fetchedEntries.sort((a, b) =>
98
+
// Sort once and set all entries at once
99
+
validEntries.sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
133
-
setEntries(fetchedEntries)
103
+
setEntries(validEntries)
106
+
// Batch fetch profiles asynchronously
107
+
if (validEntries.length > 0) {
108
+
const uniqueDids = Array.from(new Set(validEntries.map(e => e.author)))
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)
115
+
const profileUrl = new URL('/xrpc/app.bsky.actor.getProfiles', 'https://public.api.bsky.app')
116
+
batch.forEach(d => profileUrl.searchParams.append('actors', d))
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)
131
+
return new Map<string, string>()
134
+
if (err instanceof Error && err.name === 'AbortError') throw err
135
+
return new Map<string, string>()
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))
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
155
+
if (err instanceof Error && err.name === 'AbortError') return
setError(err instanceof Error ? err.message : 'Failed to load entries')
143
-
onRefresh?.(() => fetchEntries())
162
+
const abortController = new AbortController()
163
+
fetchEntries(abortController.signal)
164
+
onRefresh?.(() => {
165
+
abortController.abort()
166
+
const newController = new AbortController()
167
+
fetchEntries(newController.signal)
170
+
return () => abortController.abort()
const formatDate = (isoString: string) => {
···
const shortenDid = (did: string) => {
152
-
if (did.startsWith('did:plc:')) {
153
-
return `${did.slice(0, 12)}...`
179
+
if (did.startsWith('did:')) {
180
+
const afterPrefix = did.indexOf(':', 4)
181
+
if (afterPrefix !== -1) {
182
+
return `${did.slice(0, afterPrefix + 9)}...`
···
{entries.map((entry, index) => (
187
-
className="bg-gray-100 dark:bg-gray-800/50 rounded-lg p-4 border-l-4 transition-colors"
217
+
className="bg-gray-100 rounded-lg p-4 border-l-4 transition-colors"
style={{ borderLeftColor: getColorForIndex(index) }}
<div className="flex justify-between items-start mb-1">
···
href={`https://bsky.app/profile/${entry.authorHandle || entry.author}`}
rel="noopener noreferrer"
195
-
className="font-semibold text-gray-900 dark:text-gray-100 hover:underline"
225
+
className="font-semibold text-gray-900 hover:underline"
{entry.authorHandle || shortenDid(entry.author)}
···
href={`https://bsky.app/profile/${entry.authorHandle || entry.author}`}
rel="noopener noreferrer"
203
-
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
233
+
className="text-gray-400 hover:text-gray-600"
style={{ color: getColorForIndex(index) }}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
···
211
-
<p className="text-gray-800 dark:text-gray-200 mb-2">
241
+
<p className="text-gray-800 mb-2">
214
-
<span className="text-sm text-gray-500 dark:text-gray-400">
244
+
<span className="text-sm text-gray-500">
{formatDate(entry.createdAt)}