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

Configure Feed

Select the types of activity you want to include in your feed.

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