web components for a integrable atproto based guestbook
1// no imports needed for this component - uses native fetch
2
3/**
4 * represents a guestbook signature from constellation
5 */
6export interface GuestbookSignature {
7 uri: string;
8 cid: string;
9 value: {
10 $type: 'pet.nkp.guestbook.sign';
11 subject: string; // DID (at-identifier format)
12 message: string;
13 createdAt: string;
14 };
15 author: string;
16 authorHandle?: string;
17}
18
19/**
20 * constellation API response for backlinks
21 */
22interface ConstellationBacklinksResponse {
23 total: number;
24 records: Array<{
25 did: string;
26 collection: string;
27 rkey: string;
28 }>;
29 cursor?: string;
30}
31
32/**
33 * Web component for displaying guestbook signatures.
34 *
35 * Usage:
36 * <guestbook-display
37 * did="did:web:nekomimi.pet"
38 * limit="50">
39 * </guestbook-display>
40 */
41export class GuestbookDisplayElement extends HTMLElement {
42 private signatures: GuestbookSignature[] = [];
43 private loading = false;
44 private error: string | null = null;
45
46 static get observedAttributes() {
47 return ['did', 'limit'];
48 }
49
50 constructor() {
51 super();
52 this.attachShadow({ mode: 'open' });
53 }
54
55 connectedCallback() {
56 this.render();
57 this.fetchSignatures();
58 }
59
60 attributeChangedCallback(name: string, oldValue: string, newValue: string) {
61 if (oldValue !== newValue && (name === 'did' || name === 'limit')) {
62 this.fetchSignatures();
63 }
64 }
65
66 private render() {
67 if (!this.shadowRoot) {
68 return;
69 }
70
71 this.shadowRoot.innerHTML = `
72 <style>
73 * {
74 box-sizing: border-box;
75 margin: 0;
76 padding: 0;
77 }
78
79 :host {
80 display: block;
81 font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
82 -webkit-font-smoothing: antialiased;
83 -moz-osx-font-smoothing: grayscale;
84 }
85
86 .guestbook-display {
87 background: #f9fafb;
88 padding: 0;
89 width: 100%;
90 }
91
92 h2 {
93 margin: 0 0 24px 0;
94 font-size: 11px;
95 font-weight: 700;
96 color: #000;
97 text-transform: uppercase;
98 letter-spacing: 0.05em;
99 }
100
101 .signatures-list {
102 display: flex;
103 flex-direction: column;
104 gap: 12px;
105 }
106
107 .signature-card {
108 background: white;
109 border: 1px solid #e5e7eb;
110 border-radius: 6px;
111 padding: 16px;
112 transition: background-color 0.2s;
113 }
114
115 .signature-card:hover {
116 background: rgba(255, 255, 255, 0.5);
117 }
118
119 .signature-header {
120 display: flex;
121 justify-content: space-between;
122 align-items: baseline;
123 margin-bottom: 8px;
124 gap: 12px;
125 }
126
127 .author {
128 font-weight: 500;
129 color: #000;
130 font-size: 14px;
131 word-break: break-all;
132 }
133
134 .author a {
135 color: #000;
136 text-decoration: none;
137 }
138
139 .author a:hover {
140 text-decoration: underline;
141 }
142
143 .timestamp {
144 font-size: 12px;
145 color: #6b7280;
146 white-space: nowrap;
147 }
148
149 .message {
150 font-size: 14px;
151 line-height: 1.5;
152 color: #000;
153 word-wrap: break-word;
154 }
155
156 .loading {
157 text-align: center;
158 padding: 32px;
159 color: #9ca3af;
160 }
161
162 .spinner {
163 display: inline-block;
164 width: 20px;
165 height: 20px;
166 border: 3px solid #e5e7eb;
167 border-top-color: #6b7280;
168 border-radius: 50%;
169 animation: spin 0.8s linear infinite;
170 }
171
172 @keyframes spin {
173 to { transform: rotate(360deg); }
174 }
175
176 .error {
177 text-align: center;
178 padding: 32px;
179 color: #dc2626;
180 background: #fef2f2;
181 border-radius: 8px;
182 border: 1px solid #fecaca;
183 }
184
185 .empty {
186 text-align: center;
187 padding: 48px 24px;
188 color: #9ca3af;
189 font-size: 15px;
190 }
191
192 .empty-icon {
193 font-size: 48px;
194 margin-bottom: 16px;
195 opacity: 0.5;
196 }
197
198 .signature-count {
199 font-size: 14px;
200 color: #536471;
201 margin-bottom: 16px;
202 text-align: center;
203 }
204 </style>
205
206 <div class="guestbook-display">
207 <h2>RECENT ENTRIES</h2>
208 <div id="content"></div>
209 </div>
210 `;
211
212 this.updateContent();
213 }
214
215 private updateContent() {
216 const contentDiv = this.shadowRoot?.querySelector('#content');
217 if (!contentDiv) {
218 return;
219 }
220
221 if (this.loading) {
222 contentDiv.innerHTML = `
223 <div class="loading">
224 <div class="spinner"></div>
225 <p>Loading signatures...</p>
226 </div>
227 `;
228 return;
229 }
230
231 if (this.error) {
232 contentDiv.innerHTML = `
233 <div class="error">
234 <p><strong>Error:</strong> ${this.error}</p>
235 </div>
236 `;
237 return;
238 }
239
240 if (this.signatures.length === 0) {
241 contentDiv.innerHTML = `
242 <div class="empty">
243 <div class="empty-icon">✍️</div>
244 <p>No signatures yet. Be the first to sign!</p>
245 </div>
246 `;
247 return;
248 }
249
250 // render signatures
251 const signaturesHtml = this.signatures
252 .map((sig) => this.renderSignature(sig))
253 .join('');
254
255 contentDiv.innerHTML = `
256 <div class="signature-count">
257 ${this.signatures.length} signature${this.signatures.length !== 1 ? 's' : ''}
258 </div>
259 <div class="signatures-list">
260 ${signaturesHtml}
261 </div>
262 `;
263 }
264
265 private renderSignature(sig: GuestbookSignature): string {
266 const timestamp = this.formatTimestamp(sig.value.createdAt);
267 const authorIdentifier = sig.authorHandle || sig.author;
268 const authorLink = `https://bsky.app/profile/${sig.authorHandle || sig.author}`;
269 const displayName = sig.authorHandle || this.shortenDid(sig.author);
270
271 return `
272 <div class="signature-card">
273 <div class="signature-header">
274 <div class="author">
275 <a href="${authorLink}" target="_blank" rel="noopener noreferrer">
276 ${this.escapeHtml(displayName)}
277 </a>
278 </div>
279 <div class="timestamp">${timestamp}</div>
280 </div>
281 <div class="message">${this.escapeHtml(sig.value.message)}</div>
282 </div>
283 `;
284 }
285
286 private async fetchSignatures() {
287 const did = this.getAttribute('did');
288 if (!did) {
289 this.error = 'Missing did attribute';
290 this.render();
291 return;
292 }
293
294 // subject is just the DID (at-identifier format)
295 const subject = did;
296
297 const limit = parseInt(this.getAttribute('limit') || '50', 10);
298
299 this.loading = true;
300 this.error = null;
301 this.updateContent();
302
303 try {
304 // query constellation for backlinks
305 // source format: collection:field.path
306 // constellation is not in atproto schemas, so we use a direct fetch
307 const url = new URL('/xrpc/blue.microcosm.links.getBacklinks', 'https://constellation.microcosm.blue');
308 url.searchParams.set('subject', subject);
309 url.searchParams.set('source', 'pet.nkp.guestbook.sign:subject');
310 url.searchParams.set('limit', limit.toString());
311
312 const response = await fetch(url.toString());
313 if (!response.ok) {
314 throw new Error('Failed to fetch signatures from Constellation');
315 }
316
317 const data: ConstellationBacklinksResponse = await response.json();
318
319 console.log('Constellation response:', data);
320
321 // fetch actual records
322 if (!data.records || !Array.isArray(data.records)) {
323 console.warn('No records found in response');
324 this.signatures = [];
325 } else {
326 // Fetch all records in parallel
327 const recordPromises = data.records.map(async (record) => {
328 try {
329 const recordUrl = new URL('/xrpc/com.atproto.repo.getRecord', 'https://slingshot.wisp.place');
330 recordUrl.searchParams.set('repo', record.did);
331 recordUrl.searchParams.set('collection', record.collection);
332 recordUrl.searchParams.set('rkey', record.rkey);
333
334 const recordResponse = await fetch(recordUrl.toString());
335 if (!recordResponse.ok) {
336 console.warn(`Failed to fetch record ${record.did}/${record.collection}/${record.rkey}`);
337 return null;
338 }
339
340 const recordData = await recordResponse.json();
341
342 // validate the record
343 if (
344 recordData.value &&
345 recordData.value.$type === 'pet.nkp.guestbook.sign' &&
346 typeof recordData.value.message === 'string' &&
347 typeof recordData.value.createdAt === 'string'
348 ) {
349 return {
350 uri: recordData.uri,
351 cid: recordData.cid,
352 value: recordData.value,
353 author: record.did,
354 authorHandle: undefined,
355 } as GuestbookSignature;
356 }
357 } catch (err) {
358 console.warn(`Error fetching record ${record.did}/${record.collection}/${record.rkey}:`, err);
359 }
360 return null;
361 });
362
363 const results = await Promise.all(recordPromises);
364 const validSignatures = results.filter((sig): sig is GuestbookSignature => sig !== null);
365
366 // Sort once after collecting all signatures
367 validSignatures.sort((a, b) => {
368 return new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime();
369 });
370
371 this.signatures = validSignatures;
372 this.loading = false;
373 this.updateContent();
374
375 // Batch fetch profiles asynchronously
376 if (validSignatures.length > 0) {
377 const uniqueDids = Array.from(new Set(validSignatures.map(sig => sig.author)));
378
379 // Batch fetch profiles up to 25 at a time (API limit)
380 const profilePromises = [];
381 for (let i = 0; i < uniqueDids.length; i += 25) {
382 const batch = uniqueDids.slice(i, i + 25);
383
384 const profileUrl = new URL('/xrpc/app.bsky.actor.getProfiles', 'https://public.api.bsky.app');
385 batch.forEach(d => profileUrl.searchParams.append('actors', d));
386
387 profilePromises.push(
388 fetch(profileUrl.toString())
389 .then(profileResponse => profileResponse.ok ? profileResponse.json() : null)
390 .then(profilesData => {
391 if (profilesData?.profiles && Array.isArray(profilesData.profiles)) {
392 const handles = new Map<string, string>();
393 profilesData.profiles.forEach((profile: any) => {
394 if (profile.handle) {
395 handles.set(profile.did, profile.handle);
396 }
397 });
398 return handles;
399 }
400 return new Map<string, string>();
401 })
402 .catch((err) => {
403 console.warn('Failed to fetch profile batch:', err);
404 return new Map<string, string>();
405 })
406 );
407 }
408
409 // Wait for all profile batches, then update once
410 const handleMaps = await Promise.all(profilePromises);
411 const allHandles = new Map<string, string>();
412 handleMaps.forEach(map => {
413 map.forEach((handle, did) => allHandles.set(did, handle));
414 });
415
416 if (allHandles.size > 0) {
417 this.signatures = this.signatures.map(sig => {
418 const handle = allHandles.get(sig.author);
419 return handle ? { ...sig, authorHandle: handle } : sig;
420 });
421 this.updateContent();
422 }
423 }
424 }
425
426 } catch (error) {
427 console.error('Error fetching signatures:', error);
428 this.error = error instanceof Error ? error.message : 'Unknown error occurred';
429 this.loading = false;
430 this.updateContent();
431 }
432 }
433
434 private formatTimestamp(isoString: string): string {
435 const date = new Date(isoString);
436 // Format as "Nov 24, 2024"
437 return date.toLocaleDateString('en-US', {
438 month: 'short',
439 day: 'numeric',
440 year: 'numeric',
441 });
442 }
443
444 private shortenDid(did: string): string {
445 if (did.startsWith('did:')) {
446 const afterPrefix = did.indexOf(':', 4);
447 if (afterPrefix !== -1) {
448 return `${did.slice(0, afterPrefix + 9)}...`;
449 }
450 }
451 return did;
452 }
453
454 private escapeHtml(text: string): string {
455 const div = document.createElement('div');
456 div.textContent = text;
457 return div.innerHTML;
458 }
459
460 /**
461 * refresh signatures from the API
462 */
463 refresh() {
464 this.fetchSignatures();
465 }
466}
467