/** * Sequoia Web Components — AT Protocol-powered engagement components * * Self-contained Web Components for subscribing to publications and * recommending documents via the AT Protocol. * * Both components share: * - OAuth redirect flow via a hosted callback endpoint * - DID caching in a cookie (primary) and localStorage (fallback) * - A common visual style driven by CSS custom properties * * CSS Custom Properties (apply to both components): * - --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/button color (default: #2563eb) * - --sequoia-secondary-color: Secondary text color (default: #6b7280) * - --sequoia-border-radius: Border radius (default: 8px) * - --sequoia-icon-display: Icon display mode (default: inline-block) — set to "none" to hide */ // ============================================================================ // Styles // ============================================================================ const styles = ` :host { display: inline-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-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; font-family: inherit; } .sequoia-button:hover:not(:disabled) { background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); } .sequoia-button:disabled { opacity: 0.6; cursor: not-allowed; } .sequoia-button svg { display: var(--sequoia-icon-display, inline-block); width: 1rem; height: 1rem; flex-shrink: 0; } .sequoia-loading-spinner { display: inline-block; width: 1rem; height: 1rem; border: 2px solid rgba(255, 255, 255, 0.4); border-top-color: #ffffff; border-radius: 50%; animation: sequoia-spin 0.8s linear infinite; flex-shrink: 0; } @keyframes sequoia-spin { to { transform: rotate(360deg); } } .sequoia-error-message { display: inline-block; font-size: 0.8125rem; color: #dc2626; margin-top: 0.375rem; } `; // ============================================================================ // Icons // ============================================================================ const BLUESKY_ICON = ``; const BLACKSKY_ICON = ``; const SEQUOIA_ICON = ``; const ATMOSPHERE_ICON = ``; // ============================================================================ // Button Type Configuration // ============================================================================ const BUTTON_TYPES = { sequoia: { icon: SEQUOIA_ICON, subscribe: "Subscribe on Sequoia", unsubscribe: "Unsubscribe", }, bluesky: { icon: BLUESKY_ICON, subscribe: "Subscribe on Bluesky", unsubscribe: "Unsubscribe", }, blacksky: { icon: BLACKSKY_ICON, subscribe: "Subscribe on Blacksky", unsubscribe: "Unsubscribe", }, atmosphere: { icon: ATMOSPHERE_ICON, subscribe: "Subscribe on Atmosphere", unsubscribe: "Unsubscribe", }, plain: { icon: "", subscribe: "Subscribe", unsubscribe: "Unsubscribe" }, }; // ============================================================================ // Recommend Icon Configuration // ============================================================================ const HEART_PATH = "M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"; const HEART_ICON_OUTLINED = ``; const HEART_ICON_FILLED = ``; const STAR_PATH = "M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"; const STAR_ICON_OUTLINED = ``; const STAR_ICON_FILLED = ``; const THUMBS_UP_RECT_PATH = "M1 21h4V9H1v12z"; const THUMBS_UP_HAND_PATH = "M23 10c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"; const THUMBS_UP_ICON_OUTLINED = ``; const THUMBS_UP_ICON_FILLED = ``; const RECOMMEND_ICON_TYPES = { heart: { icon: HEART_ICON_OUTLINED, iconActioned: HEART_ICON_FILLED, action: "Recommend", unaction: "Unrecommend", }, star: { icon: STAR_ICON_OUTLINED, iconActioned: STAR_ICON_FILLED, action: "Recommend", unaction: "Unrecommend", }, "thumbs-up": { icon: THUMBS_UP_ICON_OUTLINED, iconActioned: THUMBS_UP_ICON_FILLED, action: "Recommend", unaction: "Unrecommend", }, }; // ============================================================================ // DID Storage // ============================================================================ /** * Store the subscriber DID. Tries a cookie first; falls back to localStorage. * @param {string} did */ function storeSubscriberDid(did) { try { const expires = new Date( Date.now() + 365 * 24 * 60 * 60 * 1000, ).toUTCString(); // biome-ignore lint/suspicious/noDocumentCookie: back-compat with older browsers document.cookie = `sequoia_did=${encodeURIComponent(did)}; Expires=${expires}; Path=/; SameSite=Lax; Secure`; } catch { // Cookie write may fail in some embedded contexts } try { localStorage.setItem("sequoia_did", did); } catch { // localStorage may be unavailable } } /** * Retrieve the stored subscriber DID. Checks cookie first, then localStorage. * @returns {string | null} */ function getStoredSubscriberDid() { try { const match = document.cookie.match(/(?:^|;\s*)sequoia_did=([^;]+)/); if (match) { const did = decodeURIComponent(match[1]); if (did.startsWith("did:")) return did; } } catch { // ignore } try { const did = localStorage.getItem("sequoia_did"); if (did?.startsWith("did:")) return did; } catch { // ignore } return null; } /** * Remove the stored subscriber DID from both cookie and localStorage. */ function clearSubscriberDid() { try { // biome-ignore lint/suspicious/noDocumentCookie: back-compat with older browsers document.cookie = "sequoia_did=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax; Secure"; } catch { // ignore } try { localStorage.removeItem("sequoia_did"); } catch { // ignore } } /** * Check the current page URL for sequoia_did / sequoia_unsubscribed params * set by the subscribe redirect flow. Consumes them by removing from the URL. */ function consumeReturnParams() { const url = new URL(window.location.href); const did = url.searchParams.get("sequoia_did"); const unsubscribed = url.searchParams.get("sequoia_unsubscribed"); let changed = false; if (unsubscribed === "1") { clearSubscriberDid(); url.searchParams.delete("sequoia_unsubscribed"); changed = true; } if (did?.startsWith("did:")) { storeSubscriberDid(did); url.searchParams.delete("sequoia_did"); changed = true; } if (changed) { const cleanUrl = url.pathname + (url.search || "") + (url.hash || ""); try { window.history.replaceState(null, "", cleanUrl); } catch { // ignore } } } // ============================================================================ // AT Protocol Functions // ============================================================================ /** * Fetch the publication AT URI from the host site's well-known endpoint. * @param {string} [origin] - Origin to fetch from (defaults to current page origin) * @returns {Promise} Publication AT URI */ async function fetchPublicationUri(origin) { const base = origin ?? window.location.origin; const url = `${base}/.well-known/site.standard.publication`; const response = await fetch(url); if (!response.ok) { throw new Error(`Could not fetch publication URI: ${response.status}`); } // Accept either plain text (the AT URI itself) or JSON with a `uri` field. const contentType = response.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { const data = await response.json(); const uri = data?.uri ?? data?.atUri ?? data?.publication; if (!uri) { throw new Error("Publication response did not contain a URI"); } return uri; } const text = (await response.text()).trim(); if (!text.startsWith("at://")) { throw new Error(`Unexpected publication URI format: ${text}`); } return text; } // ============================================================================ // Web Component // ============================================================================ // SSR-safe base class - use HTMLElement in browser, empty class in Node.js const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; /** * Abstract base class shared by SequoiaSubscribe and SequoiaRecommend. * Handles shadow DOM setup, state management, the OAuth redirect flow, * DID storage, and button rendering. Subclasses implement template methods * to provide resource-specific behaviour. */ class SequoiaActionBase extends BaseElement { constructor() { super(); const shadow = this.attachShadow({ mode: "open" }); const styleTag = document.createElement("style"); styleTag.innerText = styles; shadow.appendChild(styleTag); const wrapper = document.createElement("div"); shadow.appendChild(wrapper); wrapper.part = "container"; this.wrapper = wrapper; this.actioned = false; this.state = { type: "idle" }; this.abortController = null; this.render(); } disconnectedCallback() { this.abortController?.abort(); } attributeChangedCallback() { if (this.state.type === "error" || this.state.type === "no-resource") { this.state = { type: "idle" }; } this.render(); } // ── Shared getters ─────────────────────────────────────────────────────── get callbackUri() { return this.getAttribute("callback-uri") ?? this.defaultCallbackUri; } get hide() { return this.getAttribute("hide") === "auto"; } // ── Template methods (override in subclasses) ──────────────────────────── /** @returns {string} Default callback URI when the attribute is absent */ get defaultCallbackUri() { return ""; } /** @returns {string} Query-parameter name for the resource URI */ get resourceParam() { return "resourceUri"; } /** * Value of the `action` query-parameter used in the unaction redirect. * @returns {string} */ get unactionValue() { return "unaction"; } /** @returns {string} Key in the /check response that signals the action was taken */ get actionedKey() { return "actioned"; } /** @returns {string} CustomEvent name dispatched on success */ get actionedEventName() { return "sequoia-actioned"; } /** @returns {string} CustomEvent name dispatched on error */ get errorEventName() { return "sequoia-action-error"; } /** @returns {string} Fallback error message when the thrown value has no message */ get defaultErrorMessage() { return "Action failed"; } /** @returns {string} SVG string for the button icon */ getIcon() { return ""; } /** @returns {string} Accessible label for the button (defaults to the visible label) */ getAriaLabel() { return this.actioned ? (this.getUnactionLabel?.() ?? this.getDefaultUnactionLabel?.() ?? "") : (this.label ?? this.getDefaultActionLabel?.() ?? ""); } /** * Resolve the resource URI for this action. May perform async network calls. * @returns {Promise} */ async resolveResourceUri() { throw new Error("resolveResourceUri() must be implemented by subclass"); } // ── Shared logic ───────────────────────────────────────────────────────── /** * Check whether the current user has already taken this action for the * given resource URI. Updates this.actioned and re-renders on success. * @param {string} resourceUri */ async checkStatusFor(resourceUri) { try { const checkUrl = new URL(`${this.callbackUri}/check`); checkUrl.searchParams.set(this.resourceParam, resourceUri); // Pass the stored DID so the server can check without a session cookie const storedDid = getStoredSubscriberDid(); if (storedDid) { checkUrl.searchParams.set("did", storedDid); } const res = await fetch(checkUrl.toString(), { credentials: "include", }); if (!res.ok) return; const data = await res.json(); if (data[this.actionedKey]) { this.actioned = true; this.render(); } } catch { // Ignore errors — show default action button } } async handleClick() { if (this.state.type === "loading") { return; } // Unaction: redirect to the full-page unaction flow if (this.actioned) { const resourceUri = await this.resolveResourceUri(); window.location.href = `${this.callbackUri}?${this.resourceParam}=${encodeURIComponent(resourceUri)}&action=${this.unactionValue}`; return; } this.state = { type: "loading" }; this.render(); try { const resourceUri = await this.resolveResourceUri(); const response = await fetch(this.callbackUri, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", referrerPolicy: "no-referrer-when-downgrade", body: JSON.stringify({ [this.resourceParam]: resourceUri }), }); const data = await response.json(); if (response.status === 401 && data.authenticated === false) { // Redirect to the hosted action page to complete OAuth, // passing the current page URL (without credentials) as returnTo. const actionUrl = new URL(data.subscribeUrl); const pageUrl = new URL(window.location.href); pageUrl.username = ""; pageUrl.password = ""; actionUrl.searchParams.set("returnTo", pageUrl.toString()); window.location.href = actionUrl.toString(); return; } if (!response.ok) { throw new Error(data.error ?? `HTTP ${response.status}`); } const { recordUri } = data; // Store the DID from the record URI (at://did:aaa:bbb/...) if (recordUri) { const didMatch = recordUri.match(/^at:\/\/(did:[^/]+)/); if (didMatch) { storeSubscriberDid(didMatch[1]); } } this.actioned = true; this.state = { type: "idle" }; this.render(); this.dispatchEvent( new CustomEvent(this.actionedEventName, { bubbles: true, composed: true, detail: { [this.resourceParam]: resourceUri, recordUri }, }), ); } catch (error) { if (this.state.type !== "loading") return; const message = error instanceof Error ? error.message : this.defaultErrorMessage; this.state = { type: "error", message }; this.render(); this.dispatchEvent( new CustomEvent(this.errorEventName, { bubbles: true, composed: true, detail: { message }, }), ); } } render() { const { type } = this.state; if (type === "no-resource") { if (this.hide) { this.wrapper.innerHTML = ""; this.wrapper.style.display = "none"; } return; } const isLoading = type === "loading"; const icon = isLoading ? `` : this.getIcon(); const label = this.actioned ? (this.getUnactionLabel?.() ?? this.getDefaultUnactionLabel?.() ?? "") : (this.label ?? this.getDefaultActionLabel?.() ?? ""); const ariaLabel = this.getAriaLabel(); const errorHtml = type === "error" ? `${escapeHtml(this.state.message)}` : ""; this.wrapper.innerHTML = ` ${errorHtml} `; const btn = this.wrapper.querySelector("button"); btn?.addEventListener("click", () => this.handleClick()); } } class SequoiaSubscribe extends SequoiaActionBase { static get observedAttributes() { return [ "publication-uri", "callback-uri", "label", "unsubscribe-label", "button-type", "hide", ]; } connectedCallback() { consumeReturnParams(); this.checkPublication(); } get publicationUri() { return this.getAttribute("publication-uri") ?? null; } get label() { return this.getAttribute("label") ?? null; } get buttonType() { const val = this.getAttribute("button-type"); return val && val in BUTTON_TYPES ? val : "sequoia"; } get unsubscribeLabel() { return this.getAttribute("unsubscribe-label") ?? null; } // ── Template method overrides ──────────────────────────────────────────── get defaultCallbackUri() { return "https://sequoia.pub/subscribe"; } get resourceParam() { return "publicationUri"; } get unactionValue() { return "unsubscribe"; } get actionedKey() { return "subscribed"; } get actionedEventName() { return "sequoia-subscribed"; } get errorEventName() { return "sequoia-subscribe-error"; } get defaultErrorMessage() { return "Failed to subscribe"; } getDefaultActionLabel() { return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).subscribe; } getDefaultUnactionLabel() { return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).unsubscribe; } getUnactionLabel() { return this.unsubscribeLabel; } getIcon() { return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).icon; } async resolveResourceUri() { return this.publicationUri ?? (await fetchPublicationUri()); } // ── SequoiaSubscribe-specific logic ────────────────────────────────────── /** @returns {boolean} Whether the user is currently subscribed. Alias for this.actioned. */ get subscribed() { return this.actioned; } /** * Check whether the current user is subscribed to the given publication URI. * Forwards to the shared checkStatusFor() method. * @param {string} publicationUri */ checkSubscription(publicationUri) { return this.checkStatusFor(publicationUri); } async checkPublication() { this.abortController?.abort(); this.abortController = new AbortController(); try { const uri = await this.resolveResourceUri(); this.checkStatusFor(uri); } catch { this.state = { type: "no-resource" }; this.render(); } } } class SequoiaRecommend extends SequoiaActionBase { static get observedAttributes() { return ["document-uri", "callback-uri", "button-type", "hide"]; } connectedCallback() { consumeReturnParams(); this.checkDocument(); } get documentUri() { const attrUri = this.getAttribute("document-uri"); if (attrUri) return attrUri; const linkTag = document.querySelector( 'link[rel="site.standard.document"]', ); return linkTag?.href ?? null; } get buttonType() { const val = this.getAttribute("button-type"); return val && val in RECOMMEND_ICON_TYPES ? val : "heart"; } // ── Template method overrides ──────────────────────────────────────────── get defaultCallbackUri() { return "https://sequoia.pub/recommend"; } get resourceParam() { return "documentUri"; } get unactionValue() { return "remove"; } get actionedKey() { return "recommended"; } get actionedEventName() { return "sequoia-recommended"; } get errorEventName() { return "sequoia-recommend-error"; } get defaultErrorMessage() { return "Failed to recommend"; } getAriaLabel() { const config = RECOMMEND_ICON_TYPES[this.buttonType] ?? RECOMMEND_ICON_TYPES.heart; return this.actioned ? config.unaction : config.action; } getIcon() { const config = RECOMMEND_ICON_TYPES[this.buttonType] ?? RECOMMEND_ICON_TYPES.heart; return this.actioned ? config.iconActioned : config.icon; } async resolveResourceUri() { const uri = this.documentUri; if (!uri) throw new Error("No document URI found"); return uri; } // ── SequoiaRecommend-specific logic ────────────────────────────────────── async checkDocument() { this.abortController?.abort(); this.abortController = new AbortController(); const uri = this.documentUri; if (!uri) { this.state = { type: "no-resource" }; this.render(); return; } this.checkStatusFor(uri); } } /** * Escape HTML special characters (no DOM dependency for SSR). * @param {string} text * @returns {string} */ function escapeHtml(text) { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } // Register the custom elements if (typeof customElements !== "undefined") { customElements.define("sequoia-subscribe", SequoiaSubscribe); customElements.define("sequoia-recommend", SequoiaRecommend); } /** * Sequoia Subscribe - An AT Protocol-powered subscribe component * * A self-contained Web Component that lets users subscribe to a publication * via the AT Protocol by creating a site.standard.graph.subscription record. * * Usage: * * * The component resolves the publication AT URI from the host site's * /.well-known/site.standard.publication endpoint. * * Attributes: * - publication-uri: Override the publication AT URI (optional) * - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/subscribe") * - button-type: Branding style — "sequoia" (default), "bluesky", "blacksky", "atmosphere", or "plain" * - label: Override the subscribe button label text * - unsubscribe-label: Override the unsubscribe button label text * - hide: Set to "auto" to hide if no publication URI is detected * * Events: * - sequoia-subscribed: Fired when the subscription is created successfully. * detail: { publicationUri: string, recordUri: string } * - sequoia-subscribe-error: Fired when the subscription fails. * detail: { message: string } */ export { SequoiaSubscribe }; /** * Sequoia Recommend - An AT Protocol-powered recommend component * * A self-contained Web Component that lets users recommend a document * via the AT Protocol by creating a site.standard.graph.recommend record. * * Usage: * * * The component resolves the document AT URI from the `document-uri` attribute * or a tag in the page head. * * Attributes: * - document-uri: AT Protocol URI of the document to recommend (optional if link tag present) * - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/recommend") * - button-type: Icon style — "heart" (default), "star", or "thumbs-up" * - hide: Set to "auto" to hide if no document URI is detected * * Events: * - sequoia-recommended: Fired when the recommendation is created successfully. * detail: { documentUri: string, recordUri: string } * - sequoia-recommend-error: Fired when the recommendation fails. * detail: { message: string } */ export { SequoiaRecommend };