This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 21 kB View raw
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;