/** * Sequoia Subscribe - A Bluesky-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") * - label: Button label text (default: "Subscribe on Bluesky") * - hide: Set to "auto" to hide if no publication URI 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/button color (default: #2563eb) * - --sequoia-secondary-color: Secondary text color (default: #6b7280) * - --sequoia-border-radius: Border radius (default: 8px) * * 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 } */ // ============================================================================ // 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-subscribe-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-subscribe-button:hover:not(:disabled) { background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); } .sequoia-subscribe-button:disabled { opacity: 0.6; cursor: not-allowed; } .sequoia-subscribe-button svg { 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 = ``; // ============================================================================ // 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(); document.cookie = `sequoia_did=${encodeURIComponent(did)}; expires=${expires}; path=/; SameSite=Lax`; } 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 { document.cookie = "sequoia_did=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax"; } 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 && 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 {}; class SequoiaSubscribe 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.subscribed = false; this.state = { type: "idle" }; this.abortController = null; this.render(); } static get observedAttributes() { return ["publication-uri", "callback-uri", "label", "hide"]; } connectedCallback() { consumeReturnParams(); this.checkPublication(); } disconnectedCallback() { this.abortController?.abort(); } attributeChangedCallback() { if (this.state.type === "error" || this.state.type === "no-publication") { this.state = { type: "idle" }; } this.render(); } get publicationUri() { return this.getAttribute("publication-uri") ?? null; } get callbackUri() { return this.getAttribute("callback-uri") ?? "https://sequoia.pub/subscribe"; } get label() { return this.getAttribute("label") ?? "Subscribe on Bluesky"; } get hide() { const hideAttr = this.getAttribute("hide"); return hideAttr === "auto"; } async checkPublication() { this.abortController?.abort(); this.abortController = new AbortController(); try { const uri = this.publicationUri ?? (await fetchPublicationUri()); this.checkSubscription(uri); } catch { this.state = { type: "no-publication" }; this.render(); } } async checkSubscription(publicationUri) { try { const checkUrl = new URL(`${this.callbackUri}/check`); checkUrl.searchParams.set("publicationUri", publicationUri); // 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.subscribed) { this.subscribed = true; this.render(); } } catch { // Ignore errors — show default subscribe button } } async handleClick() { if (this.state.type === "loading") { return; } // Unsubscribe: redirect to full-page unsubscribe flow if (this.subscribed) { const publicationUri = this.publicationUri ?? (await fetchPublicationUri()); window.location.href = `${this.callbackUri}?publicationUri=${encodeURIComponent(publicationUri)}&action=unsubscribe`; return; } this.state = { type: "loading" }; this.render(); try { const publicationUri = this.publicationUri ?? (await fetchPublicationUri()); const response = await fetch(this.callbackUri, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", referrerPolicy: "no-referrer-when-downgrade", body: JSON.stringify({ publicationUri }), }); const data = await response.json(); if (response.status === 401 && data.authenticated === false) { // Redirect to the hosted subscribe page to complete OAuth, // passing the current page URL (without credentials) as returnTo. const subscribeUrl = new URL(data.subscribeUrl); const pageUrl = new URL(window.location.href); pageUrl.username = ""; pageUrl.password = ""; subscribeUrl.searchParams.set("returnTo", pageUrl.toString()); window.location.href = subscribeUrl.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.subscribed = true; this.state = { type: "idle" }; this.render(); this.dispatchEvent( new CustomEvent("sequoia-subscribed", { bubbles: true, composed: true, detail: { publicationUri, recordUri }, }), ); } catch (error) { if (this.state.type !== "loading") return; const message = error instanceof Error ? error.message : "Failed to subscribe"; this.state = { type: "error", message }; this.render(); this.dispatchEvent( new CustomEvent("sequoia-subscribe-error", { bubbles: true, composed: true, detail: { message }, }), ); } } render() { const { type } = this.state; if (type === "no-publication") { if (this.hide) { this.wrapper.innerHTML = ""; this.wrapper.style.display = "none"; } return; } const isLoading = type === "loading"; const icon = isLoading ? `` : BLUESKY_ICON; const label = this.subscribed ? "Unsubscribe on Bluesky" : this.label; const errorHtml = type === "error" ? `${escapeHtml(this.state.message)}` : ""; this.wrapper.innerHTML = ` ${errorHtml} `; const btn = this.wrapper.querySelector("button"); btn?.addEventListener("click", () => this.handleClick()); } } /** * 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 element if (typeof customElements !== "undefined") { customElements.define("sequoia-subscribe", SequoiaSubscribe); } // Export for module usage export { SequoiaSubscribe };