diff --git a/.gitignore b/.gitignore index ea69175..74e21d7 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,3 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Bun lockfile - keep but binary cache bun.lockb -packages/ui diff --git a/packages/ui/.gitignore b/packages/ui/.gitignore new file mode 100644 index 0000000..72a7db1 --- /dev/null +++ b/packages/ui/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +test-site/ diff --git a/packages/ui/biome.json b/packages/ui/biome.json new file mode 100644 index 0000000..80098a8 --- /dev/null +++ b/packages/ui/biome.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!!**/dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noNonNullAssertion": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..8995ff4 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,28 @@ +{ + "name": "sequoia-ui", + "version": "0.1.0", + "type": "module", + "files": [ + "dist", + "README.md" + ], + "main": "./dist/index.js", + "exports": { + ".": "./dist/index.js", + "./comments": "./dist/index.js" + }, + "scripts": { + "lint": "biome lint --write", + "format": "biome format --write", + "build": "bun build src/index.ts --outdir dist --target browser && bun build src/index.ts --outfile dist/sequoia-comments.iife.js --target browser --format iife --minify", + "dev": "bun run build", + "deploy": "bun run build && bun publish --access public" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.13", + "@types/node": "^20" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/packages/ui/src/components/sequoia-comments/index.ts b/packages/ui/src/components/sequoia-comments/index.ts new file mode 100644 index 0000000..2668d70 --- /dev/null +++ b/packages/ui/src/components/sequoia-comments/index.ts @@ -0,0 +1,11 @@ +import { SequoiaComments } from "./sequoia-comments"; + +// Register the custom element if not already registered +if ( + typeof customElements !== "undefined" && + !customElements.get("sequoia-comments") +) { + customElements.define("sequoia-comments", SequoiaComments); +} + +export { SequoiaComments }; diff --git a/packages/ui/src/components/sequoia-comments/sequoia-comments.ts b/packages/ui/src/components/sequoia-comments/sequoia-comments.ts new file mode 100644 index 0000000..6fb4b08 --- /dev/null +++ b/packages/ui/src/components/sequoia-comments/sequoia-comments.ts @@ -0,0 +1,270 @@ +import { + buildBskyAppUrl, + getDocument, + getPostThread, +} from "../../lib/atproto-client"; +import type { ThreadViewPost } from "../../types/bluesky"; +import { isThreadViewPost } from "../../types/bluesky"; +import { styles } from "./styles"; +import { formatRelativeTime, getInitials, renderTextWithFacets } from "./utils"; + +/** + * Component state + */ +type State = + | { type: "loading" } + | { type: "loaded"; thread: ThreadViewPost; postUrl: string } + | { type: "no-document" } + | { type: "no-comments-enabled" } + | { type: "empty"; postUrl: string } + | { type: "error"; message: string }; + +/** + * Bluesky butterfly SVG icon + */ +const BLUESKY_ICON = ``; + +export class SequoiaComments extends HTMLElement { + private shadow: ShadowRoot; + private state: State = { type: "loading" }; + private abortController: AbortController | null = null; + + static get observedAttributes(): string[] { + return ["document-uri", "depth"]; + } + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback(): void { + this.render(); + this.loadComments(); + } + + disconnectedCallback(): void { + this.abortController?.abort(); + } + + attributeChangedCallback(): void { + if (this.isConnected) { + this.loadComments(); + } + } + + private get documentUri(): string | null { + // 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; + } + + private get depth(): number { + const depthAttr = this.getAttribute("depth"); + return depthAttr ? Number.parseInt(depthAttr, 10) : 6; + } + + private async loadComments(): Promise { + // Cancel any in-flight request + this.abortController?.abort(); + this.abortController = new AbortController(); + + this.state = { type: "loading" }; + this.render(); + + const docUri = this.documentUri; + if (!docUri) { + this.state = { type: "no-document" }; + this.render(); + return; + } + + try { + // Fetch the document record + const document = await getDocument(docUri); + + // Check if document has a Bluesky post reference + if (!document.bskyPostRef) { + this.state = { type: "no-comments-enabled" }; + this.render(); + return; + } + + const postUrl = buildBskyAppUrl(document.bskyPostRef.uri); + + // Fetch the post thread + const thread = await getPostThread(document.bskyPostRef.uri, this.depth); + + // Check if there are any replies + const replies = thread.replies?.filter(isThreadViewPost) ?? []; + if (replies.length === 0) { + this.state = { type: "empty", postUrl }; + this.render(); + return; + } + + this.state = { type: "loaded", thread, postUrl }; + this.render(); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to load comments"; + this.state = { type: "error", message }; + this.render(); + } + } + + private render(): void { + const styleTag = ``; + + switch (this.state.type) { + case "loading": + this.shadow.innerHTML = ` + ${styleTag} +
+
+ + Loading comments... +
+
+ `; + break; + + case "no-document": + this.shadow.innerHTML = ` + ${styleTag} +
+
+ No document found. Add a <link rel="site.standard.document" href="at://..."> tag to your page. +
+
+ `; + break; + + case "no-comments-enabled": + this.shadow.innerHTML = ` + ${styleTag} +
+
+ Comments are not enabled for this post. +
+
+ `; + break; + + case "empty": + this.shadow.innerHTML = ` + ${styleTag} +
+ +
+ No comments yet. Be the first to reply on Bluesky! +
+
+ `; + break; + + case "error": + this.shadow.innerHTML = ` + ${styleTag} +
+
+ Failed to load comments: ${this.escapeHtml(this.state.message)} +
+
+ `; + break; + + case "loaded": { + const replies = this.state.thread.replies?.filter(isThreadViewPost) ?? []; + const commentsHtml = replies.map((reply) => this.renderComment(reply)).join(""); + const commentCount = this.countComments(replies); + + this.shadow.innerHTML = ` + ${styleTag} +
+
+

${commentCount} Comment${commentCount !== 1 ? "s" : ""}

+ + ${BLUESKY_ICON} + Reply on Bluesky + +
+
+ ${commentsHtml} +
+
+ `; + break; + } + } + } + + private renderComment(thread: ThreadViewPost): string { + const { post } = thread; + const author = post.author; + const displayName = author.displayName || author.handle; + const avatarHtml = author.avatar + ? `${this.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); + + // Render nested replies + const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; + const repliesHtml = + nestedReplies.length > 0 + ? `
${nestedReplies.map((r) => this.renderComment(r)).join("")}
` + : ""; + + return ` +
+
+ ${avatarHtml} +
+ + ${this.escapeHtml(displayName)} + + @${this.escapeHtml(author.handle)} +
+ ${timeAgo} +
+

${textHtml}

+ ${repliesHtml} +
+ `; + } + + private countComments(replies: ThreadViewPost[]): number { + let count = 0; + for (const reply of replies) { + count += 1; + const nested = reply.replies?.filter(isThreadViewPost) ?? []; + count += this.countComments(nested); + } + return count; + } + + private escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } +} diff --git a/packages/ui/src/components/sequoia-comments/styles.ts b/packages/ui/src/components/sequoia-comments/styles.ts new file mode 100644 index 0000000..dd7098b --- /dev/null +++ b/packages/ui/src/components/sequoia-comments/styles.ts @@ -0,0 +1,218 @@ +export const styles = ` +:host { + display: block; + 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; + border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb); +} + +.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; + background: var(--sequoia-accent-color, #2563eb); + color: #ffffff; + border: none; + border-radius: var(--sequoia-border-radius, 8px); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + text-decoration: none; + transition: background-color 0.15s ease; +} + +.sequoia-reply-button:hover { + background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); +} + +.sequoia-reply-button svg { + width: 1rem; + height: 1rem; +} + +.sequoia-comments-list { + display: flex; + flex-direction: column; + gap: 0; +} + +.sequoia-comment { + padding: 1rem; + background: var(--sequoia-bg-color, #ffffff); + border: 1px solid var(--sequoia-border-color, #e5e7eb); + border-radius: var(--sequoia-border-radius, 8px); + margin-bottom: 0.75rem; +} + +.sequoia-comment-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.sequoia-comment-avatar { + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + background: var(--sequoia-border-color, #e5e7eb); + object-fit: cover; + flex-shrink: 0; +} + +.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; +} + +.sequoia-comment-meta { + display: flex; + flex-direction: column; + min-width: 0; +} + +.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-time { + font-size: 0.75rem; + color: var(--sequoia-secondary-color, #6b7280); + margin-left: auto; + 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-comment-replies { + margin-top: 0.75rem; + margin-left: 1.5rem; + padding-left: 1rem; + border-left: 2px solid var(--sequoia-border-color, #e5e7eb); +} + +.sequoia-comment-replies .sequoia-comment { + margin-bottom: 0.5rem; +} + +.sequoia-comment-replies .sequoia-comment:last-child { + margin-bottom: 0; +} + +.sequoia-bsky-logo { + width: 1rem; + height: 1rem; +} +`; diff --git a/packages/ui/src/components/sequoia-comments/utils.ts b/packages/ui/src/components/sequoia-comments/utils.ts new file mode 100644 index 0000000..7aae3e4 --- /dev/null +++ b/packages/ui/src/components/sequoia-comments/utils.ts @@ -0,0 +1,127 @@ +/** + * Format a relative time string (e.g., "2 hours ago") + */ +export function formatRelativeTime(dateString: string): string { + 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 + */ +export function escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +/** + * Convert post text with facets to HTML + */ +export function renderTextWithFacets( + text: string, + facets?: Array<{ + index: { byteStart: number; byteEnd: number }; + features: Array< + | { $type: "app.bsky.richtext.facet#link"; uri: string } + | { $type: "app.bsky.richtext.facet#mention"; did: string } + | { $type: "app.bsky.richtext.facet#tag"; tag: string } + >; + }>, +): string { + 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 + */ +export function getInitials(name: string): string { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) { + return (parts[0]![0]! + parts[1]![0]!).toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 0000000..8a7a485 --- /dev/null +++ b/packages/ui/src/index.ts @@ -0,0 +1,26 @@ +// Components +export { SequoiaComments } from "./components/sequoia-comments"; + +// AT Protocol client utilities +export { + parseAtUri, + resolvePDS, + getRecord, + getDocument, + getPostThread, + buildBskyAppUrl, +} from "./lib/atproto-client"; + +// Types +export type { + StrongRef, + ProfileViewBasic, + PostRecord, + PostView, + ThreadViewPost, + BlockedPost, + NotFoundPost, + DocumentRecord, +} from "./types/bluesky"; + +export { isThreadViewPost } from "./types/bluesky"; diff --git a/packages/ui/src/lib/atproto-client.ts b/packages/ui/src/lib/atproto-client.ts new file mode 100644 index 0000000..7297ed0 --- /dev/null +++ b/packages/ui/src/lib/atproto-client.ts @@ -0,0 +1,144 @@ +import type { + DIDDocument, + DocumentRecord, + GetPostThreadResponse, + GetRecordResponse, + ThreadViewPost, +} from "../types/bluesky"; + +/** + * Parse an AT URI into its components + * Format: at://did/collection/rkey + */ +export function parseAtUri( + atUri: string, +): { did: string; collection: string; rkey: string } | null { + 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 + */ +export async function resolvePDS(did: string): Promise { + let pdsUrl: string | undefined; + + 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: DIDDocument = 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: DIDDocument = 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 + */ +export async function getRecord( + did: string, + collection: string, + rkey: string, +): Promise { + 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: GetRecordResponse = await response.json(); + return data.value; +} + +/** + * Fetch a document record from its AT URI + */ +export async function getDocument(atUri: string): Promise { + 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 + */ +export async function getPostThread( + postUri: string, + depth = 6, +): Promise { + 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: GetPostThreadResponse = await response.json(); + + if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { + throw new Error("Post not found or blocked"); + } + + return data.thread as ThreadViewPost; +} + +/** + * Build a Bluesky app URL for a post + */ +export function buildBskyAppUrl(postUri: string): string { + const parsed = parseAtUri(postUri); + if (!parsed) { + throw new Error(`Invalid post URI: ${postUri}`); + } + + return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; +} diff --git a/packages/ui/src/types/bluesky.ts b/packages/ui/src/types/bluesky.ts new file mode 100644 index 0000000..3ec01d4 --- /dev/null +++ b/packages/ui/src/types/bluesky.ts @@ -0,0 +1,133 @@ +/** + * Strong reference for AT Protocol records (com.atproto.repo.strongRef) + */ +export interface StrongRef { + uri: string; // at:// URI format + cid: string; // Content ID +} + +/** + * Basic profile view from Bluesky API + */ +export interface ProfileViewBasic { + did: string; + handle: string; + displayName?: string; + avatar?: string; +} + +/** + * Post record content from app.bsky.feed.post + */ +export interface PostRecord { + $type: "app.bsky.feed.post"; + text: string; + createdAt: string; + reply?: { + root: StrongRef; + parent: StrongRef; + }; + facets?: Array<{ + index: { byteStart: number; byteEnd: number }; + features: Array< + | { $type: "app.bsky.richtext.facet#link"; uri: string } + | { $type: "app.bsky.richtext.facet#mention"; did: string } + | { $type: "app.bsky.richtext.facet#tag"; tag: string } + >; + }>; +} + +/** + * Post view from Bluesky API + */ +export interface PostView { + uri: string; + cid: string; + author: ProfileViewBasic; + record: PostRecord; + replyCount?: number; + repostCount?: number; + likeCount?: number; + indexedAt: string; +} + +/** + * Thread view post from app.bsky.feed.getPostThread + */ +export interface ThreadViewPost { + $type: "app.bsky.feed.defs#threadViewPost"; + post: PostView; + parent?: ThreadViewPost | BlockedPost | NotFoundPost; + replies?: Array; +} + +/** + * Blocked post placeholder + */ +export interface BlockedPost { + $type: "app.bsky.feed.defs#blockedPost"; + uri: string; + blocked: true; +} + +/** + * Not found post placeholder + */ +export interface NotFoundPost { + $type: "app.bsky.feed.defs#notFoundPost"; + uri: string; + notFound: true; +} + +/** + * Type guard for ThreadViewPost + */ +export function isThreadViewPost( + post: ThreadViewPost | BlockedPost | NotFoundPost | undefined, +): post is ThreadViewPost { + return post?.$type === "app.bsky.feed.defs#threadViewPost"; +} + +/** + * Document record from site.standard.document + */ +export interface DocumentRecord { + $type: "site.standard.document"; + title: string; + site: string; + path: string; + textContent: string; + publishedAt: string; + canonicalUrl?: string; + description?: string; + tags?: string[]; + bskyPostRef?: StrongRef; +} + +/** + * DID document structure + */ +export interface DIDDocument { + id: string; + service?: Array<{ + id: string; + type: string; + serviceEndpoint: string; + }>; +} + +/** + * Response from com.atproto.repo.getRecord + */ +export interface GetRecordResponse { + uri: string; + cid: string; + value: T; +} + +/** + * Response from app.bsky.feed.getPostThread + */ +export interface GetPostThreadResponse { + thread: ThreadViewPost | BlockedPost | NotFoundPost; +} diff --git a/packages/ui/test.html b/packages/ui/test.html new file mode 100644 index 0000000..1d9cba0 --- /dev/null +++ b/packages/ui/test.html @@ -0,0 +1,43 @@ + + + + + + Sequoia Comments Test + + + + + +

Blog Post Title

+

This is a test page for the sequoia-comments web component.

+

The component will look for a <link rel="site.standard.document"> tag in the document head to find the AT Protocol document, then fetch and display Bluesky replies as comments.

+ +

Comments

+ + + + + diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 0000000..93a3f49 --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}