/**
* 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-subscribe-button--success {
background: #16a34a;
}
.sequoia-subscribe-button--success:hover:not(:disabled) {
background: color-mix(in srgb, #16a34a 85%, black);
}
.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 CHECK_ICON = ``;
// ============================================================================
// 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.state = { type: "idle" };
this.abortController = null;
this.render();
}
static get observedAttributes() {
return ["publication-uri", "callback-uri", "label", "hide"];
}
connectedCallback() {
// Pre-check publication availability so hide="auto" can take effect
if (!this.publicationUri) {
this.checkPublication();
}
}
disconnectedCallback() {
this.abortController?.abort();
}
attributeChangedCallback() {
// Reset to idle if attributes change after an error or success
if (
this.state.type === "error" ||
this.state.type === "subscribed" ||
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 {
await fetchPublicationUri();
} catch {
this.state = { type: "no-publication" };
this.render();
}
}
async handleClick() {
if (this.state.type === "loading" || this.state.type === "subscribed") {
return;
}
this.state = { type: "loading" };
this.render();
try {
const publicationUri =
this.publicationUri ?? (await fetchPublicationUri());
// POST to the callbackUri (e.g. https://sequoia.pub/subscribe).
// If the server reports the user isn't authenticated it returns a
// subscribeUrl for the full-page OAuth + subscription flow.
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;
this.state = { type: "subscribed", recordUri, publicationUri };
this.render();
this.dispatchEvent(
new CustomEvent("sequoia-subscribed", {
bubbles: true,
composed: true,
detail: { publicationUri, recordUri },
}),
);
} catch (error) {
// Don't overwrite state if we already navigated away
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 isSubscribed = type === "subscribed";
const icon = isLoading
? ``
: isSubscribed
? CHECK_ICON
: BLUESKY_ICON;
const label = isSubscribed ? "Subscribed" : this.label;
const buttonClass = [
"sequoia-subscribe-button",
isSubscribed ? "sequoia-subscribe-button--success" : "",
]
.filter(Boolean)
.join(" ");
const errorHtml =
type === "error"
? `${escapeHtml(this.state.message)}`
: "";
this.wrapper.innerHTML = `
${errorHtml}
`;
if (type !== "subscribed") {
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 };