tangled
alpha
login
or
join now
heaths.dev
/
sequoia
forked from
stevedylan.dev/sequoia
0
fork
atom
A CLI for publishing standard.site documents to ATProto
0
fork
atom
overview
issues
pulls
pipelines
Check existing subs and offer to unsubscribe
Heath Stewart
3 weeks ago
700f244a
c0435192
+146
-62
2 changed files
expand all
collapse all
unified
split
docs
src
routes
subscribe.ts
packages
cli
src
components
sequoia-subscribe.js
+103
-15
docs/src/routes/subscribe.ts
···
154
154
155
155
subscribe.get("/", async (c) => {
156
156
const publicationUri = c.req.query("publicationUri");
157
157
+
const action = c.req.query("action");
158
158
+
const wantsJson = c.req.header("accept")?.includes("application/json");
159
159
+
160
160
+
// JSON path: subscription status check for the web component.
161
161
+
if (wantsJson) {
162
162
+
if (action && action !== "unsubscribe") {
163
163
+
return c.json({ error: `Unsupported action: ${action}` }, 400);
164
164
+
}
165
165
+
if (!publicationUri || !publicationUri.startsWith("at://")) {
166
166
+
return c.json({ error: "Missing or invalid publicationUri" }, 400);
167
167
+
}
168
168
+
const did = getSessionDid(c);
169
169
+
if (!did) {
170
170
+
return c.json({ authenticated: false }, 401);
171
171
+
}
172
172
+
try {
173
173
+
const client = createOAuthClient(
174
174
+
c.env.SEQUOIA_SESSIONS,
175
175
+
c.env.CLIENT_URL,
176
176
+
);
177
177
+
const session = await client.restore(did);
178
178
+
const agent = new Agent(session);
179
179
+
const recordUri = await findExistingSubscription(
180
180
+
agent,
181
181
+
did,
182
182
+
publicationUri,
183
183
+
);
184
184
+
return recordUri
185
185
+
? c.json({ subscribed: true, recordUri })
186
186
+
: c.json({ subscribed: false });
187
187
+
} catch {
188
188
+
return c.json({ authenticated: false }, 401);
189
189
+
}
190
190
+
}
191
191
+
192
192
+
// HTML path: full-page subscribe/unsubscribe flow.
157
193
const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
158
194
195
195
+
if (action && action !== "unsubscribe") {
196
196
+
return c.html(renderError(`Unsupported action: ${action}`, styleHref), 400);
197
197
+
}
198
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
175
-
return c.html(renderHandleForm(publicationUri, styleHref, returnTo));
215
215
+
return c.html(
216
216
+
renderHandleForm(publicationUri, styleHref, returnTo, undefined, action),
217
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
225
+
if (action === "unsubscribe") {
226
226
+
const existingUri = await findExistingSubscription(
227
227
+
agent,
228
228
+
did,
229
229
+
publicationUri,
230
230
+
);
231
231
+
if (existingUri) {
232
232
+
const rkey = existingUri.split("/").pop()!;
233
233
+
await agent.com.atproto.repo.deleteRecord({
234
234
+
repo: did,
235
235
+
collection: COLLECTION,
236
236
+
rkey,
237
237
+
});
238
238
+
}
239
239
+
return c.html(
240
240
+
renderSuccess(
241
241
+
publicationUri,
242
242
+
null,
243
243
+
"Unsubscribed ✓",
244
244
+
existingUri
245
245
+
? "You've successfully unsubscribed!"
246
246
+
: "You weren't subscribed to this publication.",
247
247
+
styleHref,
248
248
+
returnTo,
249
249
+
),
250
250
+
);
251
251
+
}
252
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
190
-
renderSuccess(publicationUri, existingUri, true, styleHref, returnTo),
260
260
+
renderSuccess(
261
261
+
publicationUri,
262
262
+
existingUri,
263
263
+
"Subscribed ✓",
264
264
+
"You're already subscribed to this publication.",
265
265
+
styleHref,
266
266
+
returnTo,
267
267
+
),
191
268
);
192
269
}
193
270
···
204
281
renderSuccess(
205
282
publicationUri,
206
283
result.data.uri,
207
207
-
false,
284
284
+
"Subscribed ✓",
285
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
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
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
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
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
355
+
const actionInput = action
356
356
+
? `<input type="hidden" name="action" value="${escapeHtml(action)}" />`
357
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
367
+
${actionInput}
282
368
<input
283
369
type="text"
284
370
name="handle"
···
296
382
297
383
function renderSuccess(
298
384
publicationUri: string,
299
299
-
recordUri: string,
300
300
-
existing: boolean,
385
385
+
recordUri: string | null,
386
386
+
heading: string,
387
387
+
msg: string,
301
388
styleHref: string,
302
389
returnTo?: string,
303
390
): string {
304
304
-
const msg = existing
305
305
-
? "You're already subscribed to this publication."
306
306
-
: "You've successfully subscribed!";
307
391
const escapedPublicationUri = escapeHtml(publicationUri);
308
308
-
const escapedRecordUri = escapeHtml(recordUri);
392
392
+
const escapedReturnTo = returnTo ? escapeHtml(returnTo) : "";
309
393
310
394
const redirectHtml = returnTo
311
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
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
325
-
? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapeHtml(returnTo)}" />`
409
409
+
? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />`
326
410
: "";
327
411
328
412
return page(
329
413
`
330
330
-
<h1 class="vocs_H1 vocs_Heading">Subscribed ✓</h1>
414
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
342
-
<tr class="vocs_TableRow">
426
426
+
${
427
427
+
recordUri
428
428
+
? `<tr class="vocs_TableRow">
343
429
<td class="vocs_TableCell">Record</td>
344
430
<td class="vocs_TableCell" style="overflow:hidden;">
345
345
-
<div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapedRecordUri}">${escapedRecordUri}</a></code></div>
431
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
347
-
</tr>
433
433
+
</tr>`
434
434
+
: ""
435
435
+
}
348
436
</tbody>
349
437
</table>
350
438
`,
+43
-47
packages/cli/src/components/sequoia-subscribe.js
···
79
79
flex-shrink: 0;
80
80
}
81
81
82
82
-
.sequoia-subscribe-button--success {
83
83
-
background: #16a34a;
84
84
-
}
85
85
-
86
86
-
.sequoia-subscribe-button--success:hover:not(:disabled) {
87
87
-
background: color-mix(in srgb, #16a34a 85%, black);
88
88
-
}
89
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
119
-
</svg>`;
120
120
-
121
121
-
const CHECK_ICON = `<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
122
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
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
191
-
// Pre-check publication availability so hide="auto" can take effect
192
192
-
if (!this.publicationUri) {
193
193
-
this.checkPublication();
194
194
-
}
180
180
+
this.checkPublication();
195
181
}
196
182
197
183
disconnectedCallback() {
···
199
185
}
200
186
201
187
attributeChangedCallback() {
202
202
-
// Reset to idle if attributes change after an error or success
203
203
-
if (
204
204
-
this.state.type === "error" ||
205
205
-
this.state.type === "subscribed" ||
206
206
-
this.state.type === "no-publication"
207
207
-
) {
188
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
235
-
await fetchPublicationUri();
216
216
+
const uri = this.publicationUri ?? (await fetchPublicationUri());
217
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
224
+
async checkSubscription(publicationUri) {
225
225
+
try {
226
226
+
const res = await fetch(
227
227
+
`${this.callbackUri}?publicationUri=${encodeURIComponent(publicationUri)}`,
228
228
+
{
229
229
+
headers: { Accept: "application/json" },
230
230
+
credentials: "include",
231
231
+
},
232
232
+
);
233
233
+
if (!res.ok) return;
234
234
+
const data = await res.json();
235
235
+
if (data.subscribed) {
236
236
+
this.subscribed = true;
237
237
+
this.render();
238
238
+
}
239
239
+
} catch {
240
240
+
// Ignore errors — show default subscribe button
241
241
+
}
242
242
+
}
243
243
+
242
244
async handleClick() {
243
243
-
if (this.state.type === "loading" || this.state.type === "subscribed") {
245
245
+
if (this.state.type === "loading") {
246
246
+
return;
247
247
+
}
248
248
+
249
249
+
// Unsubscribe: redirect to full-page unsubscribe flow
250
250
+
if (this.subscribed) {
251
251
+
const publicationUri =
252
252
+
this.publicationUri ?? (await fetchPublicationUri());
253
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
254
-
// POST to the callbackUri (e.g. https://sequoia.pub/subscribe).
255
255
-
// If the server reports the user isn't authenticated it returns a
256
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
284
-
this.state = { type: "subscribed", recordUri, publicationUri };
291
291
+
this.subscribed = true;
292
292
+
this.state = { type: "idle" };
285
293
this.render();
286
294
287
295
this.dispatchEvent(
···
292
300
}),
293
301
);
294
302
} catch (error) {
295
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
325
-
const isSubscribed = type === "subscribed";
326
332
327
333
const icon = isLoading
328
334
? `<span class="sequoia-loading-spinner"></span>`
329
329
-
: isSubscribed
330
330
-
? CHECK_ICON
331
331
-
: BLUESKY_ICON;
335
335
+
: BLUESKY_ICON;
332
336
333
333
-
const label = isSubscribed ? "Subscribed" : this.label;
334
334
-
const buttonClass = [
335
335
-
"sequoia-subscribe-button",
336
336
-
isSubscribed ? "sequoia-subscribe-button--success" : "",
337
337
-
]
338
338
-
.filter(Boolean)
339
339
-
.join(" ");
337
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
348
-
class="${buttonClass}"
346
346
+
class="sequoia-subscribe-button"
349
347
type="button"
350
348
part="button"
351
351
-
${isLoading || isSubscribed ? "disabled" : ""}
352
352
-
aria-label="${isSubscribed ? "Subscribed" : this.label}"
349
349
+
${isLoading ? "disabled" : ""}
350
350
+
aria-label="${label}"
353
351
>
354
352
${icon}
355
353
${label}
···
357
355
${errorHtml}
358
356
`;
359
357
360
360
-
if (type !== "subscribed") {
361
361
-
const btn = this.wrapper.querySelector("button");
362
362
-
btn?.addEventListener("click", () => this.handleClick());
363
363
-
}
358
358
+
const btn = this.wrapper.querySelector("button");
359
359
+
btn?.addEventListener("click", () => this.handleClick());
364
360
}
365
361
}
366
362