packages/cli/src/components/sequoia-subscribe.js 30.1 K raw
1
/**
2
 * Sequoia Web Components — AT Protocol-powered engagement components
3
 *
4
 * Self-contained Web Components for subscribing to publications and
5
 * recommending documents via the AT Protocol.
6
 *
7
 * Both components share:
8
 *   - OAuth redirect flow via a hosted callback endpoint
9
 *   - DID caching in a cookie (primary) and localStorage (fallback)
10
 *   - A common visual style driven by CSS custom properties
11
 *
12
 * CSS Custom Properties (apply to both components):
13
 *   - --sequoia-fg-color: Text color (default: #1f2937)
14
 *   - --sequoia-bg-color: Background color (default: #ffffff)
15
 *   - --sequoia-border-color: Border color (default: #e5e7eb)
16
 *   - --sequoia-accent-color: Accent/button color (default: #2563eb)
17
 *   - --sequoia-secondary-color: Secondary text color (default: #6b7280)
18
 *   - --sequoia-border-radius: Border radius (default: 8px)
19
 *   - --sequoia-icon-display: Icon display mode (default: inline-block) — set to "none" to hide
20
 */
21
22
// ============================================================================
23
// Styles
24
// ============================================================================
25
26
const styles = `
27
:host {
28
	display: inline-block;
29
	font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
30
	color: var(--sequoia-fg-color, #1f2937);
31
	line-height: 1.5;
32
}
33
34
* {
35
	box-sizing: border-box;
36
}
37
38
.sequoia-button {
39
	display: inline-flex;
40
	align-items: center;
41
	gap: 0.375rem;
42
	padding: 0.5rem 1rem;
43
	background: var(--sequoia-accent-color, #2563eb);
44
	color: #ffffff;
45
	border: none;
46
	border-radius: var(--sequoia-border-radius, 8px);
47
	font-size: 0.875rem;
48
	font-weight: 500;
49
	cursor: pointer;
50
	text-decoration: none;
51
	transition: background-color 0.15s ease;
52
	font-family: inherit;
53
}
54
55
.sequoia-button:hover:not(:disabled) {
56
	background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
57
}
58
59
.sequoia-button:disabled {
60
	opacity: 0.6;
61
	cursor: not-allowed;
62
}
63
64
.sequoia-button svg {
65
	display: var(--sequoia-icon-display, inline-block);
66
	width: 1rem;
67
	height: 1rem;
68
	flex-shrink: 0;
69
}
70
71
.sequoia-loading-spinner {
72
	display: inline-block;
73
	width: 1rem;
74
	height: 1rem;
75
	border: 2px solid rgba(255, 255, 255, 0.4);
76
	border-top-color: #ffffff;
77
	border-radius: 50%;
78
	animation: sequoia-spin 0.8s linear infinite;
79
	flex-shrink: 0;
80
}
81
82
@keyframes sequoia-spin {
83
	to { transform: rotate(360deg); }
84
}
85
86
.sequoia-error-message {
87
	display: inline-block;
88
	font-size: 0.8125rem;
89
	color: #dc2626;
90
	margin-top: 0.375rem;
91
}
92
`;
93
94
// ============================================================================
95
// Icons
96
// ============================================================================
97
98
const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
99
  <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"/>
100
</svg>`;
101
102
const BLACKSKY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.0620117 0.348442 87.9941 74.9653" fill="currentColor"><path d="M41.9565 74.9643L24.0161 74.9653L41.9565 74.9643ZM63.8511 74.9653H45.9097L63.8501 74.9643V57.3286H63.8511V74.9653ZM45.9097 44.5893C45.9099 49.2737 49.7077 53.0707 54.3921 53.0707H63.8501V57.3286H54.3921C49.7077 57.3286 45.9099 61.1257 45.9097 65.81V74.9643H41.9565V65.81C41.9563 61.1258 38.1593 57.3287 33.4751 57.3286H24.0161V53.0707H33.4741C38.1587 53.0707 41.9565 49.2729 41.9565 44.5883V35.1303H45.9097V44.5893ZM63.8511 53.0707H63.8501V35.1303H63.8511V53.0707Z"/><path d="M52.7272 9.83198C49.4148 13.1445 49.4148 18.5151 52.7272 21.8275L59.4155 28.5158L56.4051 31.5262L49.7169 24.8379C46.4044 21.5254 41.0338 21.5254 37.7213 24.8379L31.2482 31.3111L28.4527 28.5156L34.9259 22.0424C38.2383 18.7299 38.2383 13.3594 34.9259 10.0469L28.2378 3.35883L31.2482 0.348442L37.9365 7.03672C41.2489 10.3492 46.6195 10.3492 49.932 7.03672L56.6203 0.348442L59.4155 3.14371L52.7272 9.83198Z"/><path d="M24.3831 23.2335C23.1706 27.7584 25.8559 32.4095 30.3808 33.6219L39.5172 36.07L38.4154 40.182L29.2793 37.734C24.7544 36.5215 20.1033 39.2068 18.8909 43.7317L16.5215 52.5745L12.7028 51.5513L15.0721 42.7088C16.2846 38.1839 13.5993 33.5328 9.07434 32.3204L-0.0620117 29.8723L1.03987 25.76L10.1762 28.2081C14.7011 29.4206 19.3522 26.7352 20.5647 22.2103L23.0127 13.074L26.8311 14.0971L24.3831 23.2335Z"/><path d="M67.3676 22.0297C68.5801 26.5546 73.2311 29.2399 77.756 28.0275L86.8923 25.5794L87.9941 29.6914L78.8578 32.1394C74.3329 33.3519 71.6476 38.003 72.86 42.5279L75.2294 51.3707L71.411 52.3938L69.0417 43.5513C67.8293 39.0264 63.1782 36.3411 58.6533 37.5535L49.5169 40.0016L48.415 35.8894L57.5514 33.4413C62.0763 32.2288 64.7616 27.5778 63.5492 23.0528L61.1011 13.9165L64.9195 12.8934L67.3676 22.0297Z"/></svg>`;
103
104
const SEQUOIA_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 95 151" fill="none" stroke="currentColor" stroke-width="10.5" stroke-linecap="round" stroke-linejoin="round"><path d="M47.25 145.217V54.2167M68.25 111.596C74.6356 107.909 79.9382 102.606 83.6245 96.2201C87.3108 89.8341 89.251 82.5902 89.25 75.2167C89.2641 64.2875 85.0033 53.7863 77.378 45.9567C78.8172 41.2475 79.1324 36.2663 78.2981 31.4132C77.4638 26.5601 75.5033 21.9701 72.574 18.0118C69.6448 14.0535 65.8283 10.8371 61.4309 8.62081C57.0335 6.4045 52.1778 5.25 47.2535 5.25C42.3292 5.25 37.4734 6.4045 33.0761 8.62081C28.6787 10.8371 24.8622 14.0535 21.9329 18.0118C19.0037 21.9701 17.0432 26.5601 16.2089 31.4132C15.3746 36.2663 15.6897 41.2475 17.129 45.9567C9.50114 53.7851 5.23776 64.2866 5.25003 75.2167C5.25003 90.7567 13.699 104.337 26.25 111.596M47.25 96.2167L64.75 78.7167M47.25 82.2167L29.75 64.7167M33.25 145.217H61.25"/></svg>`;
105
106
const ATMOSPHERE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 114 114" fill="currentColor"><path d="M56.9119 114C48.655 114 41.0566 112.632 34.1167 109.896C27.1769 107.16 21.1488 103.284 16.0326 98.268C10.9125 93.2969 6.87661 87.3195 4.17911 80.712C1.39304 73.9733 0 66.652 0 58.748C0 49.324 1.44369 40.964 4.33108 33.668C7.26912 26.372 11.3216 20.2413 16.4885 15.276C21.6743 10.2798 27.829 6.39999 34.5726 3.876C41.4618 1.292 48.8322 0 56.6839 0C66.3085 0 74.7427 1.49467 81.9865 4.484C89.2303 7.47333 95.2583 11.5267 100.071 16.644C104.833 21.6798 108.483 27.6613 110.784 34.2C113.115 40.736 114.178 47.576 113.976 54.72C113.722 64.5493 111.671 72.0986 107.821 77.368C103.971 82.5866 97.9938 85.196 89.8888 85.196C85.7259 85.2315 81.6218 84.2118 77.9594 82.232C74.4587 80.3688 71.7969 77.2446 70.513 73.492L74.92 73.72C72.8431 77.6213 70.0064 80.3573 66.4098 81.928C62.9411 83.4714 59.1886 84.2738 55.3922 84.284C50.2759 84.284 45.7676 83.1946 41.8671 81.016C37.9939 78.8144 34.8103 75.5775 32.673 71.668C30.4442 67.6653 29.3297 63.0293 29.3297 57.76C29.3297 52.3387 30.4948 47.652 32.825 43.7C35.0512 39.7998 38.3119 36.5909 42.247 34.428C46.1981 32.2493 50.6559 31.16 55.6201 31.16C58.9128 31.16 62.332 31.844 65.8779 33.212C69.4745 34.58 72.2606 36.5053 74.2362 38.988L71.1208 42.94V33.288H81.3027L81.0747 60.572C81.0747 64.4733 81.8345 67.412 83.3542 69.388C84.8739 71.364 87.1281 72.352 90.1168 72.352C92.7509 72.352 94.7771 71.6173 96.1955 70.148C97.6645 68.628 98.6776 66.576 99.2348 63.992C99.8841 61.0707 100.24 58.092 100.299 55.1C100.451 47.2467 99.2855 40.6347 96.8033 35.264C94.3212 29.8933 90.9526 25.5613 86.6975 22.268C82.5822 18.9703 77.857 16.5168 72.7925 15.048C67.7269 13.528 62.6866 12.768 57.6717 12.768C50.5799 12.768 44.2732 13.908 38.7517 16.188C33.2302 18.4173 28.5699 21.584 24.7707 25.688C21.0222 29.7413 18.1855 34.5547 16.2605 40.128C14.3863 45.6507 13.4998 51.7307 13.6011 58.368C13.8037 64.9547 14.9941 70.8826 17.1723 76.152C19.2339 81.2487 22.3399 85.857 26.2904 89.68C30.2557 93.4697 34.9649 96.3941 40.1194 98.268C45.4383 100.244 51.2637 101.232 57.5957 101.232C61.1416 101.232 64.6622 100.827 68.1575 100.016C71.7034 99.256 74.9453 98.1666 77.8834 96.748L82.2145 108.604C78.314 110.428 74.2108 111.771 69.9051 112.632C65.6345 113.546 61.2791 114.004 56.9119 114ZM56.304 71.364C59.9006 71.364 62.9146 70.3253 65.3461 68.248C67.7775 66.1706 68.9933 62.6493 68.9933 57.684C68.9933 53.1747 67.9042 49.78 65.726 47.5C63.5984 45.1693 60.5844 44.004 56.6839 44.004C52.0742 44.004 48.6296 45.22 46.3501 47.652C44.0706 50.084 42.9308 53.428 42.9308 57.684C42.9308 62.0413 44.0959 65.4106 46.4261 67.792C48.8069 70.1733 52.0996 71.364 56.304 71.364Z"/></svg>`;
107
108
// ============================================================================
109
// Button Type Configuration
110
// ============================================================================
111
112
const BUTTON_TYPES = {
113
	sequoia: {
114
		icon: SEQUOIA_ICON,
115
		subscribe: "Subscribe on Sequoia",
116
		unsubscribe: "Unsubscribe",
117
	},
118
	bluesky: {
119
		icon: BLUESKY_ICON,
120
		subscribe: "Subscribe on Bluesky",
121
		unsubscribe: "Unsubscribe",
122
	},
123
	blacksky: {
124
		icon: BLACKSKY_ICON,
125
		subscribe: "Subscribe on Blacksky",
126
		unsubscribe: "Unsubscribe",
127
	},
128
	atmosphere: {
129
		icon: ATMOSPHERE_ICON,
130
		subscribe: "Subscribe on Atmosphere",
131
		unsubscribe: "Unsubscribe",
132
	},
133
	plain: { icon: "", subscribe: "Subscribe", unsubscribe: "Unsubscribe" },
134
};
135
136
// ============================================================================
137
// Recommend Icon Configuration
138
// ============================================================================
139
140
const HEART_PATH =
141
	"M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z";
142
const HEART_ICON_OUTLINED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${HEART_PATH}"/></svg>`;
143
const HEART_ICON_FILLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="${HEART_PATH}"/></svg>`;
144
145
const STAR_PATH =
146
	"M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z";
147
const STAR_ICON_OUTLINED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="${STAR_PATH}"/></svg>`;
148
const STAR_ICON_FILLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"><path d="${STAR_PATH}"/></svg>`;
149
150
const THUMBS_UP_RECT_PATH = "M1 21h4V9H1v12z";
151
const THUMBS_UP_HAND_PATH =
152
	"M23 10c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z";
153
const THUMBS_UP_ICON_OUTLINED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="${THUMBS_UP_RECT_PATH}" fill="currentColor"/><path d="${THUMBS_UP_HAND_PATH}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>`;
154
const THUMBS_UP_ICON_FILLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="${THUMBS_UP_RECT_PATH}"/><path d="${THUMBS_UP_HAND_PATH}"/></svg>`;
155
156
const RECOMMEND_ICON_TYPES = {
157
	heart: {
158
		icon: HEART_ICON_OUTLINED,
159
		iconActioned: HEART_ICON_FILLED,
160
		action: "Recommend",
161
		unaction: "Unrecommend",
162
	},
163
	star: {
164
		icon: STAR_ICON_OUTLINED,
165
		iconActioned: STAR_ICON_FILLED,
166
		action: "Recommend",
167
		unaction: "Unrecommend",
168
	},
169
	"thumbs-up": {
170
		icon: THUMBS_UP_ICON_OUTLINED,
171
		iconActioned: THUMBS_UP_ICON_FILLED,
172
		action: "Recommend",
173
		unaction: "Unrecommend",
174
	},
175
};
176
177
// ============================================================================
178
// DID Storage
179
// ============================================================================
180
181
/**
182
 * Store the subscriber DID. Tries a cookie first; falls back to localStorage.
183
 * @param {string} did
184
 */
185
function storeSubscriberDid(did) {
186
	try {
187
		const expires = new Date(
188
			Date.now() + 365 * 24 * 60 * 60 * 1000,
189
		).toUTCString();
190
		// biome-ignore lint/suspicious/noDocumentCookie: back-compat with older browsers
191
		document.cookie = `sequoia_did=${encodeURIComponent(did)}; Expires=${expires}; Path=/; SameSite=Lax; Secure`;
192
	} catch {
193
		// Cookie write may fail in some embedded contexts
194
	}
195
	try {
196
		localStorage.setItem("sequoia_did", did);
197
	} catch {
198
		// localStorage may be unavailable
199
	}
200
}
201
202
/**
203
 * Retrieve the stored subscriber DID. Checks cookie first, then localStorage.
204
 * @returns {string | null}
205
 */
206
function getStoredSubscriberDid() {
207
	try {
208
		const match = document.cookie.match(/(?:^|;\s*)sequoia_did=([^;]+)/);
209
		if (match) {
210
			const did = decodeURIComponent(match[1]);
211
			if (did.startsWith("did:")) return did;
212
		}
213
	} catch {
214
		// ignore
215
	}
216
	try {
217
		const did = localStorage.getItem("sequoia_did");
218
		if (did?.startsWith("did:")) return did;
219
	} catch {
220
		// ignore
221
	}
222
	return null;
223
}
224
225
/**
226
 * Remove the stored subscriber DID from both cookie and localStorage.
227
 */
228
function clearSubscriberDid() {
229
	try {
230
		// biome-ignore lint/suspicious/noDocumentCookie: back-compat with older browsers
231
		document.cookie =
232
			"sequoia_did=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax; Secure";
233
	} catch {
234
		// ignore
235
	}
236
	try {
237
		localStorage.removeItem("sequoia_did");
238
	} catch {
239
		// ignore
240
	}
241
}
242
243
/**
244
 * Check the current page URL for sequoia_did / sequoia_unsubscribed params
245
 * set by the subscribe redirect flow. Consumes them by removing from the URL.
246
 */
247
function consumeReturnParams() {
248
	const url = new URL(window.location.href);
249
	const did = url.searchParams.get("sequoia_did");
250
	const unsubscribed = url.searchParams.get("sequoia_unsubscribed");
251
252
	let changed = false;
253
254
	if (unsubscribed === "1") {
255
		clearSubscriberDid();
256
		url.searchParams.delete("sequoia_unsubscribed");
257
		changed = true;
258
	}
259
260
	if (did?.startsWith("did:")) {
261
		storeSubscriberDid(did);
262
		url.searchParams.delete("sequoia_did");
263
		changed = true;
264
	}
265
266
	if (changed) {
267
		const cleanUrl = url.pathname + (url.search || "") + (url.hash || "");
268
		try {
269
			window.history.replaceState(null, "", cleanUrl);
270
		} catch {
271
			// ignore
272
		}
273
	}
274
}
275
276
// ============================================================================
277
// AT Protocol Functions
278
// ============================================================================
279
280
/**
281
 * Fetch the publication AT URI from the host site's well-known endpoint.
282
 * @param {string} [origin] - Origin to fetch from (defaults to current page origin)
283
 * @returns {Promise<string>} Publication AT URI
284
 */
285
async function fetchPublicationUri(origin) {
286
	const base = origin ?? window.location.origin;
287
	const url = `${base}/.well-known/site.standard.publication`;
288
	const response = await fetch(url);
289
	if (!response.ok) {
290
		throw new Error(`Could not fetch publication URI: ${response.status}`);
291
	}
292
293
	// Accept either plain text (the AT URI itself) or JSON with a `uri` field.
294
	const contentType = response.headers.get("content-type") ?? "";
295
	if (contentType.includes("application/json")) {
296
		const data = await response.json();
297
		const uri = data?.uri ?? data?.atUri ?? data?.publication;
298
		if (!uri) {
299
			throw new Error("Publication response did not contain a URI");
300
		}
301
		return uri;
302
	}
303
304
	const text = (await response.text()).trim();
305
	if (!text.startsWith("at://")) {
306
		throw new Error(`Unexpected publication URI format: ${text}`);
307
	}
308
	return text;
309
}
310
311
// ============================================================================
312
// Web Component
313
// ============================================================================
314
315
// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
316
const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
317
318
/**
319
 * Abstract base class shared by SequoiaSubscribe and SequoiaRecommend.
320
 * Handles shadow DOM setup, state management, the OAuth redirect flow,
321
 * DID storage, and button rendering. Subclasses implement template methods
322
 * to provide resource-specific behaviour.
323
 */
324
class SequoiaActionBase extends BaseElement {
325
	constructor() {
326
		super();
327
		const shadow = this.attachShadow({ mode: "open" });
328
329
		const styleTag = document.createElement("style");
330
		styleTag.innerText = styles;
331
		shadow.appendChild(styleTag);
332
333
		const wrapper = document.createElement("div");
334
		shadow.appendChild(wrapper);
335
		wrapper.part = "container";
336
337
		this.wrapper = wrapper;
338
		this.actioned = false;
339
		this.state = { type: "idle" };
340
		this.abortController = null;
341
		this.render();
342
	}
343
344
	disconnectedCallback() {
345
		this.abortController?.abort();
346
	}
347
348
	attributeChangedCallback() {
349
		if (this.state.type === "error" || this.state.type === "no-resource") {
350
			this.state = { type: "idle" };
351
		}
352
		this.render();
353
	}
354
355
	// ── Shared getters ───────────────────────────────────────────────────────
356
357
	get callbackUri() {
358
		return this.getAttribute("callback-uri") ?? this.defaultCallbackUri;
359
	}
360
361
	get hide() {
362
		return this.getAttribute("hide") === "auto";
363
	}
364
365
	// ── Template methods (override in subclasses) ────────────────────────────
366
367
	/** @returns {string} Default callback URI when the attribute is absent */
368
	get defaultCallbackUri() {
369
		return "";
370
	}
371
372
	/** @returns {string} Query-parameter name for the resource URI */
373
	get resourceParam() {
374
		return "resourceUri";
375
	}
376
377
	/**
378
	 * Value of the `action` query-parameter used in the unaction redirect.
379
	 * @returns {string}
380
	 */
381
	get unactionValue() {
382
		return "unaction";
383
	}
384
385
	/** @returns {string} Key in the /check response that signals the action was taken */
386
	get actionedKey() {
387
		return "actioned";
388
	}
389
390
	/** @returns {string} CustomEvent name dispatched on success */
391
	get actionedEventName() {
392
		return "sequoia-actioned";
393
	}
394
395
	/** @returns {string} CustomEvent name dispatched on error */
396
	get errorEventName() {
397
		return "sequoia-action-error";
398
	}
399
400
	/** @returns {string} Fallback error message when the thrown value has no message */
401
	get defaultErrorMessage() {
402
		return "Action failed";
403
	}
404
405
	/** @returns {string} SVG string for the button icon */
406
	getIcon() {
407
		return "";
408
	}
409
410
	/** @returns {string} Accessible label for the button (defaults to the visible label) */
411
	getAriaLabel() {
412
		return this.actioned
413
			? (this.getUnactionLabel?.() ?? this.getDefaultUnactionLabel?.() ?? "")
414
			: (this.label ?? this.getDefaultActionLabel?.() ?? "");
415
	}
416
417
	/**
418
	 * Resolve the resource URI for this action. May perform async network calls.
419
	 * @returns {Promise<string>}
420
	 */
421
	async resolveResourceUri() {
422
		throw new Error("resolveResourceUri() must be implemented by subclass");
423
	}
424
425
	// ── Shared logic ─────────────────────────────────────────────────────────
426
427
	/**
428
	 * Check whether the current user has already taken this action for the
429
	 * given resource URI. Updates this.actioned and re-renders on success.
430
	 * @param {string} resourceUri
431
	 */
432
	async checkStatusFor(resourceUri) {
433
		try {
434
			const checkUrl = new URL(`${this.callbackUri}/check`);
435
			checkUrl.searchParams.set(this.resourceParam, resourceUri);
436
437
			// Pass the stored DID so the server can check without a session cookie
438
			const storedDid = getStoredSubscriberDid();
439
			if (storedDid) {
440
				checkUrl.searchParams.set("did", storedDid);
441
			}
442
443
			const res = await fetch(checkUrl.toString(), {
444
				credentials: "include",
445
			});
446
			if (!res.ok) return;
447
			const data = await res.json();
448
			if (data[this.actionedKey]) {
449
				this.actioned = true;
450
				this.render();
451
			}
452
		} catch {
453
			// Ignore errors — show default action button
454
		}
455
	}
456
457
	async handleClick() {
458
		if (this.state.type === "loading") {
459
			return;
460
		}
461
462
		// Unaction: redirect to the full-page unaction flow
463
		if (this.actioned) {
464
			const resourceUri = await this.resolveResourceUri();
465
			window.location.href = `${this.callbackUri}?${this.resourceParam}=${encodeURIComponent(resourceUri)}&action=${this.unactionValue}`;
466
			return;
467
		}
468
469
		this.state = { type: "loading" };
470
		this.render();
471
472
		try {
473
			const resourceUri = await this.resolveResourceUri();
474
475
			const response = await fetch(this.callbackUri, {
476
				method: "POST",
477
				headers: { "Content-Type": "application/json" },
478
				credentials: "include",
479
				referrerPolicy: "no-referrer-when-downgrade",
480
				body: JSON.stringify({ [this.resourceParam]: resourceUri }),
481
			});
482
483
			const data = await response.json();
484
485
			if (response.status === 401 && data.authenticated === false) {
486
				// Redirect to the hosted action page to complete OAuth,
487
				// passing the current page URL (without credentials) as returnTo.
488
				const actionUrl = new URL(data.subscribeUrl);
489
				const pageUrl = new URL(window.location.href);
490
				pageUrl.username = "";
491
				pageUrl.password = "";
492
				actionUrl.searchParams.set("returnTo", pageUrl.toString());
493
				window.location.href = actionUrl.toString();
494
				return;
495
			}
496
497
			if (!response.ok) {
498
				throw new Error(data.error ?? `HTTP ${response.status}`);
499
			}
500
501
			const { recordUri } = data;
502
503
			// Store the DID from the record URI (at://did:aaa:bbb/...)
504
			if (recordUri) {
505
				const didMatch = recordUri.match(/^at:\/\/(did:[^/]+)/);
506
				if (didMatch) {
507
					storeSubscriberDid(didMatch[1]);
508
				}
509
			}
510
511
			this.actioned = true;
512
			this.state = { type: "idle" };
513
			this.render();
514
515
			this.dispatchEvent(
516
				new CustomEvent(this.actionedEventName, {
517
					bubbles: true,
518
					composed: true,
519
					detail: { [this.resourceParam]: resourceUri, recordUri },
520
				}),
521
			);
522
		} catch (error) {
523
			if (this.state.type !== "loading") return;
524
525
			const message =
526
				error instanceof Error ? error.message : this.defaultErrorMessage;
527
			this.state = { type: "error", message };
528
			this.render();
529
530
			this.dispatchEvent(
531
				new CustomEvent(this.errorEventName, {
532
					bubbles: true,
533
					composed: true,
534
					detail: { message },
535
				}),
536
			);
537
		}
538
	}
539
540
	render() {
541
		const { type } = this.state;
542
543
		if (type === "no-resource") {
544
			if (this.hide) {
545
				this.wrapper.innerHTML = "";
546
				this.wrapper.style.display = "none";
547
			}
548
			return;
549
		}
550
551
		const isLoading = type === "loading";
552
		const icon = isLoading
553
			? `<span class="sequoia-loading-spinner"></span>`
554
			: this.getIcon();
555
556
		const label = this.actioned
557
			? (this.getUnactionLabel?.() ?? this.getDefaultUnactionLabel?.() ?? "")
558
			: (this.label ?? this.getDefaultActionLabel?.() ?? "");
559
560
		const ariaLabel = this.getAriaLabel();
561
562
		const errorHtml =
563
			type === "error"
564
				? `<span class="sequoia-error-message">${escapeHtml(this.state.message)}</span>`
565
				: "";
566
567
		this.wrapper.innerHTML = `
568
			<button
569
				class="sequoia-button"
570
				type="button"
571
				part="button"
572
				${isLoading ? "disabled" : ""}
573
				aria-label="${ariaLabel}"
574
			>
575
				${icon}
576
				${label}
577
			</button>
578
			${errorHtml}
579
		`;
580
581
		const btn = this.wrapper.querySelector("button");
582
		btn?.addEventListener("click", () => this.handleClick());
583
	}
584
}
585
586
class SequoiaSubscribe extends SequoiaActionBase {
587
	static get observedAttributes() {
588
		return [
589
			"publication-uri",
590
			"callback-uri",
591
			"label",
592
			"unsubscribe-label",
593
			"button-type",
594
			"hide",
595
		];
596
	}
597
598
	connectedCallback() {
599
		consumeReturnParams();
600
		this.checkPublication();
601
	}
602
603
	get publicationUri() {
604
		return this.getAttribute("publication-uri") ?? null;
605
	}
606
607
	get label() {
608
		return this.getAttribute("label") ?? null;
609
	}
610
611
	get buttonType() {
612
		const val = this.getAttribute("button-type");
613
		return val && val in BUTTON_TYPES ? val : "sequoia";
614
	}
615
616
	get unsubscribeLabel() {
617
		return this.getAttribute("unsubscribe-label") ?? null;
618
	}
619
620
	// ── Template method overrides ────────────────────────────────────────────
621
622
	get defaultCallbackUri() {
623
		return "https://sequoia.pub/subscribe";
624
	}
625
	get resourceParam() {
626
		return "publicationUri";
627
	}
628
	get unactionValue() {
629
		return "unsubscribe";
630
	}
631
	get actionedKey() {
632
		return "subscribed";
633
	}
634
	get actionedEventName() {
635
		return "sequoia-subscribed";
636
	}
637
	get errorEventName() {
638
		return "sequoia-subscribe-error";
639
	}
640
	get defaultErrorMessage() {
641
		return "Failed to subscribe";
642
	}
643
644
	getDefaultActionLabel() {
645
		return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).subscribe;
646
	}
647
648
	getDefaultUnactionLabel() {
649
		return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).unsubscribe;
650
	}
651
652
	getUnactionLabel() {
653
		return this.unsubscribeLabel;
654
	}
655
656
	getIcon() {
657
		return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).icon;
658
	}
659
660
	async resolveResourceUri() {
661
		return this.publicationUri ?? (await fetchPublicationUri());
662
	}
663
664
	// ── SequoiaSubscribe-specific logic ──────────────────────────────────────
665
666
	/** @returns {boolean} Whether the user is currently subscribed. Alias for this.actioned. */
667
	get subscribed() {
668
		return this.actioned;
669
	}
670
671
	/**
672
	 * Check whether the current user is subscribed to the given publication URI.
673
	 * Forwards to the shared checkStatusFor() method.
674
	 * @param {string} publicationUri
675
	 */
676
	checkSubscription(publicationUri) {
677
		return this.checkStatusFor(publicationUri);
678
	}
679
680
	async checkPublication() {
681
		this.abortController?.abort();
682
		this.abortController = new AbortController();
683
684
		try {
685
			const uri = await this.resolveResourceUri();
686
			this.checkStatusFor(uri);
687
		} catch {
688
			this.state = { type: "no-resource" };
689
			this.render();
690
		}
691
	}
692
}
693
694
class SequoiaRecommend extends SequoiaActionBase {
695
	static get observedAttributes() {
696
		return ["document-uri", "callback-uri", "button-type", "hide"];
697
	}
698
699
	connectedCallback() {
700
		consumeReturnParams();
701
		this.checkDocument();
702
	}
703
704
	get documentUri() {
705
		const attrUri = this.getAttribute("document-uri");
706
		if (attrUri) return attrUri;
707
		const linkTag = document.querySelector(
708
			'link[rel="site.standard.document"]',
709
		);
710
		return linkTag?.href ?? null;
711
	}
712
713
	get buttonType() {
714
		const val = this.getAttribute("button-type");
715
		return val && val in RECOMMEND_ICON_TYPES ? val : "heart";
716
	}
717
718
	// ── Template method overrides ────────────────────────────────────────────
719
720
	get defaultCallbackUri() {
721
		return "https://sequoia.pub/recommend";
722
	}
723
	get resourceParam() {
724
		return "documentUri";
725
	}
726
	get unactionValue() {
727
		return "remove";
728
	}
729
	get actionedKey() {
730
		return "recommended";
731
	}
732
	get actionedEventName() {
733
		return "sequoia-recommended";
734
	}
735
	get errorEventName() {
736
		return "sequoia-recommend-error";
737
	}
738
	get defaultErrorMessage() {
739
		return "Failed to recommend";
740
	}
741
742
	getAriaLabel() {
743
		const config =
744
			RECOMMEND_ICON_TYPES[this.buttonType] ?? RECOMMEND_ICON_TYPES.heart;
745
		return this.actioned ? config.unaction : config.action;
746
	}
747
748
	getIcon() {
749
		const config =
750
			RECOMMEND_ICON_TYPES[this.buttonType] ?? RECOMMEND_ICON_TYPES.heart;
751
		return this.actioned ? config.iconActioned : config.icon;
752
	}
753
754
	async resolveResourceUri() {
755
		const uri = this.documentUri;
756
		if (!uri) throw new Error("No document URI found");
757
		return uri;
758
	}
759
760
	// ── SequoiaRecommend-specific logic ──────────────────────────────────────
761
762
	async checkDocument() {
763
		this.abortController?.abort();
764
		this.abortController = new AbortController();
765
766
		const uri = this.documentUri;
767
		if (!uri) {
768
			this.state = { type: "no-resource" };
769
			this.render();
770
			return;
771
		}
772
773
		this.checkStatusFor(uri);
774
	}
775
}
776
777
/**
778
 * Escape HTML special characters (no DOM dependency for SSR).
779
 * @param {string} text
780
 * @returns {string}
781
 */
782
function escapeHtml(text) {
783
	return text
784
		.replace(/&/g, "&amp;")
785
		.replace(/</g, "&lt;")
786
		.replace(/>/g, "&gt;")
787
		.replace(/"/g, "&quot;");
788
}
789
790
// Register the custom elements
791
if (typeof customElements !== "undefined") {
792
	customElements.define("sequoia-subscribe", SequoiaSubscribe);
793
	customElements.define("sequoia-recommend", SequoiaRecommend);
794
}
795
796
/**
797
 * Sequoia Subscribe - An AT Protocol-powered subscribe component
798
 *
799
 * A self-contained Web Component that lets users subscribe to a publication
800
 * via the AT Protocol by creating a site.standard.graph.subscription record.
801
 *
802
 * Usage:
803
 *   <sequoia-subscribe></sequoia-subscribe>
804
 *
805
 * The component resolves the publication AT URI from the host site's
806
 * /.well-known/site.standard.publication endpoint.
807
 *
808
 * Attributes:
809
 *   - publication-uri: Override the publication AT URI (optional)
810
 *   - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/subscribe")
811
 *   - button-type: Branding style — "sequoia" (default), "bluesky", "blacksky", "atmosphere", or "plain"
812
 *   - label: Override the subscribe button label text
813
 *   - unsubscribe-label: Override the unsubscribe button label text
814
 *   - hide: Set to "auto" to hide if no publication URI is detected
815
 *
816
 * Events:
817
 *   - sequoia-subscribed: Fired when the subscription is created successfully.
818
 *     detail: { publicationUri: string, recordUri: string }
819
 *   - sequoia-subscribe-error: Fired when the subscription fails.
820
 *     detail: { message: string }
821
 */
822
export { SequoiaSubscribe };
823
824
/**
825
 * Sequoia Recommend - An AT Protocol-powered recommend component
826
 *
827
 * A self-contained Web Component that lets users recommend a document
828
 * via the AT Protocol by creating a site.standard.graph.recommend record.
829
 *
830
 * Usage:
831
 *   <sequoia-recommend></sequoia-recommend>
832
 *
833
 * The component resolves the document AT URI from the `document-uri` attribute
834
 * or a <link rel="site.standard.document" href="at://..."> tag in the page head.
835
 *
836
 * Attributes:
837
 *   - document-uri: AT Protocol URI of the document to recommend (optional if link tag present)
838
 *   - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/recommend")
839
 *   - button-type: Icon style — "heart" (default), "star", or "thumbs-up"
840
 *   - hide: Set to "auto" to hide if no document URI is detected
841
 *
842
 * Events:
843
 *   - sequoia-recommended: Fired when the recommendation is created successfully.
844
 *     detail: { documentUri: string, recordUri: string }
845
 *   - sequoia-recommend-error: Fired when the recommendation fails.
846
 *     detail: { message: string }
847
 */
848
export { SequoiaRecommend };