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

Check existing subs and offer to unsubscribe #37

merged opened by heaths.dev targeting main from heaths.dev/sequoia: unsubscribe
Labels

None yet.

assignee

None yet.

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

History

3 rounds 10 comments
sign up or login to add to the discussion
3 commits
expand
Check existing subs and offer to unsubscribe
Add separate /subscribe/check route
Pass DID through query parameter
expand 2 comments

Not sure if storing it in localStorage is worth it assuming document.cookie always works. This is what Opus 4.6 wanted to do so maybe there's some good reason? If not, we could certainly clean up the code some.

Tested it with reverse-proxied sites and works well. Record gets deleted as expected and the button changes state as expected.

Awesome!! Thanks for this work; sick feature :)

pull request successfully merged
2 commits
expand
Check existing subs and offer to unsubscribe
Add separate /subscribe/check route
expand 7 comments

Made a separate route to check credentials. Cleaner and doesn't radically alter the behavior of the existing route. Note, however, that this doesn't work. Seems CORS is preventing it despite setting SameSite=None; Secure on the session_id cookie, which seems like it'd be safe in this case.

Using both curl and the ngrok traffic inspector (reverse-proxied for https endpoints as needed) confirm the right CORS headers are being sent from the Api endpoint, and I made sure to change my cookie settings for that endpoint but the cookie just isn't being sent it seems. I had some console.log statements in there which confirmed the DID wasn't found i.e., no session_id cookie.

I think the only reasonable way this could work is to host the script on the same origin as the /oauth and /subscribe APIs, which means either self-hosting (separate issue) or centralized hosting via sequoia.pub or at least a CDN.

I might have a solution that's not perfect, but I guess better than nothing. I realized that we could probably fetch this state client side since we are storing DID as part of the session cookie. However since we can't generally access those specific cookies, we can still store the state in local storage. An approach I took was to use the query params to store the DID as well as the subscribe state. With the DID we can make a client side call to the PDS for the site.standard.graph.subscription record, and if it matches the publication of the current site, then we show the unsubscribe button.

The downside is that since we're using local storage, clearing browser data or using another device means it will show the subscribe button, and worse cast scenario they try to create a record that already exists. I did try it out and it does work, so if you're interested I've created a diff patch file that can be applied to your branch:

curl -o unsubscribe.patch https://files.stevedylan.dev/unsubscribe.patch

git apply unsubscribe.patch

(git patches slap and I'm glad I messed around with dwm in linux to learn them lol)

Feel free to take it or leave it! Also we will need to update the OAuth scopes again to include the delete functionality for unsubscribing.

scope: "atproto repo:site.standard.graph.subscription?action=create&action=delete",

I would commit directly with the updates but since you're currently working off a fork I thought that might get messy. To that end I've added you as a collaborator on Tangled for Sequoia, so next time you need to make a PR feel free to make a branch directly on the repo and push that way :)

Thanks! I'll take a look at this tomorrow. Good call on the scopes: totally forgot about that.

The local storage idea came up when I was looking for solutions as well. That said, it at least roams with online profiles like Apple IDs, Chrome and Edge profiles, etc., right? I think so. So not terrible but, yeah, there are holes. not any worse than not having it, though. At least the flow tells you you're already sub'd so it doesn't hurt anything.

Was there a reason you deleted the /subscribe/check route? Don't we still need that for just the check, or is that the part that can't possibly work anyway? Given that, the routing to return to the original post actually was already working so I don't think modifying the URL is necessary further.

Or was the point to send those back to the publication itself for the sequoia-subscribe.js component to store in local storage?

I made that change - actually store it in a cookie via JS - and it works. New update incoming.

I think I deleted it since we switched to the client side call, but I was under the impression that was the only use case. Totally fine keeping it if we need it / want to use it later!

heaths.dev submitted #0
2 commits
expand
Send referrer path and origin through login flow
Check existing subs and offer to unsubscribe
expand 1 comment

This includes the other PR only because I needed some of the changes therein to make the diff less messy. That other PR should be taken first then this one. Even if we make other changes to the other PR, this should keep this PR more easily mergable.