From 2e1ea95ca5750ac375bdbc3f7fee18fa6b7b06a5 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 6 Jul 2026 10:53:02 -0500 Subject: [PATCH 1/2] docs: reduce login scopes --- .../src/app/oauth-client-metadata.json/route.ts | 15 ++++++++------- packages/docs/src/lib/atproto-oauth.ts | 12 +++++++----- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/docs/src/app/oauth-client-metadata.json/route.ts b/packages/docs/src/app/oauth-client-metadata.json/route.ts index 058e142..5d3ccdd 100644 --- a/packages/docs/src/app/oauth-client-metadata.json/route.ts +++ b/packages/docs/src/app/oauth-client-metadata.json/route.ts @@ -1,18 +1,19 @@ -import { NextRequest } from 'next/server'; +import { NextRequest } from "next/server"; export function GET(request: NextRequest) { const origin = new URL(request.url).origin; return Response.json({ client_id: `${origin}/oauth-client-metadata.json`, - client_name: 'HappyView', + client_name: "HappyView", client_uri: origin, redirect_uris: [`${origin}/oauth/callback`], - grant_types: ['authorization_code'], - response_types: ['code'], - scope: 'atproto transition:generic', - token_endpoint_auth_method: 'none', - application_type: 'web', + grant_types: ["authorization_code"], + response_types: ["code"], + scope: + "atproto repo:site.standard.graph.recommend repo:site.standard.graph.subscription", + token_endpoint_auth_method: "none", + application_type: "web", dpop_bound_access_tokens: true, }); } diff --git a/packages/docs/src/lib/atproto-oauth.ts b/packages/docs/src/lib/atproto-oauth.ts index 83b4c45..b95a300 100644 --- a/packages/docs/src/lib/atproto-oauth.ts +++ b/packages/docs/src/lib/atproto-oauth.ts @@ -1,25 +1,27 @@ -import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; +import { BrowserOAuthClient } from "@atproto/oauth-client-browser"; let clientPromise: Promise | null = null; export function getOAuthClient(): Promise { if (!clientPromise) { const origin = window.location.origin; - const isLoopback = origin.startsWith('http://localhost') || origin.startsWith('http://127.0.0.1'); + const isLoopback = + origin.startsWith("http://localhost") || + origin.startsWith("http://127.0.0.1"); if (isLoopback) { const port = window.location.port; const redirectUri = `http://127.0.0.1:${port}/oauth/callback`; - const clientId = `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent('atproto transition:generic')}`; + const clientId = `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent("atproto repo:site.standard.graph.recommend repo:site.standard.graph.subscription")}`; clientPromise = BrowserOAuthClient.load({ clientId, - handleResolver: 'https://bsky.social', + handleResolver: "https://bsky.social", }); } else { clientPromise = BrowserOAuthClient.load({ clientId: `${origin}/oauth-client-metadata.json`, - handleResolver: 'https://bsky.social', + handleResolver: "https://bsky.social", }); } } -- 2.51.2 From 0a094864d8759961db6e8f26f46cd081a667b14e Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 6 Jul 2026 11:25:48 -0500 Subject: [PATCH 2/2] docs: add custom renderer for comments --- packages/docs/next.config.mts | 5 + packages/docs/sequoia.json | 24 - packages/docs/src/app/blog/[slug]/page.tsx | 18 +- packages/docs/src/app/global.css | 29 +- .../src/components/engagement-actions.tsx | 378 +++--- .../docs/src/components/post-comments.tsx | 565 +++++++++ .../docs/src/components/sequoia-comments.js | 1048 ----------------- .../docs/src/components/sequoia-loader.tsx | 11 - packages/docs/src/custom-elements.d.ts | 13 - packages/docs/src/lib/sequoia.ts | 20 - 10 files changed, 844 insertions(+), 1267 deletions(-) delete mode 100644 packages/docs/sequoia.json create mode 100644 packages/docs/src/components/post-comments.tsx delete mode 100644 packages/docs/src/components/sequoia-comments.js delete mode 100644 packages/docs/src/components/sequoia-loader.tsx delete mode 100644 packages/docs/src/custom-elements.d.ts delete mode 100644 packages/docs/src/lib/sequoia.ts diff --git a/packages/docs/next.config.mts b/packages/docs/next.config.mts index 7d5cb7d..259c551 100644 --- a/packages/docs/next.config.mts +++ b/packages/docs/next.config.mts @@ -4,6 +4,11 @@ import type { NextConfig } from "next"; const config: NextConfig = { allowedDevOrigins: ["127.0.0.1"], transpilePackages: ["@happyview/design-system"], + images: { + remotePatterns: [ + { hostname: "cdn.bsky.app" }, + ], + }, }; const withMDX = createMDX(); diff --git a/packages/docs/sequoia.json b/packages/docs/sequoia.json deleted file mode 100644 index df25d65..0000000 --- a/packages/docs/sequoia.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://sequoia.pub/schema.json", - "siteUrl": "https://happyview.dev", - "pathPrefix": "/blog", - "contentDir": "./content/blog", - "imagesDir": "./public", - "outputDir": "./.next", - "publicDir": "./public", - "publicationUri": "at://did:plc:qneu5uamqs6cug7sjobbaexm/site.standard.publication/3mpokbucn3y26", - "frontmatter": { - "publishDate": "date", - "title": "title", - "description": "description", - "tags": "tags" - }, - "bluesky": { - "enabled": true - }, - "ui": { - "components": "src/components" - }, - "autoSync": true, - "publishContent": true -} diff --git a/packages/docs/src/app/blog/[slug]/page.tsx b/packages/docs/src/app/blog/[slug]/page.tsx index 7e2f3a8..b122374 100644 --- a/packages/docs/src/app/blog/[slug]/page.tsx +++ b/packages/docs/src/app/blog/[slug]/page.tsx @@ -3,9 +3,9 @@ import { notFound } from 'next/navigation'; import defaultMdxComponents from 'fumadocs-ui/mdx'; import { Mermaid } from '@/components/mermaid'; import { EngagementActions } from '@/components/engagement-actions'; -import { SequoiaLoader } from '@/components/sequoia-loader'; +import { PostComments } from '@/components/post-comments'; import { VaporwaveGrid } from '@/components/vaporwave-grid'; -import { getSequoiaPublicationUri } from '@/lib/sequoia'; +const PUBLICATION_URI = 'at://did:plc:qneu5uamqs6cug7sjobbaexm/site.standard.publication/3mpokbucn3y26'; import Image from 'next/image'; export default async function BlogPost(props: { @@ -18,7 +18,7 @@ export default async function BlogPost(props: { const { title, description, date, author, tags, atUri } = page.data; const Mdx = page.data.body; - const publicationUri = getSequoiaPublicationUri(); + const publicationUri = PUBLICATION_URI; return (
@@ -28,7 +28,6 @@ export default async function BlogPost(props: { {atUri && ( )} -

{title}

{description && ( @@ -77,13 +76,20 @@ export default async function BlogPost(props: { )} +
+ +
-
+
- +
+
diff --git a/packages/docs/src/app/global.css b/packages/docs/src/app/global.css index a2cad09..fb652c1 100644 --- a/packages/docs/src/app/global.css +++ b/packages/docs/src/app/global.css @@ -169,17 +169,6 @@ background: rgb(var(--color-bg)) !important; } -/* --- Sequoia comments --- */ - -sequoia-comments { - --sequoia-fg-color: rgb(var(--color-fg)); - --sequoia-bg-color: rgb(var(--color-surface)); - --sequoia-border-color: rgb(var(--color-border)); - --sequoia-accent-color: rgb(var(--color-aqua)); - --sequoia-secondary-color: rgb(var(--color-fg-muted)); - --sequoia-border-radius: 12px; -} - /* --- Blog layout --- */ #nd-home-layout { @@ -247,3 +236,21 @@ figure.shiki { inset 0 0 16px color-mix(in srgb, var(--callout-color) 2%, transparent); border-color: color-mix(in srgb, var(--callout-color) 20%, transparent); } + +/* --- Toast --- */ + +@keyframes toast-in-out { + 0% { transform: translateY(100%); opacity: 0; } + 12% { transform: translateY(0); opacity: 1; } + 85% { transform: translateY(0); opacity: 1; } + 100% { transform: translateY(100%); opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + @keyframes toast-in-out { + 0% { opacity: 0; } + 12% { opacity: 1; } + 85% { opacity: 1; } + 100% { opacity: 0; } + } +} diff --git a/packages/docs/src/components/engagement-actions.tsx b/packages/docs/src/components/engagement-actions.tsx index 8abb09b..dd9375d 100644 --- a/packages/docs/src/components/engagement-actions.tsx +++ b/packages/docs/src/components/engagement-actions.tsx @@ -6,7 +6,7 @@ import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; import { getOAuthClient } from '@/lib/atproto-oauth'; type OAuthSession = Awaited>; -type PendingAction = 'recommend' | 'subscribe'; +type ActionType = 'recommend' | 'subscribe'; interface EngagementActionsProps { documentUri?: string; @@ -16,11 +16,15 @@ interface EngagementActionsProps { export function EngagementActions({ documentUri, publicationUri }: EngagementActionsProps) { const clientRef = useRef(null); const [session, setSession] = useState(null); + const [ready, setReady] = useState(false); const [showLogin, setShowLogin] = useState(false); - const [pendingAction, setPendingAction] = useState(null); + const [pendingAction, setPendingAction] = useState(null); const [recommended, setRecommended] = useState(false); const [subscribed, setSubscribed] = useState(false); - const [loading, setLoading] = useState(null); + const [loading, setLoading] = useState(null); + const [actionError, setActionError] = useState(null); + const [toast, setToast] = useState(null); + const toastTimerRef = useRef>(undefined); useEffect(() => { let cancelled = false; @@ -36,6 +40,8 @@ export function EngagementActions({ documentUri, publicationUri }: EngagementAct } } catch { // OAuth init can fail on localhost — use http://127.0.0.1:PORT instead + } finally { + if (!cancelled) setReady(true); } })(); @@ -44,21 +50,31 @@ export function EngagementActions({ documentUri, publicationUri }: EngagementAct useEffect(() => { if (!session) return; - const agent = new Agent(session); if (documentUri) { checkExistingRecord(agent, session.did, 'site.standard.graph.recommend', documentUri, 'document') .then(setRecommended); } - if (publicationUri) { checkExistingRecord(agent, session.did, 'site.standard.graph.subscription', publicationUri, 'publication') .then(setSubscribed); } }, [session, documentUri, publicationUri]); - const handleAction = useCallback((action: PendingAction) => { + useEffect(() => { + if (!actionError) return; + const timer = setTimeout(() => setActionError(null), 3000); + return () => clearTimeout(timer); + }, [actionError]); + + const showToast = useCallback((message: string) => { + clearTimeout(toastTimerRef.current); + setToast(message); + toastTimerRef.current = setTimeout(() => setToast(null), 3000); + }, []); + + const handleAction = useCallback((action: ActionType) => { if (!session) { setPendingAction(action); setShowLogin(true); @@ -69,8 +85,10 @@ export function EngagementActions({ documentUri, publicationUri }: EngagementAct setRecommended, setSubscribed, setLoading, + setActionError, + showToast, }); - }, [session, documentUri, publicationUri]); + }, [session, documentUri, publicationUri, showToast]); useEffect(() => { if (session && pendingAction) { @@ -82,7 +100,6 @@ export function EngagementActions({ documentUri, publicationUri }: EngagementAct const handleLogin = async (handle: string) => { const client = clientRef.current; if (!client) return; - setShowLogin(false); await client.signInRedirect(handle, { state: window.location.href, @@ -97,174 +114,260 @@ export function EngagementActions({ documentUri, publicationUri }: EngagementAct setSubscribed(false); }; - return ( -
- {documentUri && ( - - )} + if (!ready) { + return ( +
+ {documentUri && } + {publicationUri && } +
+ ); + } - {publicationUri && ( - - )} + const actionLabel = pendingAction === 'subscribe' + ? 'subscribe for updates' + : 'recommend this article'; - {session && ( - - )} + return ( +
+
+ {documentUri && ( + + )} + + {publicationUri && ( + + )} + + {session && ( + + )} +
- {showLogin && ( - { - setShowLogin(false); - setPendingAction(null); - }} - /> +
+
+ { setShowLogin(false); setPendingAction(null); }} + visible={showLogin} + /> +
+
+ + {toast && ( +
+
+ {toast} +
+
)}
); } -function LoginDialog({ +function SkeletonPill() { + return ( +
+ ); +} + +function InlineLogin({ + action, onSubmit, onClose, + visible, }: { + action: string; onSubmit: (handle: string) => void; onClose: () => void; + visible: boolean; }) { const [handle, setHandle] = useState(''); + const [validationError, setValidationError] = useState(''); const inputRef = useRef(null); useEffect(() => { - inputRef.current?.focus(); - }, []); + if (visible) { + const raf = requestAnimationFrame(() => inputRef.current?.focus()); + return () => cancelAnimationFrame(raf); + } + }, [visible]); useEffect(() => { + if (!visible) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [onClose]); + }, [visible, onClose]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const trimmed = handle.trim(); - if (trimmed) onSubmit(trimmed); + if (!trimmed) return; + + if (!trimmed.includes('.') && !trimmed.startsWith('did:')) { + setValidationError('Enter a full handle (e.g. yourname.bsky.social) or a DID.'); + return; + } + + setValidationError(''); + onSubmit(trimmed); }; return ( -
{ - if (e.target === e.currentTarget) onClose(); - }} - > -
-

- Log in with AT Protocol -

-

- Enter your handle to continue. -

-
+
+

+ Sign in with Bluesky to {action}. +

+ +
setHandle(e.target.value)} + onChange={(e) => { + setHandle(e.target.value); + if (validationError) setValidationError(''); + }} placeholder="yourname.bsky.social" - className="rounded-lg px-3 py-2 text-sm outline-none" + tabIndex={visible ? 0 : -1} + className="w-full rounded-lg px-3 py-2 text-sm outline-none" style={{ backgroundColor: 'rgb(var(--color-bg))', color: 'rgb(var(--color-fg))', borderWidth: '1px', - borderColor: 'rgb(var(--color-border))', + borderColor: validationError + ? 'rgb(var(--color-magenta) / 0.5)' + : 'rgb(var(--color-border))', }} + aria-invalid={!!validationError} + aria-describedby={validationError ? 'inline-handle-error' : undefined} /> -
- - -
- -
+ {validationError} +

+ )} +
+ + +
); } @@ -292,13 +395,15 @@ async function checkExistingRecord( async function performAction( session: OAuthSession, - action: PendingAction, + action: ActionType, documentUri: string | undefined, publicationUri: string | undefined, callbacks: { setRecommended: (v: boolean) => void; setSubscribed: (v: boolean) => void; - setLoading: (v: PendingAction | null) => void; + setLoading: (v: ActionType | null) => void; + setActionError: (v: ActionType | null) => void; + showToast: (message: string) => void; }, ) { const agent = new Agent(session); @@ -317,6 +422,7 @@ async function performAction( rkey: existing.split('/').pop()!, }); callbacks.setRecommended(false); + callbacks.showToast('Recommendation removed'); } else { await agent.com.atproto.repo.createRecord({ repo: session.did, @@ -328,6 +434,7 @@ async function performAction( }, }); callbacks.setRecommended(true); + callbacks.showToast('Recommended!'); } } @@ -343,6 +450,7 @@ async function performAction( rkey: existing.split('/').pop()!, }); callbacks.setSubscribed(false); + callbacks.showToast('Unsubscribed'); } else { await agent.com.atproto.repo.createRecord({ repo: session.did, @@ -354,10 +462,12 @@ async function performAction( }, }); callbacks.setSubscribed(true); + callbacks.showToast('Subscribed to updates'); } } } catch (err) { console.error(`Failed to ${action}:`, err); + callbacks.setActionError(action); } finally { callbacks.setLoading(null); } diff --git a/packages/docs/src/components/post-comments.tsx b/packages/docs/src/components/post-comments.tsx new file mode 100644 index 0000000..9336109 --- /dev/null +++ b/packages/docs/src/components/post-comments.tsx @@ -0,0 +1,565 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import Image from 'next/image'; + +interface Reply { + uri: string; + author: { + did: string; + handle: string; + displayName?: string; + avatar?: string; + }; + record: { + text: string; + createdAt: string; + facets?: Facet[]; + }; + likeCount?: number; +} + +interface ParentPost { + uri: string; + author: Reply['author']; + record: Reply['record']; + likeCount: number; + repostCount: number; + replyCount: number; +} + +interface Facet { + index: { byteStart: number; byteEnd: number }; + features: FacetFeature[]; +} + +type FacetFeature = + | { $type: 'app.bsky.richtext.facet#link'; uri: string } + | { $type: 'app.bsky.richtext.facet#mention'; did: string } + | { $type: string }; + +type LoadState = 'loading' | 'loaded' | 'error' | 'no-ref'; + +const PLATFORMS = [ + { key: 'bluesky', name: 'Bluesky', domain: 'bsky.app' }, + { key: 'blacksky', name: 'Blacksky', domain: 'blacksky.app' }, + { key: 'mu', name: 'mu.social', domain: 'mu.social' }, +] as const; + +type PlatformKey = (typeof PLATFORMS)[number]['key']; + +interface PostCommentsProps { + atUri?: string; +} + +export function PostComments({ atUri }: PostCommentsProps) { + const [replies, setReplies] = useState([]); + const [parentPost, setParentPost] = useState(null); + const [postUri, setPostUri] = useState(null); + const [state, setState] = useState('loading'); + + useEffect(() => { + if (!atUri) { + setState('no-ref'); + return; + } + + setState('loading'); + resolveComments(atUri) + .then((data) => { + if (!data.bskyPostRef) { + setState('no-ref'); + return; + } + setPostUri(data.bskyPostRef); + setParentPost(data.parentPost); + setReplies(data.replies); + setState('loaded'); + }) + .catch(() => setState('error')); + }, [atUri]); + + const retry = () => { + setState('loading'); + if (!atUri) return; + resolveComments(atUri) + .then((data) => { + if (!data.bskyPostRef) { + setState('no-ref'); + return; + } + setPostUri(data.bskyPostRef); + setParentPost(data.parentPost); + setReplies(data.replies); + setState('loaded'); + }) + .catch(() => setState('error')); + }; + + if (state === 'no-ref') return null; + + if (state === 'loading') { + return ( +
+
+
+
+ {[1, 2].map((i) => ( +
+ ))} +
+
+ ); + } + + if (state === 'error') { + return ( +
+

+ Comments +

+

+ Comments couldn't be loaded.{' '} + +

+
+ ); + } + + return ( +
+
+

+ Comments +

+ +
+ + {parentPost && } + + {replies.length === 0 && ( +

+ No comments yet. +

+ )} + {replies.length > 0 && ( +
+ {replies.map((reply) => ( + + ))} +
+ )} +
+ ); +} + +function PlatformButtonGroup({ postUri }: { postUri: string }) { + const [platform, setPlatform] = useState('bluesky'); + const [open, setOpen] = useState(false); + const groupRef = useRef(null); + + useEffect(() => { + try { + const saved = localStorage.getItem('preferred-comment-platform'); + if (saved && PLATFORMS.some((p) => p.key === saved)) { + setPlatform(saved as PlatformKey); + } + } catch { + // localStorage unavailable + } + }, []); + + useEffect(() => { + if (!open) return; + const handleClick = (e: MouseEvent) => { + if (!groupRef.current?.contains(e.target as Node)) setOpen(false); + }; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + window.addEventListener('click', handleClick, true); + window.addEventListener('keydown', handleKey); + return () => { + window.removeEventListener('click', handleClick, true); + window.removeEventListener('keydown', handleKey); + }; + }, [open]); + + const current = PLATFORMS.find((p) => p.key === platform)!; + const url = platformPostUrl(postUri, current.domain); + + return ( +
+
+ + + Join the conversation + + +
+ + {open && ( +
+ {PLATFORMS.map((p) => ( + + ))} +
+ )} +
+ ); +} + +const PLATFORM_FAVICONS: Record = { + bluesky: 'https://bsky.app/static/favicon-32x32.png', + blacksky: 'https://blacksky.app/favicon.ico', + mu: 'https://mu.social/favicon.ico', +}; + +function PlatformIcon({ platform }: { platform: PlatformKey }) { + return ( + // eslint-disable-next-line @next/next/no-img-element + + ); +} + +function ParentPostCard({ post }: { post: ParentPost }) { + const displayName = post.author.displayName || post.author.handle; + const date = new Date(post.record.createdAt); + + return ( +
+
+ {post.author.avatar && ( + {displayName} + )} +
+ + {displayName} + + + @{post.author.handle} + +
+ +
+
+ +
+
+ + + + + {post.replyCount} + + + + + + + + + {post.repostCount} + + + + + + {post.likeCount} + +
+
+ ); +} + +function Comment({ reply }: { reply: Reply }) { + const displayName = reply.author.displayName || reply.author.handle; + const date = new Date(reply.record.createdAt); + + return ( +
+
+ {reply.author.avatar && ( + {displayName} + )} + + {displayName} + + + @{reply.author.handle} + + +
+
+ +
+
+ ); +} + +function RichText({ text, facets }: { text: string; facets?: Facet[] }) { + if (!facets || facets.length === 0) return <>{text}; + + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const bytes = encoder.encode(text); + + const sorted = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart); + const parts: React.ReactNode[] = []; + let lastByte = 0; + + for (const facet of sorted) { + if (facet.index.byteStart > lastByte) { + parts.push(decoder.decode(bytes.slice(lastByte, facet.index.byteStart))); + } + + const segment = decoder.decode(bytes.slice(facet.index.byteStart, facet.index.byteEnd)); + const link = facet.features.find((f) => f.$type === 'app.bsky.richtext.facet#link') as + | Extract + | undefined; + const mention = facet.features.find((f) => f.$type === 'app.bsky.richtext.facet#mention') as + | Extract + | undefined; + + if (link) { + parts.push( + + {segment} + , + ); + } else if (mention) { + parts.push( + + {segment} + , + ); + } else { + parts.push(segment); + } + + lastByte = facet.index.byteEnd; + } + + if (lastByte < bytes.length) { + parts.push(decoder.decode(bytes.slice(lastByte))); + } + + return <>{parts}; +} + +async function resolveComments(atUri: string): Promise<{ + bskyPostRef: string | null; + parentPost: ParentPost | null; + replies: Reply[]; +}> { + const match = atUri.match(/^at:\/\/(did:[^/]+)\/([^/]+)\/(.+)$/); + if (!match) throw new Error('Invalid AT URI'); + + const [, did, collection, rkey] = match; + + const plcRes = await fetch(`https://plc.directory/${did}`); + if (!plcRes.ok) throw new Error('DID resolution failed'); + const plcDoc = await plcRes.json(); + + const pdsEndpoint = plcDoc.service?.find( + (s: { id: string; type: string; serviceEndpoint: string }) => s.id === '#atproto_pds', + )?.serviceEndpoint; + if (!pdsEndpoint) throw new Error('No PDS endpoint'); + + const recordRes = await fetch( + `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=${collection}&rkey=${rkey}`, + ); + if (!recordRes.ok) throw new Error('Record fetch failed'); + const recordData = await recordRes.json(); + + const rawRef = recordData.value?.bskyPostRef; + const bskyPostRef = typeof rawRef === 'string' ? rawRef : rawRef?.uri; + if (typeof bskyPostRef !== 'string') return { bskyPostRef: null, parentPost: null, replies: [] }; + + const threadRes = await fetch( + `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(bskyPostRef)}&depth=1`, + ); + if (!threadRes.ok) return { bskyPostRef, parentPost: null, replies: [] }; + const threadData = await threadRes.json(); + + const threadPost = threadData.thread?.post; + const parentPost: ParentPost | null = threadPost + ? { + uri: threadPost.uri, + author: threadPost.author, + record: threadPost.record, + likeCount: threadPost.likeCount ?? 0, + repostCount: threadPost.repostCount ?? 0, + replyCount: threadPost.replyCount ?? 0, + } + : null; + + const replies: Reply[] = (threadData.thread?.replies ?? []) + .filter((r: { $type: string }) => r.$type === 'app.bsky.feed.defs#threadViewPost') + .map((r: { post: { uri: string; author: Reply['author']; record: Reply['record']; likeCount?: number } }) => ({ + uri: r.post.uri, + author: r.post.author, + record: r.post.record, + likeCount: r.post.likeCount, + })) + .sort( + (a: Reply, b: Reply) => + new Date(a.record.createdAt).getTime() - new Date(b.record.createdAt).getTime(), + ); + + return { bskyPostRef, parentPost, replies }; +} + +function platformPostUrl(atUri: string, domain: string): string { + const match = atUri.match(/^at:\/\/(did:[^/]+)\/[^/]+\/(.+)$/); + if (!match) return `https://${domain}`; + return `https://${domain}/profile/${match[1]}/post/${match[2]}`; +} diff --git a/packages/docs/src/components/sequoia-comments.js b/packages/docs/src/components/sequoia-comments.js deleted file mode 100644 index c1490e7..0000000 --- a/packages/docs/src/components/sequoia-comments.js +++ /dev/null @@ -1,1048 +0,0 @@ -/** - * Sequoia Comments - A Bluesky-powered comments component - * - * A self-contained Web Component that displays comments from Bluesky posts - * linked to documents via the AT Protocol. - * - * Usage: - * - * - * The component looks for a document URI in two places: - * 1. The `document-uri` attribute on the element - * 2. A tag in the document head - * - * Custom reply button: - * Place any element with slot="reply-button" to replace the default Bluesky/Blacksky buttons. - * It stays in the light DOM, so your page CSS applies to it normally. - * Only practical with post-uri, since that's the only time the URL is known at authoring time: - * - * Reply - * - * - * Attributes: - * - post-uri: Bluesky post as AT-URI (at://...) or bsky.app URL — skips PDS document lookup - * - document-uri: AT Protocol URI for the document (optional if link tag exists) - * - depth: Maximum depth of nested replies to fetch (default: 6) - * - hide: Set to "auto" to hide if no document link is detected - * - * CSS Custom Properties: - * - --sequoia-fg-color: Text color (default: #1f2937) - * - --sequoia-bg-color: Background color (default: #ffffff) - * - --sequoia-border-color: Border color (default: #e5e7eb) - * - --sequoia-accent-color: Accent/link color (default: #2563eb) - * - --sequoia-secondary-color: Secondary text color (default: #6b7280) - * - --sequoia-font-family: Font family (default: system-ui stack) - * - --sequoia-border-radius: Border radius (default: 8px) - */ - -// ============================================================================ -// Styles -// ============================================================================ - -const styles = ` -:host { - display: block; - font-family: var(--sequoia-font-family, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); - color: var(--sequoia-fg-color, #1f2937); - line-height: 1.5; -} - -* { - box-sizing: border-box; -} - -.sequoia-comments-container { - max-width: 100%; -} - -.sequoia-loading, -.sequoia-error, -.sequoia-empty, -.sequoia-warning { - padding: 1rem; - border-radius: var(--sequoia-border-radius, 8px); - text-align: center; -} - -.sequoia-loading { - background: var(--sequoia-bg-color, #ffffff); - border: 1px solid var(--sequoia-border-color, #e5e7eb); - color: var(--sequoia-secondary-color, #6b7280); -} - -.sequoia-loading-spinner { - display: inline-block; - width: 1.25rem; - height: 1.25rem; - border: 2px solid var(--sequoia-border-color, #e5e7eb); - border-top-color: var(--sequoia-accent-color, #2563eb); - border-radius: 50%; - animation: sequoia-spin 0.8s linear infinite; - margin-right: 0.5rem; - vertical-align: middle; -} - -@keyframes sequoia-spin { - to { transform: rotate(360deg); } -} - -.sequoia-error { - background: #fef2f2; - border: 1px solid #fecaca; - color: #dc2626; -} - -.sequoia-warning { - background: #fffbeb; - border: 1px solid #fde68a; - color: #d97706; -} - -.sequoia-empty { - background: var(--sequoia-bg-color, #ffffff); - border: 1px solid var(--sequoia-border-color, #e5e7eb); - color: var(--sequoia-secondary-color, #6b7280); -} - -.sequoia-comments-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; - padding-bottom: 0.75rem; -} - -.sequoia-comments-title { - font-size: 1.125rem; - font-weight: 600; - margin: 0; -} - -.sequoia-reply-button { - display: inline-flex; - align-items: center; - gap: 0.375rem; - padding: 0.5rem 1rem; - border: none; - border-radius: var(--sequoia-border-radius, 15px); - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - text-decoration: none; - transition: background-color 0.15s ease; - margin-left:10px; -} - -.sequoia-reply-bluesky { - background: var(--sequoia-accent-color, #2563eb); - color: #ffffff; -} - -.sequoia-reply-blacksky { - background: var(--sequoia-accent-color, #6060E9); - color: #ffffff; -} - -.sequoia-reply-bluesky:hover { - background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); -} - -.sequoia-reply-blacksky:hover { - background: color-mix(in srgb, var(--sequoia-accent-color, #5252c3) 85%, black); -} - -.sequoia-reply-button svg { - width: 1rem; - height: 1rem; -} - -.sequoia-comments-list { - display: flex; - flex-direction: column; -} - -.sequoia-thread { - border-top: 1px solid var(--sequoia-border-color, #e5e7eb); - padding-bottom: 1rem; -} - -.sequoia-thread + .sequoia-thread { - margin-top: 0.5rem; -} - -.sequoia-thread:last-child { - border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb); -} - -.sequoia-comment { - display: flex; - gap: 0.75rem; - padding-top: 1rem; -} - -.sequoia-comment-avatar-column { - display: flex; - flex-direction: column; - align-items: center; - flex-shrink: 0; - width: 2.5rem; - position: relative; -} - -.sequoia-comment-avatar { - width: 2.5rem; - height: 2.5rem; - border-radius: 50%; - background: var(--sequoia-border-color, #e5e7eb); - object-fit: cover; - flex-shrink: 0; - position: relative; - z-index: 1; -} - -.sequoia-comment-avatar-placeholder { - width: 2.5rem; - height: 2.5rem; - border-radius: 50%; - background: var(--sequoia-border-color, #e5e7eb); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: var(--sequoia-secondary-color, #6b7280); - font-weight: 600; - font-size: 1rem; - position: relative; - z-index: 1; -} - -.sequoia-thread-line { - position: absolute; - top: 2.5rem; - bottom: calc(-1rem - 0.5rem); - left: 50%; - transform: translateX(-50%); - width: 2px; - background: var(--sequoia-border-color, #e5e7eb); -} - -.sequoia-comment-content { - flex: 1; - min-width: 0; -} - -.sequoia-comment-header { - display: flex; - align-items: baseline; - gap: 0.5rem; - margin-bottom: 0.25rem; - flex-wrap: wrap; -} - -.sequoia-comment-author { - font-weight: 600; - color: var(--sequoia-fg-color, #1f2937); - text-decoration: none; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sequoia-comment-author:hover { - color: var(--sequoia-accent-color, #2563eb); -} - -.sequoia-comment-handle { - font-size: 0.875rem; - color: var(--sequoia-secondary-color, #6b7280); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sequoia-comment-handle::after { - content: "·"; - margin-left: 0.5rem; -} - -.sequoia-comment-time { - font-size: 0.875rem; - color: var(--sequoia-secondary-color, #6b7280); - flex-shrink: 0; -} - -.sequoia-comment-text { - margin: 0; - white-space: pre-wrap; - word-wrap: break-word; -} - -.sequoia-comment-text a { - color: var(--sequoia-accent-color, #2563eb); - text-decoration: none; -} - -.sequoia-comment-text a:hover { - text-decoration: underline; -} - -.sequoia-bsky-logo { - width: 1rem; - height: 1rem; -} - -.sequoia-quotes-section { - margin-top: 1.75rem; -} - -.sequoia-quotes-header { - font-size: 0.75rem; - font-weight: 600; - color: var(--sequoia-secondary-color, #6b7280); - letter-spacing: 0.05em; - text-transform: uppercase; - margin: 0; - padding-bottom: 0.75rem; - border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb); -} - -a.sequoia-comment-time { - text-decoration: none; - color: var(--sequoia-secondary-color, #6b7280); -} - -a.sequoia-comment-time:hover { - text-decoration: underline; -} -`; - -// ============================================================================ -// Utility Functions -// ============================================================================ - -/** - * Format a relative time string (e.g., "2 hours ago") - * @param {string} dateString - ISO date string - * @returns {string} Formatted relative time - */ -function formatRelativeTime(dateString) { - const date = new Date(dateString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffSeconds = Math.floor(diffMs / 1000); - const diffMinutes = Math.floor(diffSeconds / 60); - const diffHours = Math.floor(diffMinutes / 60); - const diffDays = Math.floor(diffHours / 24); - const diffWeeks = Math.floor(diffDays / 7); - const diffMonths = Math.floor(diffDays / 30); - const diffYears = Math.floor(diffDays / 365); - - if (diffSeconds < 60) { - return "just now"; - } - if (diffMinutes < 60) { - return `${diffMinutes}m ago`; - } - if (diffHours < 24) { - return `${diffHours}h ago`; - } - if (diffDays < 7) { - return `${diffDays}d ago`; - } - if (diffWeeks < 4) { - return `${diffWeeks}w ago`; - } - if (diffMonths < 12) { - return `${diffMonths}mo ago`; - } - return `${diffYears}y ago`; -} - -/** - * Escape HTML special characters - * @param {string} text - Text to escape - * @returns {string} Escaped HTML - */ -function escapeHtml(text) { - const div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; -} - -/** - * Convert post text with facets to HTML - * @param {string} text - Post text - * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets - * @returns {string} HTML string with links - */ -function renderTextWithFacets(text, facets) { - if (!facets || facets.length === 0) { - return escapeHtml(text); - } - - // Convert text to bytes for proper indexing - const encoder = new TextEncoder(); - const decoder = new TextDecoder(); - const textBytes = encoder.encode(text); - - // Sort facets by start index - const sortedFacets = [...facets].sort( - (a, b) => a.index.byteStart - b.index.byteStart, - ); - - let result = ""; - let lastEnd = 0; - - for (const facet of sortedFacets) { - const { byteStart, byteEnd } = facet.index; - - // Add text before this facet - if (byteStart > lastEnd) { - const beforeBytes = textBytes.slice(lastEnd, byteStart); - result += escapeHtml(decoder.decode(beforeBytes)); - } - - // Get the facet text - const facetBytes = textBytes.slice(byteStart, byteEnd); - const facetText = decoder.decode(facetBytes); - - // Find the first renderable feature - const feature = facet.features[0]; - if (feature) { - if (feature.$type === "app.bsky.richtext.facet#link") { - result += `${escapeHtml(facetText)}`; - } else if (feature.$type === "app.bsky.richtext.facet#mention") { - result += `${escapeHtml(facetText)}`; - } else if (feature.$type === "app.bsky.richtext.facet#tag") { - result += `${escapeHtml(facetText)}`; - } else { - result += escapeHtml(facetText); - } - } else { - result += escapeHtml(facetText); - } - - lastEnd = byteEnd; - } - - // Add remaining text - if (lastEnd < textBytes.length) { - const remainingBytes = textBytes.slice(lastEnd); - result += escapeHtml(decoder.decode(remainingBytes)); - } - - return result; -} - -/** - * Get initials from a name for avatar placeholder - * @param {string} name - Display name - * @returns {string} Initials (1-2 characters) - */ -function getInitials(name) { - const parts = name.trim().split(/\s+/); - if (parts.length >= 2) { - return (parts[0][0] + parts[1][0]).toUpperCase(); - } - return name.substring(0, 2).toUpperCase(); -} - -// ============================================================================ -// AT Protocol Client Functions -// ============================================================================ - -/** - * Parse an AT URI into its components - * Format: at://did/collection/rkey - * @param {string} atUri - AT Protocol URI - * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null - */ -function parseAtUri(atUri) { - const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!match) return null; - return { - did: match[1], - collection: match[2], - rkey: match[3], - }; -} - -/** - * Resolve a DID to its PDS URL - * Supports did:plc and did:web methods - * @param {string} did - Decentralized Identifier - * @returns {Promise} PDS URL - */ -async function resolvePDS(did) { - let pdsUrl; - - if (did.startsWith("did:plc:")) { - // Fetch DID document from plc.directory - const didDocUrl = `https://plc.directory/${did}`; - const didDocResponse = await fetch(didDocUrl); - if (!didDocResponse.ok) { - throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); - } - const didDoc = await didDocResponse.json(); - - // Find the PDS service endpoint - const pdsService = didDoc.service?.find( - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", - ); - pdsUrl = pdsService?.serviceEndpoint; - } else if (did.startsWith("did:web:")) { - // For did:web, fetch the DID document from the domain - const domain = did.replace("did:web:", ""); - const didDocUrl = `https://${domain}/.well-known/did.json`; - const didDocResponse = await fetch(didDocUrl); - if (!didDocResponse.ok) { - throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); - } - const didDoc = await didDocResponse.json(); - - const pdsService = didDoc.service?.find( - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", - ); - pdsUrl = pdsService?.serviceEndpoint; - } else { - throw new Error(`Unsupported DID method: ${did}`); - } - - if (!pdsUrl) { - throw new Error("Could not find PDS URL for user"); - } - - return pdsUrl; -} - -/** - * Fetch a record from a PDS using the public API - * @param {string} did - DID of the repository owner - * @param {string} collection - Collection name - * @param {string} rkey - Record key - * @returns {Promise} Record value - */ -async function getRecord(did, collection, rkey) { - const pdsUrl = await resolvePDS(did); - - const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`); - url.searchParams.set("repo", did); - url.searchParams.set("collection", collection); - url.searchParams.set("rkey", rkey); - - const response = await fetch(url.toString()); - if (!response.ok) { - throw new Error(`Failed to fetch record: ${response.status}`); - } - - const data = await response.json(); - return data.value; -} - -/** - * Fetch a document record from its AT URI - * @param {string} atUri - AT Protocol URI for the document - * @returns {Promise<{$type: string, title: string, site: string, path: string, textContent: string, publishedAt: string, canonicalUrl?: string, description?: string, tags?: string[], bskyPostRef?: {uri: string, cid: string}}>} Document record - */ -async function getDocument(atUri) { - const parsed = parseAtUri(atUri); - if (!parsed) { - throw new Error(`Invalid AT URI: ${atUri}`); - } - - return getRecord(parsed.did, parsed.collection, parsed.rkey); -} - -/** - * Fetch a post thread from the public Bluesky API - * @param {string} postUri - AT Protocol URI for the post - * @param {number} [depth=6] - Maximum depth of replies to fetch - * @returns {Promise} Thread view post - */ -async function getPostThread(postUri, depth = 6) { - const url = new URL( - "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread", - ); - url.searchParams.set("uri", postUri); - url.searchParams.set("depth", depth.toString()); - - const response = await fetch(url.toString()); - if (!response.ok) { - throw new Error(`Failed to fetch post thread: ${response.status}`); - } - - const data = await response.json(); - - if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { - throw new Error("Post not found or blocked"); - } - - return data.thread; -} - -/** - * Build a Bluesky app URL for a post - * @param {string} postUri - AT Protocol URI for the post - * @returns {string} Bluesky app URL - */ -function buildBskyAppUrl(postUri) { - const parsed = parseAtUri(postUri); - if (!parsed) { - throw new Error(`Invalid post URI: ${postUri}`); - } - - return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; -} - -/** - * Build a Blacksky app URL for a post - * @param {string} postUri - AT Protocol URI for the post - * @returns {string} Blacksky app URL - */ -function buildBlackskyAppUrl(postUri) { - const parsed = parseAtUri(postUri); - if (!parsed) { - throw new Error(`Invalid post URI: ${postUri}`); - } - - return `https://blacksky.community/profile/${parsed.did}/post/${parsed.rkey}`; -} - -/** - * Type guard for ThreadViewPost - * @param {any} post - Post to check - * @returns {boolean} True if post is a ThreadViewPost - */ -function isThreadViewPost(post) { - return post?.$type === "app.bsky.feed.defs#threadViewPost"; -} - -/** - * Fetch all quote posts for a given post URI, paginating through all results. - * Uses the public Bluesky AppView — gaps are expected for posts from - * less-connected PDS instances. - * @param {string} postUri - AT Protocol URI for the post - * @returns {Promise} Array of PostView objects - */ -/** - * Normalise a user-supplied post reference to an AT-URI. - * Accepts: - * - AT-URIs as-is: at://did:plc:.../app.bsky.feed.post/rkey - * - bsky.app post URLs: https://bsky.app/profile//post/ - * When the profile segment is already a DID no network request is made. - * @param {string} uriOrUrl - * @returns {Promise} AT-URI - */ -async function resolvePostUri(uriOrUrl) { - if (uriOrUrl.startsWith("at://")) return uriOrUrl; - - const match = uriOrUrl.match( - /bsky\.app\/profile\/([^/?#]+)\/post\/([^/?#]+)/, - ); - if (!match) throw new Error(`Cannot parse Bluesky URL: ${uriOrUrl}`); - - const [, handleOrDid, rkey] = match; - - let did = handleOrDid; - if (!handleOrDid.startsWith("did:")) { - const url = new URL( - "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle", - ); - url.searchParams.set("handle", handleOrDid); - const response = await fetch(url.toString()); - if (!response.ok) - throw new Error(`Failed to resolve handle: ${response.status}`); - did = (await response.json()).did; - } - - return `at://${did}/app.bsky.feed.post/${rkey}`; -} - -async function getQuotes(postUri) { - const quotes = []; - let cursor; - - do { - const url = new URL( - "https://public.api.bsky.app/xrpc/app.bsky.feed.getQuotes", - ); - url.searchParams.set("uri", postUri); - url.searchParams.set("limit", "100"); - if (cursor) url.searchParams.set("cursor", cursor); - - const response = await fetch(url.toString()); - if (!response.ok) { - throw new Error(`Failed to fetch quotes: ${response.status}`); - } - - const data = await response.json(); - quotes.push(...(data.posts ?? [])); - cursor = data.cursor; - } while (cursor); - - return quotes; -} - -// ============================================================================ -// Bluesky Icon -// ============================================================================ - -const BLUESKY_ICON = ``; -const BLACKSKY_ICON = - ''; - -// ============================================================================ -// Web Component -// ============================================================================ - -// SSR-safe base class - use HTMLElement in browser, empty class in Node.js -const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; - -class SequoiaComments extends BaseElement { - constructor() { - super(); - const shadow = this.attachShadow({ mode: "open" }); - - const styleTag = document.createElement("style"); - shadow.appendChild(styleTag); - styleTag.innerText = styles; - - const container = document.createElement("div"); - shadow.appendChild(container); - container.className = "sequoia-comments-container"; - container.part = "container"; - - this.commentsContainer = container; - this.state = { type: "loading" }; - this.abortController = null; - } - - static get observedAttributes() { - return ["post-uri", "document-uri", "depth", "hide"]; - } - - connectedCallback() { - this.initialized = true; - this.render(); - this.loadComments(); - } - - disconnectedCallback() { - this.abortController?.abort(); - } - - attributeChangedCallback() { - // attributeChangedCallback fires for pre-existing attributes during - // element upgrade, *before* connectedCallback — skip until we've done - // the initial load, otherwise every attribute triggers a duplicate fetch. - if (this.initialized) { - this.loadComments(); - } - } - - get documentUri() { - // First check attribute - const attrUri = this.getAttribute("document-uri"); - if (attrUri) { - return attrUri; - } - - // Then scan for link tag in document head - const linkTag = document.querySelector( - 'link[rel="site.standard.document"]', - ); - return linkTag?.href ?? null; - } - - get depth() { - const depthAttr = this.getAttribute("depth"); - return depthAttr ? parseInt(depthAttr, 10) : 6; - } - - get hide() { - const hideAttr = this.getAttribute("hide"); - return hideAttr === "auto"; - } - - async loadComments() { - // Cancel any in-flight request - this.abortController?.abort(); - this.abortController = new AbortController(); - - this.state = { type: "loading" }; - this.render(); - - try { - // Resolve the post URI — either directly from the attribute or via the - // document record (which requires a PDS roundtrip) - const rawPostUri = this.getAttribute("post-uri"); - let postUri = rawPostUri ? await resolvePostUri(rawPostUri) : null; - if (!postUri) { - const docUri = this.documentUri; - if (!docUri) { - this.state = { type: "no-document" }; - this.render(); - return; - } - - const document = await getDocument(docUri); - if (!document.bskyPostRef) { - this.state = { type: "no-comments-enabled" }; - this.render(); - return; - } - - postUri = document.bskyPostRef.uri; - } - - const postUrl = buildBskyAppUrl(postUri); - const blackskyPostUrl = buildBlackskyAppUrl(postUri); - - // Fetch thread and quotes in parallel; quote failures degrade gracefully - const [threadResult, quotesResult] = await Promise.allSettled([ - getPostThread(postUri, this.depth), - getQuotes(postUri), - ]); - - if (threadResult.status === "rejected") { - throw threadResult.reason; - } - - const thread = threadResult.value; - const quotes = - quotesResult.status === "fulfilled" ? quotesResult.value : []; - - const replies = thread.replies?.filter(isThreadViewPost) ?? []; - if (replies.length === 0 && quotes.length === 0) { - this.state = { type: "empty", postUrl, blackskyPostUrl }; - this.render(); - return; - } - - this.state = { type: "loaded", thread, quotes, postUrl, blackskyPostUrl }; - this.render(); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to load comments"; - this.state = { type: "error", message }; - this.render(); - } - } - - render() { - switch (this.state.type) { - case "loading": - this.commentsContainer.innerHTML = ` -
- - Loading comments... -
- `; - break; - - case "no-document": - this.commentsContainer.innerHTML = ` -
- No document found. Add a <link rel="site.standard.document" href="at://..."> tag to your page. -
- `; - if (this.hide) { - this.commentsContainer.style.display = "none"; - } - break; - - case "no-comments-enabled": - this.commentsContainer.innerHTML = ` -
- Comments are not enabled for this post. -
- `; - break; - - case "empty": - this.commentsContainer.innerHTML = ` -
-

Comments

-
${this.renderReplyButtons(this.state.postUrl, this.state.blackskyPostUrl)}
-
-
- No comments yet. Be the first to reply on Bluesky! -
- `; - break; - - case "error": - this.commentsContainer.innerHTML = ` -
- Failed to load comments: ${escapeHtml(this.state.message)} -
- `; - break; - - case "loaded": { - const replies = - this.state.thread.replies?.filter(isThreadViewPost) ?? []; - const quotes = this.state.quotes ?? []; - const threadsHtml = replies - .map((reply) => this.renderThread(reply)) - .join(""); - const commentCount = this.countComments(replies); - const titleText = - commentCount > 0 - ? `${commentCount} Comment${commentCount !== 1 ? "s" : ""}` - : "Comments"; - const quotesHtml = this.renderQuotesSection(quotes); - - this.commentsContainer.innerHTML = ` -
-

${titleText}

-
${this.renderReplyButtons(this.state.postUrl, this.state.blackskyPostUrl)}
-
-
- ${threadsHtml} -
- ${quotesHtml} - `; - break; - } - } - } - - /** - * Flatten a thread into a linear list of comments - * @param {ThreadViewPost} thread - Thread to flatten - * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments - */ - flattenThread(thread) { - const result = []; - const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; - - result.push({ - post: thread.post, - hasMoreReplies: nestedReplies.length > 0, - }); - - // Recursively flatten nested replies - for (const reply of nestedReplies) { - result.push(...this.flattenThread(reply)); - } - - return result; - } - - /** - * Render the reply-button slot. Any element with slot="reply-button" in the - * light DOM is projected here and remains styleable by external CSS. - * The default Bluesky/Blacksky buttons are used as fallback content. - */ - renderReplyButtons(postUrl, blackskyPostUrl) { - return ` - - - ${BLUESKY_ICON} - - - ${BLACKSKY_ICON} - - - `; - } - - /** - * Render a complete thread (top-level comment + all nested replies) - */ - renderThread(thread) { - const flatComments = this.flattenThread(thread); - const commentsHtml = flatComments - .map((item, index) => - this.renderComment(item.post, item.hasMoreReplies, index), - ) - .join(""); - - return `
${commentsHtml}
`; - } - - /** - * Render a section of quote posts below the replies - * @param {Array} quotes - Array of PostView objects from getQuotes - */ - renderQuotesSection(quotes) { - if (quotes.length === 0) return ""; - - const quotesHtml = quotes - .map((post) => { - return `
${this.renderComment(post, false, 0)}
`; - }) - .join(""); - - return ` -
-

Quotes (${quotes.length})

-
- ${quotesHtml} -
-
- `; - } - - /** - * Render a single comment - * @param {any} post - Post data - * @param {boolean} showThreadLine - Whether to show the connecting thread line - * @param {number} _index - Index in the flattened thread (0 = top-level) - */ - renderComment(post, showThreadLine = false, _index = 0) { - const author = post.author; - const displayName = author.displayName || author.handle; - const avatarHtml = author.avatar - ? `${escapeHtml(displayName)}` - : `
${getInitials(displayName)}
`; - - const profileUrl = `https://bsky.app/profile/${author.did}`; - const textHtml = renderTextWithFacets(post.record.text, post.record.facets); - const timeAgo = formatRelativeTime(post.record.createdAt); - const timeHtml = `${timeAgo}`; - const threadLineHtml = showThreadLine - ? '
' - : ""; - - return ` -
-
- ${avatarHtml} - ${threadLineHtml} -
-
-
- - ${escapeHtml(displayName)} - - @${escapeHtml(author.handle)} - ${timeHtml} -
-

${textHtml}

-
-
- `; - } - - countComments(replies) { - let count = 0; - for (const reply of replies) { - count += 1; - const nested = reply.replies?.filter(isThreadViewPost) ?? []; - count += this.countComments(nested); - } - return count; - } -} - -// Register the custom element -if (typeof customElements !== "undefined") { - customElements.define("sequoia-comments", SequoiaComments); -} - -// Export for module usage -export { SequoiaComments }; diff --git a/packages/docs/src/components/sequoia-loader.tsx b/packages/docs/src/components/sequoia-loader.tsx deleted file mode 100644 index 15fad0c..0000000 --- a/packages/docs/src/components/sequoia-loader.tsx +++ /dev/null @@ -1,11 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; - -export function SequoiaLoader() { - useEffect(() => { - import('./sequoia-comments.js'); - }, []); - - return null; -} diff --git a/packages/docs/src/custom-elements.d.ts b/packages/docs/src/custom-elements.d.ts deleted file mode 100644 index 76bafbe..0000000 --- a/packages/docs/src/custom-elements.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -declare namespace React.JSX { - interface IntrinsicElements { - 'sequoia-comments': React.DetailedHTMLProps< - React.HTMLAttributes & { - 'document-uri'?: string; - 'post-uri'?: string; - depth?: string | number; - hide?: string; - }, - HTMLElement - >; - } -} diff --git a/packages/docs/src/lib/sequoia.ts b/packages/docs/src/lib/sequoia.ts deleted file mode 100644 index c64549d..0000000 --- a/packages/docs/src/lib/sequoia.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -let cachedUri: string | undefined; -let loaded = false; - -export function getSequoiaPublicationUri(): string | undefined { - if (loaded) return cachedUri; - loaded = true; - - try { - const configPath = resolve(process.cwd(), 'sequoia.json'); - const config = JSON.parse(readFileSync(configPath, 'utf-8')); - cachedUri = config.publicationUri || undefined; - } catch { - cachedUri = undefined; - } - - return cachedUri; -} -- 2.51.2