A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
57
fork

Configure Feed

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

add quote posts!

authored by

Pascal Hertleif and committed by
Tangled
582de0b7 97bd8773

+109 -8
+109 -8
packages/cli/src/components/sequoia-comments.js
··· 280 280 width: 1rem; 281 281 height: 1rem; 282 282 } 283 + 284 + .sequoia-quotes-section { 285 + margin-top: 1.75rem; 286 + } 287 + 288 + .sequoia-quotes-header { 289 + font-size: 0.75rem; 290 + font-weight: 600; 291 + color: var(--sequoia-secondary-color, #6b7280); 292 + letter-spacing: 0.05em; 293 + text-transform: uppercase; 294 + margin: 0; 295 + padding-bottom: 0.75rem; 296 + border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb); 297 + } 298 + 299 + a.sequoia-comment-time { 300 + text-decoration: none; 301 + color: var(--sequoia-secondary-color, #6b7280); 302 + } 303 + 304 + a.sequoia-comment-time:hover { 305 + text-decoration: underline; 306 + } 283 307 `; 284 308 285 309 // ============================================================================ ··· 583 607 return post?.$type === "app.bsky.feed.defs#threadViewPost"; 584 608 } 585 609 610 + /** 611 + * Fetch all quote posts for a given post URI, paginating through all results. 612 + * Uses the public Bluesky AppView — gaps are expected for posts from 613 + * less-connected PDS instances. 614 + * @param {string} postUri - AT Protocol URI for the post 615 + * @returns {Promise<Array>} Array of PostView objects 616 + */ 617 + async function getQuotes(postUri) { 618 + const quotes = []; 619 + let cursor; 620 + 621 + do { 622 + const url = new URL( 623 + "https://public.api.bsky.app/xrpc/app.bsky.feed.getQuotes", 624 + ); 625 + url.searchParams.set("uri", postUri); 626 + url.searchParams.set("limit", "100"); 627 + if (cursor) url.searchParams.set("cursor", cursor); 628 + 629 + const response = await fetch(url.toString()); 630 + if (!response.ok) { 631 + throw new Error(`Failed to fetch quotes: ${response.status}`); 632 + } 633 + 634 + const data = await response.json(); 635 + quotes.push(...(data.posts ?? [])); 636 + cursor = data.cursor; 637 + } while (cursor); 638 + 639 + return quotes; 640 + } 641 + 586 642 // ============================================================================ 587 643 // Bluesky Icon 588 644 // ============================================================================ ··· 691 747 const postUrl = buildBskyAppUrl(document.bskyPostRef.uri); 692 748 const blackskyPostUrl = buildBlackskyAppUrl(document.bskyPostRef.uri); 693 749 694 - // Fetch the post thread 695 - const thread = await getPostThread(document.bskyPostRef.uri, this.depth); 750 + // Fetch thread and quotes in parallel; quote failures degrade gracefully 751 + const [threadResult, quotesResult] = await Promise.allSettled([ 752 + getPostThread(document.bskyPostRef.uri, this.depth), 753 + getQuotes(document.bskyPostRef.uri), 754 + ]); 755 + 756 + if (threadResult.status === "rejected") { 757 + throw threadResult.reason; 758 + } 759 + 760 + const thread = threadResult.value; 761 + const quotes = 762 + quotesResult.status === "fulfilled" ? quotesResult.value : []; 696 763 697 - // Check if there are any replies 698 764 const replies = thread.replies?.filter(isThreadViewPost) ?? []; 699 - if (replies.length === 0) { 765 + if (replies.length === 0 && quotes.length === 0) { 700 766 this.state = { type: "empty", postUrl, blackskyPostUrl }; 701 767 this.render(); 702 768 return; 703 769 } 704 770 705 - this.state = { type: "loaded", thread, postUrl, blackskyPostUrl }; 771 + this.state = { type: "loaded", thread, quotes, postUrl, blackskyPostUrl }; 706 772 this.render(); 707 773 } catch (error) { 708 774 const message = ··· 772 838 case "loaded": { 773 839 const replies = 774 840 this.state.thread.replies?.filter(isThreadViewPost) ?? []; 841 + const quotes = this.state.quotes ?? []; 775 842 const threadsHtml = replies 776 843 .map((reply) => this.renderThread(reply)) 777 844 .join(""); 778 845 const commentCount = this.countComments(replies); 846 + const titleText = 847 + commentCount > 0 848 + ? `${commentCount} Comment${commentCount !== 1 ? "s" : ""}` 849 + : "Comments"; 850 + const quotesHtml = this.renderQuotesSection(quotes); 779 851 780 852 this.commentsContainer.innerHTML = ` 781 853 <div class="sequoia-comments-header"> 782 - <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3> 854 + <h3 class="sequoia-comments-title">${titleText}</h3> 783 855 <div> 784 856 <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky"> 785 857 ${BLUESKY_ICON} ··· 792 864 <div class="sequoia-comments-list"> 793 865 ${threadsHtml} 794 866 </div> 867 + ${quotesHtml} 795 868 `; 796 869 break; 797 870 } ··· 835 908 } 836 909 837 910 /** 911 + * Render a section of quote posts below the replies 912 + * @param {Array} quotes - Array of PostView objects from getQuotes 913 + */ 914 + renderQuotesSection(quotes) { 915 + if (quotes.length === 0) return ""; 916 + 917 + const quotesHtml = quotes 918 + .map((post) => { 919 + const quotePostUrl = buildBskyAppUrl(post.uri); 920 + return `<div class="sequoia-thread">${this.renderComment(post, false, 0, quotePostUrl)}</div>`; 921 + }) 922 + .join(""); 923 + 924 + return ` 925 + <div class="sequoia-quotes-section"> 926 + <h4 class="sequoia-quotes-header">Quotes (${quotes.length})</h4> 927 + <div class="sequoia-comments-list"> 928 + ${quotesHtml} 929 + </div> 930 + </div> 931 + `; 932 + } 933 + 934 + /** 838 935 * Render a single comment 839 936 * @param {any} post - Post data 840 937 * @param {boolean} showThreadLine - Whether to show the connecting thread line 841 938 * @param {number} _index - Index in the flattened thread (0 = top-level) 939 + * @param {string|null} postUrl - Optional URL to link the timestamp to (used for quote posts) 842 940 */ 843 - renderComment(post, showThreadLine = false, _index = 0) { 941 + renderComment(post, showThreadLine = false, _index = 0, postUrl = null) { 844 942 const author = post.author; 845 943 const displayName = author.displayName || author.handle; 846 944 const avatarHtml = author.avatar ··· 850 948 const profileUrl = `https://bsky.app/profile/${author.did}`; 851 949 const textHtml = renderTextWithFacets(post.record.text, post.record.facets); 852 950 const timeAgo = formatRelativeTime(post.record.createdAt); 951 + const timeHtml = postUrl 952 + ? `<a href="${escapeHtml(postUrl)}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-time">${timeAgo}</a>` 953 + : `<span class="sequoia-comment-time">${timeAgo}</span>`; 853 954 const threadLineHtml = showThreadLine 854 955 ? '<div class="sequoia-thread-line"></div>' 855 956 : ""; ··· 866 967 ${escapeHtml(displayName)} 867 968 </a> 868 969 <span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span> 869 - <span class="sequoia-comment-time">${timeAgo}</span> 970 + ${timeHtml} 870 971 </div> 871 972 <p class="sequoia-comment-text">${textHtml}</p> 872 973 </div>