From c05003fb26c50108503a590258394b2636253f3d Mon Sep 17 00:00:00 2001 From: Heath Stewart Date: Sat, 28 Feb 2026 01:55:46 -0800 Subject: [PATCH] Check existing subs and offer to unsubscribe --- docs/src/routes/subscribe.ts | 118 +++++++++++++++--- .../cli/src/components/sequoia-subscribe.js | 92 +++++++------- 2 files changed, 147 insertions(+), 63 deletions(-) diff --git a/docs/src/routes/subscribe.ts b/docs/src/routes/subscribe.ts index 45170be..7e26d69 100644 --- a/docs/src/routes/subscribe.ts +++ b/docs/src/routes/subscribe.ts @@ -154,8 +154,48 @@ subscribe.post("/", async (c) => { subscribe.get("/", async (c) => { const publicationUri = c.req.query("publicationUri"); + const action = c.req.query("action"); + const wantsJson = c.req.header("accept")?.includes("application/json"); + + // JSON path: subscription status check for the web component. + if (wantsJson) { + if (action && action !== "unsubscribe") { + return c.json({ error: `Unsupported action: ${action}` }, 400); + } + if (!publicationUri || !publicationUri.startsWith("at://")) { + return c.json({ error: "Missing or invalid publicationUri" }, 400); + } + const did = getSessionDid(c); + if (!did) { + return c.json({ authenticated: false }, 401); + } + try { + const client = createOAuthClient( + c.env.SEQUOIA_SESSIONS, + c.env.CLIENT_URL, + ); + const session = await client.restore(did); + const agent = new Agent(session); + const recordUri = await findExistingSubscription( + agent, + did, + publicationUri, + ); + return recordUri + ? c.json({ subscribed: true, recordUri }) + : c.json({ subscribed: false }); + } catch { + return c.json({ authenticated: false }, 401); + } + } + + // HTML path: full-page subscribe/unsubscribe flow. const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); + if (action && action !== "unsubscribe") { + return c.html(renderError(`Unsupported action: ${action}`, styleHref), 400); + } + if (!publicationUri || !publicationUri.startsWith("at://")) { return c.html( renderError("Missing or invalid publication URI.", styleHref), @@ -172,7 +212,9 @@ subscribe.get("/", async (c) => { const did = getSessionDid(c); if (!did) { - return c.html(renderHandleForm(publicationUri, styleHref, returnTo)); + return c.html( + renderHandleForm(publicationUri, styleHref, returnTo, undefined, action), + ); } try { @@ -180,6 +222,34 @@ subscribe.get("/", async (c) => { const session = await client.restore(did); const agent = new Agent(session); + if (action === "unsubscribe") { + const existingUri = await findExistingSubscription( + agent, + did, + publicationUri, + ); + if (existingUri) { + const rkey = existingUri.split("/").pop()!; + await agent.com.atproto.repo.deleteRecord({ + repo: did, + collection: COLLECTION, + rkey, + }); + } + return c.html( + renderSuccess( + publicationUri, + null, + "Unsubscribed ✓", + existingUri + ? "You've successfully unsubscribed!" + : "You weren't subscribed to this publication.", + styleHref, + returnTo, + ), + ); + } + const existingUri = await findExistingSubscription( agent, did, @@ -187,7 +257,14 @@ subscribe.get("/", async (c) => { ); if (existingUri) { return c.html( - renderSuccess(publicationUri, existingUri, true, styleHref, returnTo), + renderSuccess( + publicationUri, + existingUri, + "Subscribed ✓", + "You're already subscribed to this publication.", + styleHref, + returnTo, + ), ); } @@ -204,7 +281,8 @@ subscribe.get("/", async (c) => { renderSuccess( publicationUri, result.data.uri, - false, + "Subscribed ✓", + "You've successfully subscribed!", styleHref, returnTo, ), @@ -218,6 +296,7 @@ subscribe.get("/", async (c) => { styleHref, returnTo, "Session expired. Please sign in again.", + action, ), ); } @@ -235,6 +314,7 @@ subscribe.post("/login", async (c) => { const handle = (body["handle"] as string | undefined)?.trim(); const publicationUri = body["publicationUri"] as string | undefined; const formReturnTo = (body["returnTo"] as string | undefined) || undefined; + const formAction = (body["action"] as string | undefined) || undefined; if (!handle || !publicationUri) { const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); @@ -246,6 +326,7 @@ subscribe.post("/login", async (c) => { const returnTo = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}` + + (formAction ? `&action=${encodeURIComponent(formAction)}` : "") + (formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : ""); setReturnToCookie(c, returnTo, c.env.CLIENT_URL); @@ -263,6 +344,7 @@ function renderHandleForm( styleHref: string, returnTo?: string, error?: string, + action?: string, ): string { const errorHtml = error ? `

${escapeHtml(error)}

` @@ -270,6 +352,9 @@ function renderHandleForm( const returnToInput = returnTo ? `` : ""; + const actionInput = action + ? `` + : ""; return page( ` @@ -279,6 +364,7 @@ function renderHandleForm(
${returnToInput} + ${actionInput} Redirecting to ${escapeHtml(returnTo)} in ${REDIRECT_DELAY_SECONDS}\u00a0seconds\u2026

+ ? `

Redirecting to ${escapedReturnTo} in ${REDIRECT_DELAY_SECONDS}\u00a0seconds\u2026

` : ""; const headExtra = returnTo - ? `` + ? `` : ""; return page( ` -

Subscribed ✓

+

${escapeHtml(heading)}

${msg}

${redirectHtml} @@ -339,12 +423,16 @@ function renderSuccess(
${escapedPublicationUri}
- + ${ + recordUri + ? ` - + ` + : "" + }
Record - +
`, diff --git a/packages/cli/src/components/sequoia-subscribe.js b/packages/cli/src/components/sequoia-subscribe.js index 3d32c04..a0e5e84 100644 --- a/packages/cli/src/components/sequoia-subscribe.js +++ b/packages/cli/src/components/sequoia-subscribe.js @@ -79,14 +79,6 @@ const styles = ` flex-shrink: 0; } -.sequoia-subscribe-button--success { - background: #16a34a; -} - -.sequoia-subscribe-button--success:hover:not(:disabled) { - background: color-mix(in srgb, #16a34a 85%, black); -} - .sequoia-loading-spinner { display: inline-block; width: 1rem; @@ -118,10 +110,6 @@ const BLUESKY_ICON = ` `; -const CHECK_ICON = ` - -`; - // ============================================================================ // AT Protocol Functions // ============================================================================ @@ -178,6 +166,7 @@ class SequoiaSubscribe extends BaseElement { wrapper.part = "container"; this.wrapper = wrapper; + this.subscribed = false; this.state = { type: "idle" }; this.abortController = null; this.render(); @@ -188,10 +177,7 @@ class SequoiaSubscribe extends BaseElement { } connectedCallback() { - // Pre-check publication availability so hide="auto" can take effect - if (!this.publicationUri) { - this.checkPublication(); - } + this.checkPublication(); } disconnectedCallback() { @@ -199,12 +185,7 @@ class SequoiaSubscribe extends BaseElement { } attributeChangedCallback() { - // Reset to idle if attributes change after an error or success - if ( - this.state.type === "error" || - this.state.type === "subscribed" || - this.state.type === "no-publication" - ) { + if (this.state.type === "error" || this.state.type === "no-publication") { this.state = { type: "idle" }; } this.render(); @@ -232,15 +213,44 @@ class SequoiaSubscribe extends BaseElement { this.abortController = new AbortController(); try { - await fetchPublicationUri(); + const uri = this.publicationUri ?? (await fetchPublicationUri()); + this.checkSubscription(uri); } catch { this.state = { type: "no-publication" }; this.render(); } } + async checkSubscription(publicationUri) { + try { + const res = await fetch( + `${this.callbackUri}?publicationUri=${encodeURIComponent(publicationUri)}`, + { + headers: { Accept: "application/json" }, + credentials: "include", + }, + ); + if (!res.ok) return; + const data = await res.json(); + if (data.subscribed) { + this.subscribed = true; + this.render(); + } + } catch { + // Ignore errors — show default subscribe button + } + } + async handleClick() { - if (this.state.type === "loading" || this.state.type === "subscribed") { + if (this.state.type === "loading") { + return; + } + + // Unsubscribe: redirect to full-page unsubscribe flow + if (this.subscribed) { + const publicationUri = + this.publicationUri ?? (await fetchPublicationUri()); + window.location.href = `${this.callbackUri}?publicationUri=${encodeURIComponent(publicationUri)}&action=unsubscribe`; return; } @@ -251,9 +261,6 @@ class SequoiaSubscribe extends BaseElement { const publicationUri = this.publicationUri ?? (await fetchPublicationUri()); - // POST to the callbackUri (e.g. https://sequoia.pub/subscribe). - // If the server reports the user isn't authenticated it returns a - // subscribeUrl for the full-page OAuth + subscription flow. const response = await fetch(this.callbackUri, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -281,7 +288,8 @@ class SequoiaSubscribe extends BaseElement { } const { recordUri } = data; - this.state = { type: "subscribed", recordUri, publicationUri }; + this.subscribed = true; + this.state = { type: "idle" }; this.render(); this.dispatchEvent( @@ -292,7 +300,6 @@ class SequoiaSubscribe extends BaseElement { }), ); } catch (error) { - // Don't overwrite state if we already navigated away if (this.state.type !== "loading") return; const message = @@ -322,21 +329,12 @@ class SequoiaSubscribe extends BaseElement { } const isLoading = type === "loading"; - const isSubscribed = type === "subscribed"; const icon = isLoading ? `` - : isSubscribed - ? CHECK_ICON - : BLUESKY_ICON; - - const label = isSubscribed ? "Subscribed" : this.label; - const buttonClass = [ - "sequoia-subscribe-button", - isSubscribed ? "sequoia-subscribe-button--success" : "", - ] - .filter(Boolean) - .join(" "); + : BLUESKY_ICON; + + const label = this.subscribed ? "Unsubscribe on Bluesky" : this.label; const errorHtml = type === "error" @@ -345,11 +343,11 @@ class SequoiaSubscribe extends BaseElement { this.wrapper.innerHTML = `