add quote posts!
582de0b7
1 file(s) · +109 −8
| 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> |
|