A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing

Check existing subs and offer to unsubscribe

authored by

Heath Stewart and committed by tangled.org c05003fb 3520b605

+146 -62
+103 -15
docs/src/routes/subscribe.ts
··· 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 `,
+43 -47
packages/cli/src/components/sequoia-subscribe.js
··· 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