packages/cli/src/components/sequoia-subscribe.js 10.7 K 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
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, "&amp;")
368
		.replace(/</g, "&lt;")
369
		.replace(/>/g, "&gt;")
370
		.replace(/"/g, "&quot;");
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 };