Check existing subs and offer to unsubscribe
c05003fb
2 file(s) · +146 −62
| 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 | `, |
|
| 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 | ||