this repo has no description
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, "&")
375 .replace(/</g, "<")
376 .replace(/>/g, ">")
377 .replace(/"/g, """);
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 };