This repository has no description
1import React, { useState } from 'react';
2import './FeedTimeline.css';
3import { formatDistanceToNow } from 'date-fns';
4
5const FeedTimeline = ({ records, serviceEndpoint, compactView = false }) => {
6 // Define all hooks at the top level, before any conditionals
7 const [selectedRecord, setSelectedRecord] = useState(null);
8 const [modalData, setModalData] = useState(null);
9 const [modalLoading, setModalLoading] = useState(false);
10 const [modalError, setModalError] = useState(null);
11
12 if (!records || records.length === 0) {
13 return null;
14 }
15
16 // Helper to format the timestamp as a relative time
17 const formatRelativeTime = (timestamp) => {
18 if (!timestamp) return 'Unknown time';
19 try {
20 return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
21 } catch (error) {
22 return 'Invalid date';
23 }
24 };
25
26 // Helper to generate Bluesky app URL from ATProto URI
27 const getBskyAppUrl = (uri) => {
28 if (!uri || !uri.startsWith('at://')) return null;
29
30 // Parse the at:// URI format: at://did:plc:xyz/collection/rkey
31 const parts = uri.replace('at://', '').split('/');
32
33 if (parts.length < 3) return null;
34
35 const did = parts[0];
36 const collection = parts[1];
37 const rkey = parts[2];
38
39 // Handle different collection types
40 if (collection === 'app.bsky.feed.post') {
41 return `https://bsky.app/profile/${did}/post/${rkey}`;
42 } else if (collection === 'app.bsky.feed.generator') {
43 return `https://bsky.app/profile/${did}/feed/${rkey}`;
44 } else if (collection === 'app.bsky.graph.list') {
45 return `https://bsky.app/profile/${did}/lists/${rkey}`;
46 } else if (collection === 'app.bsky.actor.profile') {
47 return `https://bsky.app/profile/${did}`;
48 }
49
50 return null;
51 };
52
53 // Helper to extract readable content from different record types
54 const getRecordContent = (record) => {
55 // Extract from specific record types
56 if (record.value) {
57 // Initialize result object
58 let result = null;
59
60 // Handle posts with text
61 if (record.value.text) {
62 result = {
63 label: 'Text',
64 content: record.value.text.length > 100
65 ? `${record.value.text.substring(0, 100)}...`
66 : record.value.text
67 };
68
69 // Detect if it's a reply post
70 if (record.value.reply && record.value.reply.parent) {
71 result.isReply = true;
72 result.replyParent = record.value.reply.parent.uri;
73 result.replyRoot = record.value.reply.root?.uri || record.value.reply.parent.uri;
74 }
75
76 // Detect if it's a quote post
77 if (record.value.embed &&
78 (record.value.embed['$type'] === 'app.bsky.embed.record' ||
79 record.value.embed['$type'] === 'app.bsky.embed.recordWithMedia')) {
80 result.isQuote = true;
81
82 // Extract the quoted record URI
83 if (record.value.embed.record) {
84 if (record.value.embed.record.uri) {
85 result.quotedUri = record.value.embed.record.uri;
86 } else if (record.value.embed.record.record && record.value.embed.record.record.uri) {
87 result.quotedUri = record.value.embed.record.record.uri;
88 }
89 }
90 }
91 }
92
93 // Handle likes
94 else if (record.collection === 'app.bsky.feed.like' && record.value.subject?.uri) {
95 result = {
96 label: 'Liked',
97 content: record.value.subject.uri.split('/').pop(),
98 subjectUri: record.value.subject.uri,
99 subjectCid: record.value.subject.cid
100 };
101 }
102
103 // Handle reposts
104 else if (record.collection === 'app.bsky.feed.repost' && record.value.subject?.uri) {
105 result = {
106 label: 'Reposted',
107 content: record.value.subject.uri.split('/').pop(),
108 subjectUri: record.value.subject.uri,
109 subjectCid: record.value.subject.cid
110 };
111 }
112
113 // Handle follows
114 else if (record.collection === 'app.bsky.graph.follow' && record.value.subject) {
115 result = {
116 label: 'Followed',
117 content: record.value.subject
118 };
119 }
120
121 // Handle generic subject for other types
122 else if (record.value.subject?.uri) {
123 result = {
124 label: 'Subject',
125 content: record.value.subject.uri.split('/').pop(),
126 subjectUri: record.value.subject.uri,
127 subjectCid: record.value.subject.cid
128 };
129 }
130
131 // If we found content and it has a subject URI for app.bsky collections, add bskyUrl
132 if (result && result.subjectUri && result.subjectUri.includes('/app.bsky.')) {
133 result.bskyUrl = getBskyAppUrl(result.subjectUri);
134 }
135
136 // If the record itself is an app.bsky collection, add selfBskyUrl
137 if (record.collection.startsWith('app.bsky.')) {
138 const selfBskyUrl = getBskyAppUrl(record.uri);
139 if (selfBskyUrl) {
140 result = result || {};
141 result.selfBskyUrl = selfBskyUrl;
142 }
143 }
144
145 // For reply posts, add the parent post URL if possible
146 if (result && result.isReply && result.replyParent) {
147 result.replyParentUrl = getBskyAppUrl(result.replyParent);
148 }
149
150 // For quote posts, add the quoted post URL if possible
151 if (result && result.isQuote && result.quotedUri) {
152 result.quotedUrl = getBskyAppUrl(result.quotedUri);
153 }
154
155 return result;
156 }
157
158 // Fallback: no specific content found
159 return null;
160 };
161
162 // Function to fetch record data
163 const fetchRecordData = async (record) => {
164 try {
165 setModalLoading(true);
166 setModalError(null);
167
168 // Extract the necessary components from the URI
169 const uri = record.uri;
170 const uriParts = uri.split('/');
171 const did = uriParts[2]; // did:plc:xxx part
172 const collection = record.collection;
173 const rkey = record.rkey;
174
175 // Build the API URL
176 const apiUrl = `${serviceEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`;
177
178 // Fetch the record data
179 const response = await fetch(apiUrl);
180
181 if (!response.ok) {
182 throw new Error(`Failed to fetch record: ${response.statusText}`);
183 }
184
185 const data = await response.json();
186 setModalData(data);
187 } catch (error) {
188 console.error('Error fetching record data:', error);
189 setModalError(error.message || 'Failed to fetch record data');
190 } finally {
191 setModalLoading(false);
192 }
193 };
194
195 // Function to handle opening the modal
196 const openModal = (record) => {
197 setSelectedRecord(record);
198 fetchRecordData(record);
199 };
200
201 // Function to close the modal
202 const closeModal = () => {
203 setSelectedRecord(null);
204 setModalData(null);
205 setModalError(null);
206 };
207
208 // Function to fetch related record
209 const fetchRelatedRecord = async (uri) => {
210 try {
211 setModalLoading(true);
212 setModalError(null);
213
214 // Extract components from the URI
215 // Format: at://did:plc:xxx/collection/rkey
216 const uriParts = uri.split('/');
217 const did = uriParts[2]; // did:plc:xxx part
218 const collection = uriParts[3];
219 const rkey = uriParts[4];
220
221 // Build the API URL
222 const apiUrl = `${serviceEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`;
223
224 // Fetch the record data
225 const response = await fetch(apiUrl);
226
227 if (!response.ok) {
228 throw new Error(`Failed to fetch related record: ${response.statusText}`);
229 }
230
231 const data = await response.json();
232 setModalData(data);
233 setSelectedRecord({
234 ...selectedRecord,
235 uri: uri,
236 collection: collection,
237 rkey: rkey
238 });
239 } catch (error) {
240 console.error('Error fetching related record:', error);
241 setModalError(error.message || 'Failed to fetch related record data');
242 } finally {
243 setModalLoading(false);
244 }
245 };
246
247 return (
248 <div className={`feed-timeline ${compactView ? 'compact-view' : ''}`}>
249 {compactView ? (
250 // Compact view - simpler list format
251 <table className="compact-records-table">
252 <thead>
253 <tr>
254 <th className="collection-col">Collection</th>
255 <th className="time-col">Time</th>
256 <th className="type-col">Type</th>
257 <th className="content-col">TID/rkey</th>
258 <th className="actions-col">Actions</th>
259 </tr>
260 </thead>
261 <tbody>
262 {records.map((record, index) => {
263 const content = getRecordContent(record);
264 const timestamp = record.contentTimestamp || record.rkeyTimestamp;
265 const viewUrl = content?.selfBskyUrl || content?.bskyUrl || null;
266
267 return (
268 <tr
269 key={`${record.collection}-${record.rkey}-${index}`}
270 className={record.collection.startsWith('app.bsky.') ? 'bsky-row' : 'atproto-row'}
271 >
272 <td className="collection-col">
273 <span className="compact-collection">{record.collection.split('.').pop()}</span>
274 </td>
275 <td className="time-col">
276 <span className="compact-time">{formatRelativeTime(timestamp)}</span>
277 </td>
278 <td className="type-col">
279 <span className="compact-type">
280 {record.collectionType ? record.collectionType.split('.').pop() : '—'}
281 {content?.isReply && <span className="mini-badge reply-badge">R</span>}
282 {content?.isQuote && <span className="mini-badge quote-badge">Q</span>}
283 </span>
284 </td>
285 <td className="content-col">
286 <span className="compact-content record-key-link" onClick={() => openModal(record)}>
287 {record.rkey}
288 </span>
289 </td>
290 <td className="actions-col">
291 <div className="compact-actions">
292 <button
293 className="compact-view-json"
294 onClick={(e) => {
295 e.stopPropagation();
296 openModal(record);
297 }}
298 title="View JSON"
299 >
300 { }
301 </button>
302 {viewUrl && (
303 <a
304 href={viewUrl}
305 target="_blank"
306 rel="noopener noreferrer"
307 className="compact-bsky-link"
308 title="View on Bluesky"
309 onClick={(e) => e.stopPropagation()}
310 >
311 <svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
312 <path d="M8 0L14.9282 4V12L8 16L1.0718 12V4L8 0Z" fill="#0085ff"/>
313 </svg>
314 </a>
315 )}
316 </div>
317 </td>
318 </tr>
319 );
320 })}
321 </tbody>
322 </table>
323 ) : (
324 // Standard view - original card format
325 records.map((record, index) => {
326 const content = getRecordContent(record);
327
328 return (
329 <div
330 key={`${record.collection}-${record.rkey}-${index}`}
331 className={`feed-item ${record.collection.startsWith('app.bsky.') ? 'bsky-item' : 'atproto-item'}`}
332 >
333 <div className="feed-item-header">
334 <div className="collection-type">
335 <span
336 className={`collection-name ${record.collection.startsWith('app.bsky.') ? 'bsky-collection' : 'atproto-collection'}`}
337 >
338 {record.collection.split('.').pop()}
339 </span>
340 <span className="collection-full">{record.collection}</span>
341 </div>
342 <div
343 className="record-rkey record-key-link"
344 onClick={() => openModal(record)}
345 >
346 {record.rkey}
347 </div>
348 </div>
349
350 <div className="feed-item-content">
351 {record.value && record.value.$type && (
352 <div className="record-type">
353 <span className="type-label">Type:</span> {record.value.$type}
354 {content && content.isReply && (
355 <span className="post-type-badge post-type-reply">Reply</span>
356 )}
357 {content && content.isQuote && (
358 <span className="post-type-badge post-type-quote">Quote</span>
359 )}
360 </div>
361 )}
362
363 {content && (
364 <div className="record-content">
365 <span className="content-label">{content.label}:</span>
366 {content.subjectUri ? (
367 <span
368 className="record-link"
369 onClick={() => fetchRelatedRecord(content.subjectUri)}
370 >
371 {content.content}
372 </span>
373 ) : (
374 <span>{content.content}</span>
375 )}
376
377 {/* Show Bluesky links for either the record itself or its subject */}
378 <div className="bsky-link-container">
379 {content.bskyUrl && (
380 <a
381 href={content.bskyUrl}
382 target="_blank"
383 rel="noopener noreferrer"
384 className="bsky-link"
385 >
386 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
387 <path d="M8 0L14.9282 4V12L8 16L1.0718 12V4L8 0Z" fill="#0085ff"/>
388 </svg>
389 <span className="bsky-link-text">View on Bluesky (Referenced Content)</span>
390 </a>
391 )}
392
393 {content.replyParentUrl && (
394 <a
395 href={content.replyParentUrl}
396 target="_blank"
397 rel="noopener noreferrer"
398 className="bsky-link"
399 >
400 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
401 <path d="M8 0L14.9282 4V12L8 16L1.0718 12V4L8 0Z" fill="#0085ff"/>
402 </svg>
403 <span className="bsky-link-text">View Parent Post on Bluesky</span>
404 </a>
405 )}
406
407 {content.quotedUrl && (
408 <a
409 href={content.quotedUrl}
410 target="_blank"
411 rel="noopener noreferrer"
412 className="bsky-link"
413 >
414 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
415 <path d="M8 0L14.9282 4V12L8 16L1.0718 12V4L8 0Z" fill="#0085ff"/>
416 </svg>
417 <span className="bsky-link-text">View Quoted Post on Bluesky</span>
418 </a>
419 )}
420
421 {content.selfBskyUrl && !content.bskyUrl && !content.replyParentUrl && !content.quotedUrl && (
422 <a
423 href={content.selfBskyUrl}
424 target="_blank"
425 rel="noopener noreferrer"
426 className="bsky-link"
427 >
428 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
429 <path d="M8 0L14.9282 4V12L8 16L1.0718 12V4L8 0Z" fill="#0085ff"/>
430 </svg>
431 <span className="bsky-link-text">View on Bluesky</span>
432 </a>
433 )}
434 </div>
435 </div>
436 )}
437 </div>
438
439 <div className="feed-item-footer">
440 <div className="record-timestamp">
441 {formatRelativeTime(record.contentTimestamp || record.rkeyTimestamp)}
442 </div>
443 </div>
444 </div>
445 );
446 })
447 )}
448
449 {selectedRecord && (
450 <div className="record-modal-backdrop" onClick={closeModal}>
451 <div className="record-modal" onClick={(e) => e.stopPropagation()}>
452 <div className="record-modal-header">
453 <h3 className="record-modal-title">
454 {selectedRecord.collection} / {selectedRecord.rkey}
455 </h3>
456 <button className="record-modal-close" onClick={closeModal}>×</button>
457 </div>
458
459 <div className="record-modal-content">
460 {modalLoading && (
461 <div className="record-modal-loading">
462 <div className="loading-spinner"></div>
463 <span>Loading record data...</span>
464 </div>
465 )}
466
467 {modalError && (
468 <div className="record-modal-error">{modalError}</div>
469 )}
470
471 {modalData && !modalLoading && !modalError && (
472 <div className="record-json-container">
473 <div className="record-json-header">
474 <span>Record Data</span>
475 <button
476 className="copy-json-button"
477 onClick={(event) => {
478 navigator.clipboard.writeText(JSON.stringify(modalData, null, 2));
479 // Show temporary success message
480 const button = event.currentTarget;
481 button.classList.add('copied');
482 setTimeout(() => button.classList.remove('copied'), 2000);
483 }}
484 title="Copy JSON"
485 >
486 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
487 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
488 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
489 </svg>
490 <span>Copy JSON</span>
491 </button>
492 </div>
493 <div className="record-json">
494 {JSON.stringify(modalData, null, 2)}
495 </div>
496 </div>
497 )}
498 </div>
499
500 <div className="record-modal-footer">
501 <div className="record-uri">
502 <span>URI: {selectedRecord.uri}</span>
503 <button
504 className="copy-button"
505 onClick={(event) => {
506 navigator.clipboard.writeText(selectedRecord.uri);
507 // Show temporary success message
508 const button = event.currentTarget;
509 button.classList.add('copied');
510 setTimeout(() => button.classList.remove('copied'), 2000);
511 }}
512 title="Copy URI"
513 >
514 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
515 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
516 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
517 </svg>
518 </button>
519 </div>
520 </div>
521 </div>
522 </div>
523 )}
524 </div>
525 );
526};
527
528export default FeedTimeline;