add quote posts! 582de0b7
Pascal Hertleif · 2026-04-22 13:49 1 file(s) · +109 −8
packages/cli/src/components/sequoia-comments.js +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>