| 1 | /** |
| 2 | * Sequoia Subscribe - A Bluesky-powered subscribe component |
| 3 | * |
| 4 | * A self-contained Web Component that lets users subscribe to a publication |
| 5 | * via the AT Protocol by creating a site.standard.graph.subscription record. |
| 6 | * |
| 7 | * Usage: |
| 8 | * <sequoia-subscribe></sequoia-subscribe> |
| 9 | * |
| 10 | * The component resolves the publication AT URI from the host site's |
| 11 | * /.well-known/site.standard.publication endpoint. |
| 12 | * |
| 13 | * Attributes: |
| 14 | * - publication-uri: Override the publication AT URI (optional) |
| 15 | * - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/subscribe") |
| 16 | * - label: Button label text (default: "Subscribe on Bluesky") |
| 17 | * - hide: Set to "auto" to hide if no publication URI is detected |
| 18 | * |
| 19 | * CSS Custom Properties: |
| 20 | * - --sequoia-fg-color: Text color (default: #1f2937) |
| 21 | * - --sequoia-bg-color: Background color (default: #ffffff) |
| 22 | * - --sequoia-border-color: Border color (default: #e5e7eb) |
| 23 | * - --sequoia-accent-color: Accent/button color (default: #2563eb) |
| 24 | * - --sequoia-secondary-color: Secondary text color (default: #6b7280) |
| 25 | * - --sequoia-border-radius: Border radius (default: 8px) |
| 26 | * |
| 27 | * Events: |
| 28 | * - sequoia-subscribed: Fired when the subscription is created successfully. |
| 29 | * detail: { publicationUri: string, recordUri: string } |
| 30 | * - sequoia-subscribe-error: Fired when the subscription fails. |
| 31 | * detail: { message: string } |
| 32 | */ |
| 33 | |
| 34 | // ============================================================================ |
| 35 | // Styles |
| 36 | // ============================================================================ |
| 37 | |
| 38 | const styles = ` |
| 39 | :host { |
| 40 | display: inline-block; |
| 41 | font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| 42 | color: var(--sequoia-fg-color, #1f2937); |
| 43 | line-height: 1.5; |
| 44 | } |
| 45 | |
| 46 | * { |
| 47 | box-sizing: border-box; |
| 48 | } |
| 49 | |
| 50 | .sequoia-subscribe-button { |
| 51 | display: inline-flex; |
| 52 | align-items: center; |
| 53 | gap: 0.375rem; |
| 54 | padding: 0.5rem 1rem; |
| 55 | background: var(--sequoia-accent-color, #2563eb); |
| 56 | color: #ffffff; |
| 57 | border: none; |
| 58 | border-radius: var(--sequoia-border-radius, 8px); |
| 59 | font-size: 0.875rem; |
| 60 | font-weight: 500; |
| 61 | cursor: pointer; |
| 62 | text-decoration: none; |
| 63 | transition: background-color 0.15s ease; |
| 64 | font-family: inherit; |
| 65 | } |
| 66 | |
| 67 | .sequoia-subscribe-button:hover:not(:disabled) { |
| 68 | background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); |
| 69 | } |
| 70 | |
| 71 | .sequoia-subscribe-button:disabled { |
| 72 | opacity: 0.6; |
| 73 | cursor: not-allowed; |
| 74 | } |
| 75 | |
| 76 | .sequoia-subscribe-button svg { |
| 77 | width: 1rem; |
| 78 | height: 1rem; |
| 79 | flex-shrink: 0; |
| 80 | } |
| 81 | |
| 82 | .sequoia-subscribe-button--success { |
| 83 | background: #16a34a; |
| 84 | } |
| 85 | |
| 86 | .sequoia-subscribe-button--success:hover:not(:disabled) { |
| 87 | background: color-mix(in srgb, #16a34a 85%, black); |
| 88 | } |
| 89 | |
| 90 | .sequoia-loading-spinner { |
| 91 | display: inline-block; |
| 92 | width: 1rem; |
| 93 | height: 1rem; |
| 94 | border: 2px solid rgba(255, 255, 255, 0.4); |
| 95 | border-top-color: #ffffff; |
| 96 | border-radius: 50%; |
| 97 | animation: sequoia-spin 0.8s linear infinite; |
| 98 | flex-shrink: 0; |
| 99 | } |
| 100 | |
| 101 | @keyframes sequoia-spin { |
| 102 | to { transform: rotate(360deg); } |
| 103 | } |
| 104 | |
| 105 | .sequoia-error-message { |
| 106 | display: inline-block; |
| 107 | font-size: 0.8125rem; |
| 108 | color: #dc2626; |
| 109 | margin-top: 0.375rem; |
| 110 | } |
| 111 | `; |
| 112 | |
| 113 | // ============================================================================ |
| 114 | // Icons |
| 115 | // ============================================================================ |
| 116 | |
| 117 | const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> |
| 118 | <path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/> |
| 119 | </svg>`; |
| 120 | |
| 121 | const CHECK_ICON = `<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> |
| 122 | <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> |
| 123 | </svg>`; |
| 124 | |
| 125 | // ============================================================================ |
| 126 | // AT Protocol Functions |
| 127 | // ============================================================================ |
| 128 | |
| 129 | /** |
| 130 | * Fetch the publication AT URI from the host site's well-known endpoint. |
| 131 | * @param {string} [origin] - Origin to fetch from (defaults to current page origin) |
| 132 | * @returns {Promise<string>} Publication AT URI |
| 133 | */ |
| 134 | async function fetchPublicationUri(origin) { |
| 135 | const base = origin ?? window.location.origin; |
| 136 | const url = `${base}/.well-known/site.standard.publication`; |
| 137 | const response = await fetch(url); |
| 138 | if (!response.ok) { |
| 139 | throw new Error(`Could not fetch publication URI: ${response.status}`); |
| 140 | } |
| 141 | |
| 142 | // Accept either plain text (the AT URI itself) or JSON with a `uri` field. |
| 143 | const contentType = response.headers.get("content-type") ?? ""; |
| 144 | if (contentType.includes("application/json")) { |
| 145 | const data = await response.json(); |
| 146 | const uri = data?.uri ?? data?.atUri ?? data?.publication; |
| 147 | if (!uri) { |
| 148 | throw new Error("Publication response did not contain a URI"); |
| 149 | } |
| 150 | return uri; |
| 151 | } |
| 152 | |
| 153 | const text = (await response.text()).trim(); |
| 154 | if (!text.startsWith("at://")) { |
| 155 | throw new Error(`Unexpected publication URI format: ${text}`); |
| 156 | } |
| 157 | return text; |
| 158 | } |
| 159 | |
| 160 | // ============================================================================ |
| 161 | // Web Component |
| 162 | // ============================================================================ |
| 163 | |
| 164 | // SSR-safe base class - use HTMLElement in browser, empty class in Node.js |
| 165 | const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; |
| 166 | |
| 167 | class SequoiaSubscribe extends BaseElement { |
| 168 | constructor() { |
| 169 | super(); |
| 170 | const shadow = this.attachShadow({ mode: "open" }); |
| 171 | |
| 172 | const styleTag = document.createElement("style"); |
| 173 | styleTag.innerText = styles; |
| 174 | shadow.appendChild(styleTag); |
| 175 | |
| 176 | const wrapper = document.createElement("div"); |
| 177 | shadow.appendChild(wrapper); |
| 178 | wrapper.part = "container"; |
| 179 | |
| 180 | this.wrapper = wrapper; |
| 181 | this.state = { type: "idle" }; |
| 182 | this.abortController = null; |
| 183 | this.render(); |
| 184 | } |
| 185 | |
| 186 | static get observedAttributes() { |
| 187 | return ["publication-uri", "callback-uri", "label", "hide"]; |
| 188 | } |
| 189 | |
| 190 | connectedCallback() { |
| 191 | // Pre-check publication availability so hide="auto" can take effect |
| 192 | if (!this.publicationUri) { |
| 193 | this.checkPublication(); |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | disconnectedCallback() { |
| 198 | this.abortController?.abort(); |
| 199 | } |
| 200 | |
| 201 | attributeChangedCallback() { |
| 202 | // Reset to idle if attributes change after an error or success |
| 203 | if ( |
| 204 | this.state.type === "error" || |
| 205 | this.state.type === "subscribed" || |
| 206 | this.state.type === "no-publication" |
| 207 | ) { |
| 208 | this.state = { type: "idle" }; |
| 209 | } |
| 210 | this.render(); |
| 211 | } |
| 212 | |
| 213 | get publicationUri() { |
| 214 | return this.getAttribute("publication-uri") ?? null; |
| 215 | } |
| 216 | |
| 217 | get callbackUri() { |
| 218 | return this.getAttribute("callback-uri") ?? "https://sequoia.pub/subscribe"; |
| 219 | } |
| 220 | |
| 221 | get label() { |
| 222 | return this.getAttribute("label") ?? "Subscribe on Bluesky"; |
| 223 | } |
| 224 | |
| 225 | get hide() { |
| 226 | const hideAttr = this.getAttribute("hide"); |
| 227 | return hideAttr === "auto"; |
| 228 | } |
| 229 | |
| 230 | async checkPublication() { |
| 231 | this.abortController?.abort(); |
| 232 | this.abortController = new AbortController(); |
| 233 | |
| 234 | try { |
| 235 | await fetchPublicationUri(); |
| 236 | } catch { |
| 237 | this.state = { type: "no-publication" }; |
| 238 | this.render(); |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | async handleClick() { |
| 243 | if (this.state.type === "loading" || this.state.type === "subscribed") { |
| 244 | return; |
| 245 | } |
| 246 | |
| 247 | this.state = { type: "loading" }; |
| 248 | this.render(); |
| 249 | |
| 250 | try { |
| 251 | const publicationUri = |
| 252 | this.publicationUri ?? (await fetchPublicationUri()); |
| 253 | |
| 254 | // POST to the callbackUri (e.g. https://sequoia.pub/subscribe). |
| 255 | // If the server reports the user isn't authenticated it returns a |
| 256 | // subscribeUrl for the full-page OAuth + subscription flow. |
| 257 | const response = await fetch(this.callbackUri, { |
| 258 | method: "POST", |
| 259 | headers: { "Content-Type": "application/json" }, |
| 260 | credentials: "include", |
| 261 | body: JSON.stringify({ publicationUri }), |
| 262 | }); |
| 263 | |
| 264 | const data = await response.json(); |
| 265 | |
| 266 | if (response.status === 401 && data.authenticated === false) { |
| 267 | // Redirect to the hosted subscribe page to complete OAuth |
| 268 | window.location.href = data.subscribeUrl; |
| 269 | return; |
| 270 | } |
| 271 | |
| 272 | if (!response.ok) { |
| 273 | throw new Error(data.error ?? `HTTP ${response.status}`); |
| 274 | } |
| 275 | |
| 276 | const { recordUri } = data; |
| 277 | this.state = { type: "subscribed", recordUri, publicationUri }; |
| 278 | this.render(); |
| 279 | |
| 280 | this.dispatchEvent( |
| 281 | new CustomEvent("sequoia-subscribed", { |
| 282 | bubbles: true, |
| 283 | composed: true, |
| 284 | detail: { publicationUri, recordUri }, |
| 285 | }), |
| 286 | ); |
| 287 | } catch (error) { |
| 288 | // Don't overwrite state if we already navigated away |
| 289 | if (this.state.type !== "loading") return; |
| 290 | |
| 291 | const message = |
| 292 | error instanceof Error ? error.message : "Failed to subscribe"; |
| 293 | this.state = { type: "error", message }; |
| 294 | this.render(); |
| 295 | |
| 296 | this.dispatchEvent( |
| 297 | new CustomEvent("sequoia-subscribe-error", { |
| 298 | bubbles: true, |
| 299 | composed: true, |
| 300 | detail: { message }, |
| 301 | }), |
| 302 | ); |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | render() { |
| 307 | const { type } = this.state; |
| 308 | |
| 309 | if (type === "no-publication") { |
| 310 | if (this.hide) { |
| 311 | this.wrapper.innerHTML = ""; |
| 312 | this.wrapper.style.display = "none"; |
| 313 | } |
| 314 | return; |
| 315 | } |
| 316 | |
| 317 | const isLoading = type === "loading"; |
| 318 | const isSubscribed = type === "subscribed"; |
| 319 | |
| 320 | const icon = isLoading |
| 321 | ? `<span class="sequoia-loading-spinner"></span>` |
| 322 | : isSubscribed |
| 323 | ? CHECK_ICON |
| 324 | : BLUESKY_ICON; |
| 325 | |
| 326 | const label = isSubscribed ? "Subscribed" : this.label; |
| 327 | const buttonClass = [ |
| 328 | "sequoia-subscribe-button", |
| 329 | isSubscribed ? "sequoia-subscribe-button--success" : "", |
| 330 | ] |
| 331 | .filter(Boolean) |
| 332 | .join(" "); |
| 333 | |
| 334 | const errorHtml = |
| 335 | type === "error" |
| 336 | ? `<span class="sequoia-error-message">${escapeHtml(this.state.message)}</span>` |
| 337 | : ""; |
| 338 | |
| 339 | this.wrapper.innerHTML = ` |
| 340 | <button |
| 341 | class="${buttonClass}" |
| 342 | type="button" |
| 343 | part="button" |
| 344 | ${isLoading || isSubscribed ? "disabled" : ""} |
| 345 | aria-label="${isSubscribed ? "Subscribed" : this.label}" |
| 346 | > |
| 347 | ${icon} |
| 348 | ${label} |
| 349 | </button> |
| 350 | ${errorHtml} |
| 351 | `; |
| 352 | |
| 353 | if (type !== "subscribed") { |
| 354 | const btn = this.wrapper.querySelector("button"); |
| 355 | btn?.addEventListener("click", () => this.handleClick()); |
| 356 | } |
| 357 | } |
| 358 | } |
| 359 | |
| 360 | /** |
| 361 | * Escape HTML special characters (no DOM dependency for SSR). |
| 362 | * @param {string} text |
| 363 | * @returns {string} |
| 364 | */ |
| 365 | function escapeHtml(text) { |
| 366 | return text |
| 367 | .replace(/&/g, "&") |
| 368 | .replace(/</g, "<") |
| 369 | .replace(/>/g, ">") |
| 370 | .replace(/"/g, """); |
| 371 | } |
| 372 | |
| 373 | // Register the custom element |
| 374 | if (typeof customElements !== "undefined") { |
| 375 | customElements.define("sequoia-subscribe", SequoiaSubscribe); |
| 376 | } |
| 377 | |
| 378 | // Export for module usage |
| 379 | export { SequoiaSubscribe }; |