this repo has no description
at main 386 lines 11 kB view raw
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 38const 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 117const 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 121const 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 */ 134async 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 165const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 166 167class 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 referrerPolicy: "no-referrer-when-downgrade", 262 body: JSON.stringify({ publicationUri }), 263 }); 264 265 const data = await response.json(); 266 267 if (response.status === 401 && data.authenticated === false) { 268 // Redirect to the hosted subscribe page to complete OAuth, 269 // passing the current page URL (without credentials) as returnTo. 270 const subscribeUrl = new URL(data.subscribeUrl); 271 const pageUrl = new URL(window.location.href); 272 pageUrl.username = ""; 273 pageUrl.password = ""; 274 subscribeUrl.searchParams.set("returnTo", pageUrl.toString()); 275 window.location.href = subscribeUrl.toString(); 276 return; 277 } 278 279 if (!response.ok) { 280 throw new Error(data.error ?? `HTTP ${response.status}`); 281 } 282 283 const { recordUri } = data; 284 this.state = { type: "subscribed", recordUri, publicationUri }; 285 this.render(); 286 287 this.dispatchEvent( 288 new CustomEvent("sequoia-subscribed", { 289 bubbles: true, 290 composed: true, 291 detail: { publicationUri, recordUri }, 292 }), 293 ); 294 } catch (error) { 295 // Don't overwrite state if we already navigated away 296 if (this.state.type !== "loading") return; 297 298 const message = 299 error instanceof Error ? error.message : "Failed to subscribe"; 300 this.state = { type: "error", message }; 301 this.render(); 302 303 this.dispatchEvent( 304 new CustomEvent("sequoia-subscribe-error", { 305 bubbles: true, 306 composed: true, 307 detail: { message }, 308 }), 309 ); 310 } 311 } 312 313 render() { 314 const { type } = this.state; 315 316 if (type === "no-publication") { 317 if (this.hide) { 318 this.wrapper.innerHTML = ""; 319 this.wrapper.style.display = "none"; 320 } 321 return; 322 } 323 324 const isLoading = type === "loading"; 325 const isSubscribed = type === "subscribed"; 326 327 const icon = isLoading 328 ? `<span class="sequoia-loading-spinner"></span>` 329 : isSubscribed 330 ? CHECK_ICON 331 : BLUESKY_ICON; 332 333 const label = isSubscribed ? "Subscribed" : this.label; 334 const buttonClass = [ 335 "sequoia-subscribe-button", 336 isSubscribed ? "sequoia-subscribe-button--success" : "", 337 ] 338 .filter(Boolean) 339 .join(" "); 340 341 const errorHtml = 342 type === "error" 343 ? `<span class="sequoia-error-message">${escapeHtml(this.state.message)}</span>` 344 : ""; 345 346 this.wrapper.innerHTML = ` 347 <button 348 class="${buttonClass}" 349 type="button" 350 part="button" 351 ${isLoading || isSubscribed ? "disabled" : ""} 352 aria-label="${isSubscribed ? "Subscribed" : this.label}" 353 > 354 ${icon} 355 ${label} 356 </button> 357 ${errorHtml} 358 `; 359 360 if (type !== "subscribed") { 361 const btn = this.wrapper.querySelector("button"); 362 btn?.addEventListener("click", () => this.handleClick()); 363 } 364 } 365} 366 367/** 368 * Escape HTML special characters (no DOM dependency for SSR). 369 * @param {string} text 370 * @returns {string} 371 */ 372function escapeHtml(text) { 373 return text 374 .replace(/&/g, "&amp;") 375 .replace(/</g, "&lt;") 376 .replace(/>/g, "&gt;") 377 .replace(/"/g, "&quot;"); 378} 379 380// Register the custom element 381if (typeof customElements !== "undefined") { 382 customElements.define("sequoia-subscribe", SequoiaSubscribe); 383} 384 385// Export for module usage 386export { SequoiaSubscribe };