/**
* 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 };