this repo has no description

feat: Add subscription component

Resolves #16

authored by

Heath Stewart and committed by tangled.org 597d4cb3 1b1b4136

+422 -10
+21 -10
packages/cli/src/commands/add.ts
··· 14 14 15 15 const DEFAULT_COMPONENTS_PATH = "src/components"; 16 16 17 - const AVAILABLE_COMPONENTS = ["sequoia-comments"]; 17 + const AVAILABLE_COMPONENTS: { name: string; notes?: string }[] = [ 18 + { 19 + name: "sequoia-comments", 20 + notes: 21 + `The component will automatically read the document URI from:\n` + 22 + `<link rel="site.standard.document" href="at://...">`, 23 + }, 24 + { 25 + name: "sequoia-subscribe", 26 + }, 27 + ]; 18 28 19 29 export const addCommand = command({ 20 30 name: "add", ··· 30 40 intro("Add Sequoia Component"); 31 41 32 42 // Validate component name 33 - if (!AVAILABLE_COMPONENTS.includes(componentName)) { 43 + const component = AVAILABLE_COMPONENTS.find((c) => c.name === componentName); 44 + if (!component) { 34 45 log.error(`Component '${componentName}' not found`); 35 46 log.info("Available components:"); 36 47 for (const comp of AVAILABLE_COMPONENTS) { 37 - log.info(` - ${comp}`); 48 + log.info(` - ${comp.name}`); 38 49 } 39 50 process.exit(1); 40 51 } ··· 143 154 } 144 155 145 156 // Show usage instructions 146 - note( 157 + let notes = 147 158 `Add to your HTML:\n\n` + 148 - `<script type="module" src="${componentsDir}/${componentName}.js"></script>\n` + 149 - `<${componentName}></${componentName}>\n\n` + 150 - `The component will automatically read the document URI from:\n` + 151 - `<link rel="site.standard.document" href="at://...">`, 152 - "Usage", 153 - ); 159 + `<script type="module" src="${componentsDir}/${componentName}.js"></script>\n` + 160 + `<${componentName}></${componentName}>\n`; 161 + if (component.notes) { 162 + notes += `\n${component.notes}`; 163 + } 164 + note(notes, "Usage"); 154 165 155 166 outro(`${componentName} added successfully!`); 156 167 },
+401
packages/cli/src/components/sequoia-subscribe.js
··· 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 + * - label: Button label text (default: "Subscribe on Bluesky") 16 + * 17 + * CSS Custom Properties: 18 + * - --sequoia-fg-color: Text color (default: #1f2937) 19 + * - --sequoia-bg-color: Background color (default: #ffffff) 20 + * - --sequoia-border-color: Border color (default: #e5e7eb) 21 + * - --sequoia-accent-color: Accent/button color (default: #2563eb) 22 + * - --sequoia-secondary-color: Secondary text color (default: #6b7280) 23 + * - --sequoia-border-radius: Border radius (default: 8px) 24 + * 25 + * Events: 26 + * - sequoia-subscribed: Fired when the subscription is created successfully. 27 + * detail: { publicationUri: string, recordUri: string } 28 + * - sequoia-subscribe-error: Fired when the subscription fails. 29 + * detail: { message: string } 30 + */ 31 + 32 + // ============================================================================ 33 + // Styles 34 + // ============================================================================ 35 + 36 + const styles = ` 37 + :host { 38 + display: inline-block; 39 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 40 + color: var(--sequoia-fg-color, #1f2937); 41 + line-height: 1.5; 42 + } 43 + 44 + * { 45 + box-sizing: border-box; 46 + } 47 + 48 + .sequoia-subscribe-button { 49 + display: inline-flex; 50 + align-items: center; 51 + gap: 0.375rem; 52 + padding: 0.5rem 1rem; 53 + background: var(--sequoia-accent-color, #2563eb); 54 + color: #ffffff; 55 + border: none; 56 + border-radius: var(--sequoia-border-radius, 8px); 57 + font-size: 0.875rem; 58 + font-weight: 500; 59 + cursor: pointer; 60 + text-decoration: none; 61 + transition: background-color 0.15s ease; 62 + font-family: inherit; 63 + } 64 + 65 + .sequoia-subscribe-button:hover:not(:disabled) { 66 + background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); 67 + } 68 + 69 + .sequoia-subscribe-button:disabled { 70 + opacity: 0.6; 71 + cursor: not-allowed; 72 + } 73 + 74 + .sequoia-subscribe-button svg { 75 + width: 1rem; 76 + height: 1rem; 77 + flex-shrink: 0; 78 + } 79 + 80 + .sequoia-subscribe-button--success { 81 + background: #16a34a; 82 + } 83 + 84 + .sequoia-subscribe-button--success:hover:not(:disabled) { 85 + background: color-mix(in srgb, #16a34a 85%, black); 86 + } 87 + 88 + .sequoia-loading-spinner { 89 + display: inline-block; 90 + width: 1rem; 91 + height: 1rem; 92 + border: 2px solid rgba(255, 255, 255, 0.4); 93 + border-top-color: #ffffff; 94 + border-radius: 50%; 95 + animation: sequoia-spin 0.8s linear infinite; 96 + flex-shrink: 0; 97 + } 98 + 99 + @keyframes sequoia-spin { 100 + to { transform: rotate(360deg); } 101 + } 102 + 103 + .sequoia-error-message { 104 + display: inline-block; 105 + font-size: 0.8125rem; 106 + color: #dc2626; 107 + margin-top: 0.375rem; 108 + } 109 + `; 110 + 111 + // ============================================================================ 112 + // Icons 113 + // ============================================================================ 114 + 115 + const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 116 + <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"/> 117 + </svg>`; 118 + 119 + const CHECK_ICON = `<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 120 + <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"/> 121 + </svg>`; 122 + 123 + // ============================================================================ 124 + // AT Protocol Functions 125 + // ============================================================================ 126 + 127 + /** 128 + * Resolve a DID to its PDS URL. 129 + * Supports did:plc and did:web methods. 130 + * @param {string} did - Decentralized Identifier 131 + * @returns {Promise<string>} PDS URL 132 + */ 133 + async function resolvePDS(did) { 134 + let pdsUrl; 135 + 136 + if (did.startsWith("did:plc:")) { 137 + const didDocUrl = `https://plc.directory/${did}`; 138 + const didDocResponse = await fetch(didDocUrl); 139 + if (!didDocResponse.ok) { 140 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 141 + } 142 + const didDoc = await didDocResponse.json(); 143 + 144 + const pdsService = didDoc.service?.find( 145 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 146 + ); 147 + pdsUrl = pdsService?.serviceEndpoint; 148 + } else if (did.startsWith("did:web:")) { 149 + const domain = did.replace("did:web:", ""); 150 + const didDocUrl = `https://${domain}/.well-known/did.json`; 151 + const didDocResponse = await fetch(didDocUrl); 152 + if (!didDocResponse.ok) { 153 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 154 + } 155 + const didDoc = await didDocResponse.json(); 156 + 157 + const pdsService = didDoc.service?.find( 158 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 159 + ); 160 + pdsUrl = pdsService?.serviceEndpoint; 161 + } else { 162 + throw new Error(`Unsupported DID method: ${did}`); 163 + } 164 + 165 + if (!pdsUrl) { 166 + throw new Error("Could not find PDS URL for user"); 167 + } 168 + 169 + return pdsUrl; 170 + } 171 + 172 + /** 173 + * Create a site.standard.graph.subscription record in the subscriber's PDS. 174 + * @param {string} did - DID of the subscriber 175 + * @param {string} accessToken - AT Protocol access token 176 + * @param {string} publicationUri - AT URI of the publication to subscribe to 177 + * @returns {Promise<{uri: string, cid: string}>} The created record's URI and CID 178 + */ 179 + async function createRecord(did, accessToken, publicationUri) { 180 + const pdsUrl = await resolvePDS(did); 181 + 182 + const collection = "site.standard.graph.subscription"; 183 + const url = `${pdsUrl}/xrpc/com.atproto.repo.createRecord`; 184 + const response = await fetch(url, { 185 + method: "POST", 186 + headers: { 187 + "Content-Type": "application/json", 188 + Authorization: `Bearer ${accessToken}`, 189 + }, 190 + body: JSON.stringify({ 191 + repo: did, 192 + collection, 193 + record: { 194 + $type: "site.standard.graph.subscription", 195 + publication: publicationUri, 196 + }, 197 + }), 198 + }); 199 + 200 + if (!response.ok) { 201 + const body = await response.json().catch(() => ({})); 202 + const message = body?.message ?? body?.error ?? `HTTP ${response.status}`; 203 + throw new Error(`Failed to create record: ${message}`); 204 + } 205 + 206 + const data = await response.json(); 207 + return { uri: data.uri, cid: data.cid }; 208 + } 209 + 210 + /** 211 + * Fetch the publication AT URI from the host site's well-known endpoint. 212 + * @param {string} [origin] - Origin to fetch from (defaults to current page origin) 213 + * @returns {Promise<string>} Publication AT URI 214 + */ 215 + async function fetchPublicationUri(origin) { 216 + const base = origin ?? window.location.origin; 217 + const url = `${base}/.well-known/site.standard.publication`; 218 + const response = await fetch(url); 219 + if (!response.ok) { 220 + throw new Error( 221 + `Could not fetch publication URI: ${response.status}`, 222 + ); 223 + } 224 + 225 + // Accept either plain text (the AT URI itself) or JSON with a `uri` field. 226 + const contentType = response.headers.get("content-type") ?? ""; 227 + if (contentType.includes("application/json")) { 228 + const data = await response.json(); 229 + const uri = data?.uri ?? data?.atUri ?? data?.publication; 230 + if (!uri) { 231 + throw new Error("Publication response did not contain a URI"); 232 + } 233 + return uri; 234 + } 235 + 236 + const text = (await response.text()).trim(); 237 + if (!text.startsWith("at://")) { 238 + throw new Error(`Unexpected publication URI format: ${text}`); 239 + } 240 + return text; 241 + } 242 + 243 + // ============================================================================ 244 + // Web Component 245 + // ============================================================================ 246 + 247 + // SSR-safe base class - use HTMLElement in browser, empty class in Node.js 248 + const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 249 + 250 + class SequoiaSubscribe extends BaseElement { 251 + constructor() { 252 + super(); 253 + const shadow = this.attachShadow({ mode: "open" }); 254 + 255 + const styleTag = document.createElement("style"); 256 + styleTag.innerText = styles; 257 + shadow.appendChild(styleTag); 258 + 259 + const wrapper = document.createElement("div"); 260 + shadow.appendChild(wrapper); 261 + wrapper.part = "container"; 262 + 263 + this.wrapper = wrapper; 264 + this.state = { type: "idle" }; 265 + this.render(); 266 + } 267 + 268 + static get observedAttributes() { 269 + return ["publication-uri", "label"]; 270 + } 271 + 272 + attributeChangedCallback() { 273 + // Reset to idle if attributes change after an error or success 274 + if ( 275 + this.state.type === "error" || 276 + this.state.type === "subscribed" 277 + ) { 278 + this.state = { type: "idle" }; 279 + } 280 + this.render(); 281 + } 282 + 283 + get publicationUri() { 284 + return this.getAttribute("publication-uri") ?? null; 285 + } 286 + 287 + get label() { 288 + return this.getAttribute("label") ?? "Subscribe on Bluesky"; 289 + } 290 + 291 + async handleClick() { 292 + if (this.state.type === "loading" || this.state.type === "subscribed") { 293 + return; 294 + } 295 + 296 + this.state = { type: "loading" }; 297 + this.render(); 298 + 299 + try { 300 + // Resolve the publication AT URI 301 + const publicationUri = 302 + this.publicationUri ?? (await fetchPublicationUri()); 303 + 304 + // TODO: resolve authenticated DID and access token before calling createRecord 305 + const { uri: recordUri } = await createRecord( 306 + /* did */ undefined, 307 + /* accessToken */ undefined, 308 + publicationUri, 309 + ); 310 + 311 + this.state = { type: "subscribed", recordUri, publicationUri }; 312 + this.render(); 313 + 314 + this.dispatchEvent( 315 + new CustomEvent("sequoia-subscribed", { 316 + bubbles: true, 317 + composed: true, 318 + detail: { publicationUri, recordUri }, 319 + }), 320 + ); 321 + } catch (error) { 322 + const message = 323 + error instanceof Error ? error.message : "Failed to subscribe"; 324 + this.state = { type: "error", message }; 325 + this.render(); 326 + 327 + this.dispatchEvent( 328 + new CustomEvent("sequoia-subscribe-error", { 329 + bubbles: true, 330 + composed: true, 331 + detail: { message }, 332 + }), 333 + ); 334 + } 335 + } 336 + 337 + render() { 338 + const { type } = this.state; 339 + const isLoading = type === "loading"; 340 + const isSubscribed = type === "subscribed"; 341 + 342 + const icon = isLoading 343 + ? `<span class="sequoia-loading-spinner"></span>` 344 + : isSubscribed 345 + ? CHECK_ICON 346 + : BLUESKY_ICON; 347 + 348 + const label = isSubscribed ? "Subscribed" : this.label; 349 + const buttonClass = [ 350 + "sequoia-subscribe-button", 351 + isSubscribed ? "sequoia-subscribe-button--success" : "", 352 + ] 353 + .filter(Boolean) 354 + .join(" "); 355 + 356 + const errorHtml = 357 + type === "error" 358 + ? `<span class="sequoia-error-message">${escapeHtml(this.state.message)}</span>` 359 + : ""; 360 + 361 + this.wrapper.innerHTML = ` 362 + <button 363 + class="${buttonClass}" 364 + type="button" 365 + part="button" 366 + ${isLoading || isSubscribed ? "disabled" : ""} 367 + aria-label="${isSubscribed ? "Subscribed" : this.label}" 368 + > 369 + ${icon} 370 + ${label} 371 + </button> 372 + ${errorHtml} 373 + `; 374 + 375 + if (type !== "subscribed") { 376 + const btn = this.wrapper.querySelector("button"); 377 + btn?.addEventListener("click", () => this.handleClick()); 378 + } 379 + } 380 + } 381 + 382 + /** 383 + * Escape HTML special characters (no DOM dependency for SSR). 384 + * @param {string} text 385 + * @returns {string} 386 + */ 387 + function escapeHtml(text) { 388 + return text 389 + .replace(/&/g, "&amp;") 390 + .replace(/</g, "&lt;") 391 + .replace(/>/g, "&gt;") 392 + .replace(/"/g, "&quot;"); 393 + } 394 + 395 + // Register the custom element 396 + if (typeof customElements !== "undefined") { 397 + customElements.define("sequoia-subscribe", SequoiaSubscribe); 398 + } 399 + 400 + // Export for module usage 401 + export { SequoiaSubscribe };