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