Check existing subs and offer to unsubscribe c05003fb
Heath Stewart · 2026-02-28 01:55 2 file(s) · +146 −62
docs/src/routes/subscribe.ts +103 −15
154 154
155 155
subscribe.get("/", async (c) => {
156 156
	const publicationUri = c.req.query("publicationUri");
157 +
	const action = c.req.query("action");
158 +
	const wantsJson = c.req.header("accept")?.includes("application/json");
159 +
160 +
	// JSON path: subscription status check for the web component.
161 +
	if (wantsJson) {
162 +
		if (action && action !== "unsubscribe") {
163 +
			return c.json({ error: `Unsupported action: ${action}` }, 400);
164 +
		}
165 +
		if (!publicationUri || !publicationUri.startsWith("at://")) {
166 +
			return c.json({ error: "Missing or invalid publicationUri" }, 400);
167 +
		}
168 +
		const did = getSessionDid(c);
169 +
		if (!did) {
170 +
			return c.json({ authenticated: false }, 401);
171 +
		}
172 +
		try {
173 +
			const client = createOAuthClient(
174 +
				c.env.SEQUOIA_SESSIONS,
175 +
				c.env.CLIENT_URL,
176 +
			);
177 +
			const session = await client.restore(did);
178 +
			const agent = new Agent(session);
179 +
			const recordUri = await findExistingSubscription(
180 +
				agent,
181 +
				did,
182 +
				publicationUri,
183 +
			);
184 +
			return recordUri
185 +
				? c.json({ subscribed: true, recordUri })
186 +
				: c.json({ subscribed: false });
187 +
		} catch {
188 +
			return c.json({ authenticated: false }, 401);
189 +
		}
190 +
	}
191 +
192 +
	// HTML path: full-page subscribe/unsubscribe flow.
157 193
	const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
158 194
195 +
	if (action && action !== "unsubscribe") {
196 +
		return c.html(renderError(`Unsupported action: ${action}`, styleHref), 400);
197 +
	}
198 +
159 199
	if (!publicationUri || !publicationUri.startsWith("at://")) {
160 200
		return c.html(
161 201
			renderError("Missing or invalid publication URI.", styleHref),
172 212
173 213
	const did = getSessionDid(c);
174 214
	if (!did) {
175 -
		return c.html(renderHandleForm(publicationUri, styleHref, returnTo));
215 +
		return c.html(
216 +
			renderHandleForm(publicationUri, styleHref, returnTo, undefined, action),
217 +
		);
176 218
	}
177 219
178 220
	try {
180 222
		const session = await client.restore(did);
181 223
		const agent = new Agent(session);
182 224
225 +
		if (action === "unsubscribe") {
226 +
			const existingUri = await findExistingSubscription(
227 +
				agent,
228 +
				did,
229 +
				publicationUri,
230 +
			);
231 +
			if (existingUri) {
232 +
				const rkey = existingUri.split("/").pop()!;
233 +
				await agent.com.atproto.repo.deleteRecord({
234 +
					repo: did,
235 +
					collection: COLLECTION,
236 +
					rkey,
237 +
				});
238 +
			}
239 +
			return c.html(
240 +
				renderSuccess(
241 +
					publicationUri,
242 +
					null,
243 +
					"Unsubscribed ✓",
244 +
					existingUri
245 +
						? "You've successfully unsubscribed!"
246 +
						: "You weren't subscribed to this publication.",
247 +
					styleHref,
248 +
					returnTo,
249 +
				),
250 +
			);
251 +
		}
252 +
183 253
		const existingUri = await findExistingSubscription(
184 254
			agent,
185 255
			did,
187 257
		);
188 258
		if (existingUri) {
189 259
			return c.html(
190 -
				renderSuccess(publicationUri, existingUri, true, styleHref, returnTo),
260 +
				renderSuccess(
261 +
					publicationUri,
262 +
					existingUri,
263 +
					"Subscribed ✓",
264 +
					"You're already subscribed to this publication.",
265 +
					styleHref,
266 +
					returnTo,
267 +
				),
191 268
			);
192 269
		}
193 270
204 281
			renderSuccess(
205 282
				publicationUri,
206 283
				result.data.uri,
207 -
				false,
284 +
				"Subscribed ✓",
285 +
				"You've successfully subscribed!",
208 286
				styleHref,
209 287
				returnTo,
210 288
			),
218 296
				styleHref,
219 297
				returnTo,
220 298
				"Session expired. Please sign in again.",
299 +
				action,
221 300
			),
222 301
		);
223 302
	}
235 314
	const handle = (body["handle"] as string | undefined)?.trim();
236 315
	const publicationUri = body["publicationUri"] as string | undefined;
237 316
	const formReturnTo = (body["returnTo"] as string | undefined) || undefined;
317 +
	const formAction = (body["action"] as string | undefined) || undefined;
238 318
239 319
	if (!handle || !publicationUri) {
240 320
		const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
246 326
247 327
	const returnTo =
248 328
		`${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}` +
329 +
		(formAction ? `&action=${encodeURIComponent(formAction)}` : "") +
249 330
		(formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : "");
250 331
	setReturnToCookie(c, returnTo, c.env.CLIENT_URL);
251 332
263 344
	styleHref: string,
264 345
	returnTo?: string,
265 346
	error?: string,
347 +
	action?: string,
266 348
): string {
267 349
	const errorHtml = error
268 350
		? `<p class="vocs_Paragraph error">${escapeHtml(error)}</p>`
270 352
	const returnToInput = returnTo
271 353
		? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />`
272 354
		: "";
355 +
	const actionInput = action
356 +
		? `<input type="hidden" name="action" value="${escapeHtml(action)}" />`
357 +
		: "";
273 358
274 359
	return page(
275 360
		`
279 364
		<form method="POST" action="/subscribe/login">
280 365
			<input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" />
281 366
			${returnToInput}
367 +
			${actionInput}
282 368
			<input
283 369
				type="text"
284 370
				name="handle"
296 382
297 383
function renderSuccess(
298 384
	publicationUri: string,
299 -
	recordUri: string,
300 -
	existing: boolean,
385 +
	recordUri: string | null,
386 +
	heading: string,
387 +
	msg: string,
301 388
	styleHref: string,
302 389
	returnTo?: string,
303 390
): string {
304 -
	const msg = existing
305 -
		? "You're already subscribed to this publication."
306 -
		: "You've successfully subscribed!";
307 391
	const escapedPublicationUri = escapeHtml(publicationUri);
308 -
	const escapedRecordUri = escapeHtml(recordUri);
392 +
	const escapedReturnTo = returnTo ? escapeHtml(returnTo) : "";
309 393
310 394
	const redirectHtml = returnTo
311 -
		? `<p class="vocs_Paragraph" id="redirect-msg">Redirecting to <a class="vocs_Anchor" href="${escapeHtml(returnTo)}">${escapeHtml(returnTo)}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p>
395 +
		? `<p class="vocs_Paragraph" id="redirect-msg">Redirecting to <a class="vocs_Anchor" href="${escapedReturnTo}">${escapedReturnTo}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p>
312 396
		<script>
313 397
		(function(){
314 398
			var secs = ${REDIRECT_DELAY_SECONDS};
322 406
		</script>`
323 407
		: "";
324 408
	const headExtra = returnTo
325 -
		? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapeHtml(returnTo)}" />`
409 +
		? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />`
326 410
		: "";
327 411
328 412
	return page(
329 413
		`
330 -
		<h1 class="vocs_H1 vocs_Heading">Subscribed ✓</h1>
414 +
		<h1 class="vocs_H1 vocs_Heading">${escapeHtml(heading)}</h1>
331 415
		<p class="vocs_Paragraph">${msg}</p>
332 416
		${redirectHtml}
333 417
		<table class="vocs_Table" style="display:table;table-layout:fixed;width:100%;overflow:hidden;">
339 423
						<div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></div>
340 424
					</td>
341 425
				</tr>
342 -
				<tr class="vocs_TableRow">
426 +
				${
427 +
					recordUri
428 +
						? `<tr class="vocs_TableRow">
343 429
					<td class="vocs_TableCell">Record</td>
344 430
					<td class="vocs_TableCell" style="overflow:hidden;">
345 -
						<div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapedRecordUri}">${escapedRecordUri}</a></code></div>
431 +
						<div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div>
346 432
					</td>
347 -
				</tr>
433 +
				</tr>`
434 +
						: ""
435 +
				}
348 436
			</tbody>
349 437
		</table>
350 438
	`,
packages/cli/src/components/sequoia-subscribe.js +43 −47
79 79
	flex-shrink: 0;
80 80
}
81 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 82
.sequoia-loading-spinner {
91 83
	display: inline-block;
92 84
	width: 1rem;
116 108
117 109
const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
118 110
  <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 111
</svg>`;
124 112
125 113
// ============================================================================
178 166
		wrapper.part = "container";
179 167
180 168
		this.wrapper = wrapper;
169 +
		this.subscribed = false;
181 170
		this.state = { type: "idle" };
182 171
		this.abortController = null;
183 172
		this.render();
188 177
	}
189 178
190 179
	connectedCallback() {
191 -
		// Pre-check publication availability so hide="auto" can take effect
192 -
		if (!this.publicationUri) {
193 -
			this.checkPublication();
194 -
		}
180 +
		this.checkPublication();
195 181
	}
196 182
197 183
	disconnectedCallback() {
199 185
	}
200 186
201 187
	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 -
		) {
188 +
		if (this.state.type === "error" || this.state.type === "no-publication") {
208 189
			this.state = { type: "idle" };
209 190
		}
210 191
		this.render();
232 213
		this.abortController = new AbortController();
233 214
234 215
		try {
235 -
			await fetchPublicationUri();
216 +
			const uri = this.publicationUri ?? (await fetchPublicationUri());
217 +
			this.checkSubscription(uri);
236 218
		} catch {
237 219
			this.state = { type: "no-publication" };
238 220
			this.render();
239 221
		}
240 222
	}
241 223
224 +
	async checkSubscription(publicationUri) {
225 +
		try {
226 +
			const res = await fetch(
227 +
				`${this.callbackUri}?publicationUri=${encodeURIComponent(publicationUri)}`,
228 +
				{
229 +
					headers: { Accept: "application/json" },
230 +
					credentials: "include",
231 +
				},
232 +
			);
233 +
			if (!res.ok) return;
234 +
			const data = await res.json();
235 +
			if (data.subscribed) {
236 +
				this.subscribed = true;
237 +
				this.render();
238 +
			}
239 +
		} catch {
240 +
			// Ignore errors — show default subscribe button
241 +
		}
242 +
	}
243 +
242 244
	async handleClick() {
243 -
		if (this.state.type === "loading" || this.state.type === "subscribed") {
245 +
		if (this.state.type === "loading") {
246 +
			return;
247 +
		}
248 +
249 +
		// Unsubscribe: redirect to full-page unsubscribe flow
250 +
		if (this.subscribed) {
251 +
			const publicationUri =
252 +
				this.publicationUri ?? (await fetchPublicationUri());
253 +
			window.location.href = `${this.callbackUri}?publicationUri=${encodeURIComponent(publicationUri)}&action=unsubscribe`;
244 254
			return;
245 255
		}
246 256
251 261
			const publicationUri =
252 262
				this.publicationUri ?? (await fetchPublicationUri());
253 263
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 264
			const response = await fetch(this.callbackUri, {
258 265
				method: "POST",
259 266
				headers: { "Content-Type": "application/json" },
281 288
			}
282 289
283 290
			const { recordUri } = data;
284 -
			this.state = { type: "subscribed", recordUri, publicationUri };
291 +
			this.subscribed = true;
292 +
			this.state = { type: "idle" };
285 293
			this.render();
286 294
287 295
			this.dispatchEvent(
292 300
				}),
293 301
			);
294 302
		} catch (error) {
295 -
			// Don't overwrite state if we already navigated away
296 303
			if (this.state.type !== "loading") return;
297 304
298 305
			const message =
322 329
		}
323 330
324 331
		const isLoading = type === "loading";
325 -
		const isSubscribed = type === "subscribed";
326 332
327 333
		const icon = isLoading
328 334
			? `<span class="sequoia-loading-spinner"></span>`
329 -
			: isSubscribed
330 -
				? CHECK_ICON
331 -
				: BLUESKY_ICON;
335 +
			: BLUESKY_ICON;
332 336
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(" ");
337 +
		const label = this.subscribed ? "Unsubscribe on Bluesky" : this.label;
340 338
341 339
		const errorHtml =
342 340
			type === "error"
345 343
346 344
		this.wrapper.innerHTML = `
347 345
			<button
348 -
				class="${buttonClass}"
346 +
				class="sequoia-subscribe-button"
349 347
				type="button"
350 348
				part="button"
351 -
				${isLoading || isSubscribed ? "disabled" : ""}
352 -
				aria-label="${isSubscribed ? "Subscribed" : this.label}"
349 +
				${isLoading ? "disabled" : ""}
350 +
				aria-label="${label}"
353 351
			>
354 352
				${icon}
355 353
				${label}
357 355
			${errorHtml}
358 356
		`;
359 357
360 -
		if (type !== "subscribed") {
361 -
			const btn = this.wrapper.querySelector("button");
362 -
			btn?.addEventListener("click", () => this.handleClick());
363 -
		}
358 +
		const btn = this.wrapper.querySelector("button");
359 +
		btn?.addEventListener("click", () => this.handleClick());
364 360
	}
365 361
}
366 362