diff --git a/docs/docs/pages/recommend.mdx b/docs/docs/pages/recommend.mdx new file mode 100644 index 0000000..24584ec --- /dev/null +++ b/docs/docs/pages/recommend.mdx @@ -0,0 +1,238 @@ +# Recommend + +Sequoia provides a recommend button web component that lets your readers recommend a document directly from your site using their AT Protocol account. + +## Setup + +The recommend component is bundled in the same file as the subscribe component. Run the following command to install it if you haven't already: + +```bash [Terminal] +sequoia add sequoia-subscribe +``` + +The component will look for your document AT URI from a `` tag in the page `` automatically, so no additional configuration is required for most setups. + +:::tip +`sequoia-subscribe.js` registers both the `` and `` custom elements. You only need to import it once even if you use both components on the same page. +::: + +## Usage + +Since `sequoia-recommend` is a standard Web Component, it works with any framework. Choose your setup below: + +:::code-group + +```html [HTML] + + + + + +

My Post

+ + + + + +``` + +```tsx [React] +// Import the component (registers both custom elements) +import './components/sequoia-subscribe.js'; + +function PostPage() { + return ( +
+

My Post

+ {/* Content */} + +
+ ); +} +``` + +```vue [Vue] + + + +``` + +```svelte [Svelte] + + +
+

My Post

+ + +
+``` + +```astro [Astro] +
+

My Post

+ + + +
+``` + +::: + +### TypeScript Support + +If you're using TypeScript with React, add this type declaration to avoid JSX errors: + +```ts [custom-elements.d.ts] +declare namespace JSX { + interface IntrinsicElements { + 'sequoia-recommend': React.DetailedHTMLProps< + React.HTMLAttributes & { + 'document-uri'?: string; + 'callback-uri'?: string; + 'button-type'?: 'heart' | 'star' | 'thumbs-up'; + hide?: string; + }, + HTMLElement + >; + } +} +``` + +### Vue Configuration + +For Vue, you may need to configure the compiler to recognize custom elements: + +```ts [vite.config.ts] +export default defineConfig({ + plugins: [ + vue({ + template: { + compilerOptions: { + isCustomElement: (tag) => tag === 'sequoia-recommend' + } + } + }) + ] +}); +``` + +## Configuration + +The recommend web component has several configuration options available. + +### Attributes + +The `` component accepts the following attributes: + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `document-uri` | `string` | - | AT Protocol URI for the document to recommend. Optional if a `` tag exists in the page ``. | +| `callback-uri` | `string` | `https://sequoia.pub/recommend` | Redirect URI used for the OAuth authentication flow. | +| `button-type` | `string` | `heart` | Icon style for the button. Accepted values: `heart`, `star`, `thumbs-up`. | +| `hide` | `string` | - | Set to `"auto"` to hide the component if no document URI is detected. | + +#### Button Types + +The `button-type` attribute controls which icon the button uses. Icons are outlined when not yet recommended and filled once recommended. + +| Type | Icon | Aria Label | +|------|------|-----------| +| `heart` | Heart | Recommend / Unrecommend | +| `star` | Star | Recommend / Unrecommend | +| `thumbs-up` | Thumbs up | Recommend / Unrecommend | + +```html + + + + + + + + + + + + +``` + +#### Resolving the Document URI + +If `document-uri` is not set on the element, the component looks for a `` tag in the page ``: + +```html + +``` + +This lets you set the document URI once per page without having to pass it to every component instance. + +### Events + +The component dispatches custom events you can listen to: + +| Event | Description | Detail | +|-------|-------------|--------| +| `sequoia-recommended` | Fired when the recommendation is created successfully. | `{ documentUri: string, recordUri: string }` | +| `sequoia-recommend-error` | Fired when the recommendation fails. | `{ message: string }` | + +```js +const btn = document.querySelector('sequoia-recommend'); + +btn.addEventListener('sequoia-recommended', (e) => { + console.log('Recommended!', e.detail.recordUri); +}); + +btn.addEventListener('sequoia-recommend-error', (e) => { + console.error('Recommendation failed:', e.detail.message); +}); +``` + +### Styling + +The component uses the same CSS custom properties as `` for theming, plus a `part="button"` attribute for direct CSS targeting: + +| CSS Property | Default | Description | +|--------------|---------|-------------| +| `--sequoia-fg-color` | `#1f2937` | Text color | +| `--sequoia-bg-color` | `#ffffff` | Background color | +| `--sequoia-border-color` | `#e5e7eb` | Border color | +| `--sequoia-accent-color` | `#2563eb` | Button background color | +| `--sequoia-secondary-color` | `#6b7280` | Secondary text color | +| `--sequoia-border-radius` | `8px` | Border radius for the button | +| `--sequoia-icon-display` | `inline-block` | Set to `none` to hide the button icon | + +### Example: Match Site Theme + +```css +:root { + --sequoia-accent-color: #3A5A40; + --sequoia-border-radius: 6px; + --sequoia-bg-color: #F5F3EF; + --sequoia-fg-color: #2C2C2C; + --sequoia-border-color: #D5D1C8; + --sequoia-secondary-color: #8B7355; +} +``` + +### Using Both Components Together + +Because `sequoia-subscribe.js` only needs to be imported once, you can use both components on the same page with a single script tag: + +```html + + + +``` diff --git a/docs/docs/pages/subscribe.mdx b/docs/docs/pages/subscribe.mdx index 4266ba2..af60934 100644 --- a/docs/docs/pages/subscribe.mdx +++ b/docs/docs/pages/subscribe.mdx @@ -12,6 +12,10 @@ sequoia add sequoia-subscribe The component will look for your publication AT URI from your site's `/.well-known/site.standard.publication` endpoint automatically, so no additional configuration is required for most setups. +:::tip +`sequoia-subscribe.js` registers both the `` and `` custom elements. You only need to import it once even if you use both components on the same page. +::: + ## Usage Since `sequoia-subscribe` is a standard Web Component, it works with any framework. Choose your setup below: diff --git a/docs/src/index.ts b/docs/src/index.ts index 13dac9e..07027ab 100644 --- a/docs/src/index.ts +++ b/docs/src/index.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import auth from "./routes/auth"; import subscribe from "./routes/subscribe"; +import recommend from "./routes/recommend"; import "./lib/path-redirect"; type Bindings = { @@ -30,6 +31,22 @@ app.use( app.route("/oauth", auth); app.route("/subscribe", subscribe); +app.use( + "/recommend", + cors({ + origin: (origin) => origin, + credentials: true, + }), +); +app.use( + "/recommend/*", + cors({ + origin: (origin) => origin, + credentials: true, + }), +); +app.route("/recommend", recommend); + app.get("/api/health", (c) => { return c.json({ status: "ok" }); }); diff --git a/docs/src/lib/oauth-client.ts b/docs/src/lib/oauth-client.ts index 4692cc3..373df79 100644 --- a/docs/src/lib/oauth-client.ts +++ b/docs/src/lib/oauth-client.ts @@ -4,7 +4,7 @@ import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver"; import { createStateStore, createSessionStore } from "./kv-stores"; export const OAUTH_SCOPE = - "atproto repo:site.standard.graph.subscription?action=create&action=delete"; + "atproto repo:site.standard.graph.recommend?action=create&action=delete repo:site.standard.graph.subscription?action=create&action=delete"; export function createOAuthClient(kv: KVNamespace, clientUrl: string) { const clientId = `${clientUrl}/oauth/client-metadata.json`; @@ -22,7 +22,7 @@ export function createOAuthClient(kv: KVNamespace, clientUrl: string) { redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], - scope: "atproto repo:site.standard.graph.subscription?action=create", + scope: OAUTH_SCOPE, token_endpoint_auth_method: "none", application_type: "web", dpop_bound_access_tokens: true, diff --git a/docs/src/routes/lib.ts b/docs/src/routes/lib.ts new file mode 100644 index 0000000..ce09bd3 --- /dev/null +++ b/docs/src/routes/lib.ts @@ -0,0 +1,247 @@ +import type { Agent } from "@atproto/api"; + +export const REDIRECT_DELAY_SECONDS = 5; + +// ============================================================================ +// Helpers +// ============================================================================ + +export function withReturnToParam( + returnTo: string | undefined, + key: string, + value: string, +): string | undefined { + if (!returnTo) return undefined; + try { + const url = new URL(returnTo); + url.searchParams.set(key, value); + return url.toString(); + } catch { + return returnTo; + } +} + +/** + * Scan a repo for a record in `collection` where `record[field] === uri`. + * Returns the record AT-URI, or null if not found. + */ +export async function findExistingRecord( + agent: Agent, + did: string, + collection: string, + field: string, + uri: string, +): Promise { + let cursor: string | undefined; + + do { + const result = await agent.com.atproto.repo.listRecords({ + repo: did, + collection, + limit: 100, + cursor, + }); + + for (const record of result.data.records) { + const value = record.value as Record; + if (value[field] === uri) { + return record.uri; + } + } + + cursor = result.data.cursor; + } while (cursor); + + return null; +} + +// ============================================================================ +// HTML rendering +// ============================================================================ + +export function renderHandleForm( + params: { + resourceUri: string; + resourceField: string; + loginPath: string; + title: string; + description: string; + buttonLabel: string; + returnTo?: string; + error?: string; + action?: string; + }, + styleHref: string, +): string { + const { + resourceUri, + resourceField, + loginPath, + title, + description, + buttonLabel, + returnTo, + error, + action, + } = params; + + const errorHtml = error + ? `

${escapeHtml(error)}

` + : ""; + const returnToInput = returnTo + ? `` + : ""; + const actionInput = action + ? `` + : ""; + + return page( + ` +

${escapeHtml(title)}

+

${escapeHtml(description)}

+ ${errorHtml} +
+ + ${returnToInput} + ${actionInput} + + +
+ `, + styleHref, + ); +} + +export function renderSuccess( + params: { + resourceUri: string; + resourceLabel: string; + recordUri: string | null; + heading: string; + msg: string; + returnTo?: string; + }, + styleHref: string, +): string { + const { resourceUri, resourceLabel, recordUri, heading, msg, returnTo } = + params; + const escapedResourceUri = escapeHtml(resourceUri); + const escapedReturnTo = returnTo ? escapeHtml(returnTo) : ""; + + const redirectHtml = returnTo + ? `

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

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

${escapeHtml(heading)}

+

${msg}

+ ${redirectHtml} + + + + + + + + ${ + recordUri + ? ` + + + ` + : "" + } + +
${escapeHtml(resourceLabel)} + +
Record + +
+ `, + styleHref, + headExtra, + ); +} + +export function renderError(message: string, styleHref: string): string { + return page( + `

Error

${escapeHtml(message)}

`, + styleHref, + ); +} + +export function page(body: string, styleHref: string, headExtra = ""): string { + return ` + + + + + Sequoia + + + ${headExtra} + + + +
+ ${body} +
+ +`; +} + +export function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/docs/src/routes/recommend.ts b/docs/src/routes/recommend.ts new file mode 100644 index 0000000..18a2ce4 --- /dev/null +++ b/docs/src/routes/recommend.ts @@ -0,0 +1,348 @@ +import { Agent } from "@atproto/api"; +import { Hono } from "hono"; +import { createOAuthClient } from "../lib/oauth-client"; +import { getSessionDid, setReturnToCookie } from "../lib/session"; +import { + findExistingRecord, + renderError, + renderHandleForm, + renderSuccess, + withReturnToParam, +} from "./lib"; + +interface Env { + ASSETS: Fetcher; + SEQUOIA_SESSIONS: KVNamespace; + CLIENT_URL: string; +} + +// Cache the vocs-generated stylesheet href across requests (changes on rebuild). +let _vocsStyleHref: string | null = null; + +async function getVocsStyleHref( + assets: Fetcher, + baseUrl: string, +): Promise { + if (_vocsStyleHref) return _vocsStyleHref; + try { + const indexUrl = new URL("/", baseUrl).toString(); + const res = await assets.fetch(indexUrl); + const html = await res.text(); + const match = html.match(/]+href="(\/assets\/style[^"]+\.css)"/); + if (match?.[1]) { + _vocsStyleHref = match[1]; + return match[1]; + } + } catch { + // Fall back to the custom stylesheet which at least provides --sequoia-* vars + } + return "/styles.css"; +} + +const recommend = new Hono<{ Bindings: Env }>(); + +const COLLECTION = "site.standard.graph.recommend"; + +// ============================================================================ +// POST /recommend +// ============================================================================ + +recommend.post("/", async (c) => { + let documentUri: string; + try { + const body = await c.req.json<{ documentUri?: string }>(); + documentUri = body.documentUri ?? ""; + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (!documentUri || !documentUri.startsWith("at://")) { + return c.json({ error: "Missing or invalid documentUri" }, 400); + } + + const did = getSessionDid(c); + if (!did) { + const subscribeUrl = `${c.env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}`; + return c.json({ authenticated: false, subscribeUrl }, 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 existingUri = await findExistingRecord( + agent, + did, + COLLECTION, + "document", + documentUri, + ); + if (existingUri) { + return c.json({ + recommended: true, + existing: true, + recordUri: existingUri, + }); + } + + const result = await agent.com.atproto.repo.createRecord({ + repo: did, + collection: COLLECTION, + record: { + $type: COLLECTION, + document: documentUri, + createdAt: new Date().toISOString(), + }, + }); + + return c.json({ + recommended: true, + existing: false, + recordUri: result.data.uri, + }); + } catch (error) { + console.error("Recommend POST error:", error); + const subscribeUrl = `${c.env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}`; + return c.json({ authenticated: false, subscribeUrl }, 401); + } +}); + +// ============================================================================ +// GET /recommend?documentUri=at://... +// +// Full-page OAuth + recommendation flow. Unauthenticated users land here after +// the component redirects them, and authenticated users land here after the +// OAuth callback (via the login_return_to cookie set in POST /recommend/login). +// ============================================================================ + +recommend.get("/", async (c) => { + const documentUri = c.req.query("documentUri"); + const action = c.req.query("action"); + const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); + + if (action && action !== "remove") { + return c.html(renderError(`Unsupported action: ${action}`, styleHref), 400); + } + + if (!documentUri || !documentUri.startsWith("at://")) { + return c.html( + renderError("Missing or invalid document URI.", styleHref), + 400, + ); + } + + // Prefer an explicit returnTo query param (survives the OAuth round-trip); + // fall back to the Referer header on the first visit, ignoring self-referrals. + const referer = c.req.header("referer"); + const returnTo = + c.req.query("returnTo") ?? + (referer && !referer.includes("/recommend") ? referer : undefined); + + const did = getSessionDid(c); + if (!did) { + return c.html( + renderHandleForm( + { + resourceUri: documentUri, + resourceField: "documentUri", + loginPath: "/recommend/login", + title: "Recommend on Sequoia", + description: "Enter your Bluesky handle to recommend this document.", + buttonLabel: "Continue on Bluesky", + returnTo, + action, + }, + styleHref, + ), + ); + } + + try { + const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); + const session = await client.restore(did); + const agent = new Agent(session); + + if (action === "remove") { + const existingUri = await findExistingRecord( + agent, + did, + COLLECTION, + "document", + documentUri, + ); + if (existingUri) { + const rkey = existingUri.split("/").pop()!; + await agent.com.atproto.repo.deleteRecord({ + repo: did, + collection: COLLECTION, + rkey, + }); + } + + return c.html( + renderSuccess( + { + resourceUri: documentUri, + resourceLabel: "Document", + recordUri: null, + heading: "Recommendation Removed \u2713", + msg: existingUri + ? "You've successfully removed your recommendation." + : "You hadn't recommended this document.", + returnTo: withReturnToParam(returnTo, "sequoia_did", did), + }, + styleHref, + ), + ); + } + + const existingUri = await findExistingRecord( + agent, + did, + COLLECTION, + "document", + documentUri, + ); + const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did); + + if (existingUri) { + return c.html( + renderSuccess( + { + resourceUri: documentUri, + resourceLabel: "Document", + recordUri: existingUri, + heading: "Recommended \u2713", + msg: "You've already recommended this document.", + returnTo: returnToWithDid, + }, + styleHref, + ), + ); + } + + const result = await agent.com.atproto.repo.createRecord({ + repo: did, + collection: COLLECTION, + record: { + $type: COLLECTION, + document: documentUri, + createdAt: new Date().toISOString(), + }, + }); + + return c.html( + renderSuccess( + { + resourceUri: documentUri, + resourceLabel: "Document", + recordUri: result.data.uri, + heading: "Recommended \u2713", + msg: "You've successfully recommended this document!", + returnTo: returnToWithDid, + }, + styleHref, + ), + ); + } catch (error) { + console.error("Recommend GET error:", error); + // Session expired - ask the user to sign in again + return c.html( + renderHandleForm( + { + resourceUri: documentUri, + resourceField: "documentUri", + loginPath: "/recommend/login", + title: "Recommend on Sequoia", + description: "Enter your Bluesky handle to recommend this document.", + buttonLabel: "Continue on Bluesky", + returnTo, + error: "Session expired. Please sign in again.", + action, + }, + styleHref, + ), + ); + } +}); + +// ============================================================================ +// GET /recommend/check?documentUri=at://... +// +// JSON-only endpoint for the web component to check recommendation status. +// +// Responses: +// 200 { recommended: true, recordUri: string } +// 200 { recommended: false } +// 400 { error: string } +// 401 { authenticated: false } +// ============================================================================ + +recommend.get("/check", async (c) => { + const documentUri = c.req.query("documentUri"); + + if (!documentUri || !documentUri.startsWith("at://")) { + return c.json({ error: "Missing or invalid documentUri" }, 400); + } + + // Prefer the server-side session DID; fall back to a client-provided DID + // (stored by the web component from a previous recommend flow). + const did = getSessionDid(c) ?? c.req.query("did") ?? null; + if (!did || !did.startsWith("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 findExistingRecord( + agent, + did, + COLLECTION, + "document", + documentUri, + ); + return recordUri + ? c.json({ recommended: true, recordUri }) + : c.json({ recommended: false }); + } catch { + return c.json({ authenticated: false }, 401); + } +}); + +// ============================================================================ +// POST /recommend/login +// +// Handles the handle-entry form submission. Stores the return URL in a cookie +// so the OAuth callback in auth.ts can redirect back to /recommend after auth. +// ============================================================================ + +recommend.post("/login", async (c) => { + const body = await c.req.parseBody(); + const handle = (body.handle as string | undefined)?.trim(); + const documentUri = body.documentUri as string | undefined; + const formReturnTo = (body.returnTo as string | undefined) || undefined; + const formAction = (body.action as string | undefined) || undefined; + + if (!handle || !documentUri) { + const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); + return c.html( + renderError("Missing handle or document URI.", styleHref), + 400, + ); + } + + const returnTo = + `${c.env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}` + + (formAction ? `&action=${encodeURIComponent(formAction)}` : "") + + (formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : ""); + setReturnToCookie(c, returnTo, c.env.CLIENT_URL); + + return c.redirect( + `${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`, + ); +}); + +export default recommend; diff --git a/docs/src/routes/subscribe.ts b/docs/src/routes/subscribe.ts index 5728981..0d83416 100644 --- a/docs/src/routes/subscribe.ts +++ b/docs/src/routes/subscribe.ts @@ -2,6 +2,13 @@ import { Agent } from "@atproto/api"; import { Hono } from "hono"; import { createOAuthClient } from "../lib/oauth-client"; import { getSessionDid, setReturnToCookie } from "../lib/session"; +import { + findExistingRecord, + renderError, + renderHandleForm, + renderSuccess, + withReturnToParam, +} from "./lib"; interface Env { ASSETS: Fetcher; @@ -35,62 +42,11 @@ async function getVocsStyleHref( const subscribe = new Hono<{ Bindings: Env }>(); const COLLECTION = "site.standard.graph.subscription"; -const REDIRECT_DELAY_SECONDS = 5; // ============================================================================ // Helpers // ============================================================================ -/** - * Append a query parameter to a returnTo URL, preserving existing params. - */ -function withReturnToParam( - returnTo: string | undefined, - key: string, - value: string, -): string | undefined { - if (!returnTo) return undefined; - try { - const url = new URL(returnTo); - url.searchParams.set(key, value); - return url.toString(); - } catch { - return returnTo; - } -} - -/** - * Scan the user's repo for an existing site.standard.graph.subscription - * matching the given publication URI. Returns the record AT-URI if found. - */ -async function findExistingSubscription( - agent: Agent, - did: string, - publicationUri: string, -): Promise { - let cursor: string | undefined; - - do { - const result = await agent.com.atproto.repo.listRecords({ - repo: did, - collection: COLLECTION, - limit: 100, - cursor, - }); - - for (const record of result.data.records) { - const value = record.value as { publication?: string }; - if (value.publication === publicationUri) { - return record.uri; - } - } - - cursor = result.data.cursor; - } while (cursor); - - return null; -} - // ============================================================================ // POST /subscribe // @@ -127,9 +83,11 @@ subscribe.post("/", async (c) => { const session = await client.restore(did); const agent = new Agent(session); - const existingUri = await findExistingSubscription( + const existingUri = await findExistingRecord( agent, did, + COLLECTION, + "publication", publicationUri, ); if (existingUri) { @@ -196,7 +154,20 @@ subscribe.get("/", async (c) => { const did = getSessionDid(c); if (!did) { return c.html( - renderHandleForm(publicationUri, styleHref, returnTo, undefined, action), + renderHandleForm( + { + resourceUri: publicationUri, + resourceField: "publicationUri", + loginPath: "/subscribe/login", + title: "Subscribe on Sequoia", + description: + "Enter your Bluesky handle to subscribe to this publication.", + buttonLabel: "Continue on Bluesky", + returnTo, + action, + }, + styleHref, + ), ); } @@ -206,9 +177,11 @@ subscribe.get("/", async (c) => { const agent = new Agent(session); if (action === "unsubscribe") { - const existingUri = await findExistingSubscription( + const existingUri = await findExistingRecord( agent, did, + COLLECTION, + "publication", publicationUri, ); if (existingUri) { @@ -234,21 +207,30 @@ subscribe.get("/", async (c) => { return c.html( renderSuccess( - publicationUri, - null, - "Unsubscribed ✓", - existingUri - ? "You've successfully unsubscribed!" - : "You weren't subscribed to this publication.", + { + resourceUri: publicationUri, + resourceLabel: "Publication", + recordUri: null, + heading: "Unsubscribed ✓", + msg: existingUri + ? "You've successfully unsubscribed!" + : "You weren't subscribed to this publication.", + returnTo: withReturnToParam( + cleanReturnTo, + "sequoia_unsubscribed", + "1", + ), + }, styleHref, - withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"), ), ); } - const existingUri = await findExistingSubscription( + const existingUri = await findExistingRecord( agent, did, + COLLECTION, + "publication", publicationUri, ); const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did); @@ -256,12 +238,15 @@ subscribe.get("/", async (c) => { if (existingUri) { return c.html( renderSuccess( - publicationUri, - existingUri, - "Subscribed ✓", - "You're already subscribed to this publication.", + { + resourceUri: publicationUri, + resourceLabel: "Publication", + recordUri: existingUri, + heading: "Subscribed ✓", + msg: "You're already subscribed to this publication.", + returnTo: returnToWithDid, + }, styleHref, - returnToWithDid, ), ); } @@ -277,12 +262,15 @@ subscribe.get("/", async (c) => { return c.html( renderSuccess( - publicationUri, - result.data.uri, - "Subscribed ✓", - "You've successfully subscribed!", + { + resourceUri: publicationUri, + resourceLabel: "Publication", + recordUri: result.data.uri, + heading: "Subscribed ✓", + msg: "You've successfully subscribed!", + returnTo: returnToWithDid, + }, styleHref, - returnToWithDid, ), ); } catch (error) { @@ -290,11 +278,19 @@ subscribe.get("/", async (c) => { // Session expired - ask the user to sign in again return c.html( renderHandleForm( - publicationUri, + { + resourceUri: publicationUri, + resourceField: "publicationUri", + loginPath: "/subscribe/login", + title: "Subscribe on Sequoia", + description: + "Enter your Bluesky handle to subscribe to this publication.", + buttonLabel: "Continue on Bluesky", + returnTo, + error: "Session expired. Please sign in again.", + action, + }, styleHref, - returnTo, - "Session expired. Please sign in again.", - action, ), ); } @@ -330,9 +326,11 @@ subscribe.get("/check", async (c) => { 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( + const recordUri = await findExistingRecord( agent, did, + COLLECTION, + "publication", publicationUri, ); return recordUri @@ -376,171 +374,4 @@ subscribe.post("/login", async (c) => { ); }); -// ============================================================================ -// HTML rendering -// ============================================================================ - -function renderHandleForm( - publicationUri: string, - styleHref: string, - returnTo?: string, - error?: string, - action?: string, -): string { - const errorHtml = error - ? `

${escapeHtml(error)}

` - : ""; - const returnToInput = returnTo - ? `` - : ""; - const actionInput = action - ? `` - : ""; - - return page( - ` -

Subscribe on Bluesky

-

Enter your Bluesky handle to subscribe to this publication.

- ${errorHtml} -
- - ${returnToInput} - ${actionInput} - - -
- `, - styleHref, - ); -} - -function renderSuccess( - publicationUri: string, - recordUri: string | null, - heading: string, - msg: string, - styleHref: string, - returnTo?: string, -): string { - const escapedPublicationUri = escapeHtml(publicationUri); - const escapedReturnTo = returnTo ? escapeHtml(returnTo) : ""; - - const redirectHtml = returnTo - ? `

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

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

${escapeHtml(heading)}

-

${msg}

- ${redirectHtml} - - - - - - - - ${ - recordUri - ? ` - - - ` - : "" - } - -
Publication - -
Record - -
- `, - styleHref, - headExtra, - ); -} - -function renderError(message: string, styleHref: string): string { - return page( - `

Error

${escapeHtml(message)}

`, - styleHref, - ); -} - -function page(body: string, styleHref: string, headExtra = ""): string { - return ` - - - - - Sequoia · Subscribe - - - ${headExtra} - - - -
- ${body} -
- -`; -} - -function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - export default subscribe; diff --git a/docs/vocs.config.ts b/docs/vocs.config.ts index a8eebfe..4357340 100644 --- a/docs/vocs.config.ts +++ b/docs/vocs.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ { text: "Setup", link: "/setup" }, { text: "Publishing", link: "/publishing" }, { text: "Comments", link: "/comments" }, + { text: "Recommend", link: "/recommend" }, { text: "Subscribe", link: "/subscribe" }, { text: "Verifying", link: "/verifying" }, { text: "Workflows", link: "/workflows" }, diff --git a/docs/wrangler.toml b/docs/wrangler.toml index 4660cac..1d915b4 100644 --- a/docs/wrangler.toml +++ b/docs/wrangler.toml @@ -8,7 +8,7 @@ directory = "./docs/dist" binding = "ASSETS" not_found_handling = "single-page-application" html_handling = "auto-trailing-slash" -run_worker_first = ["/api/*", "/oauth/*", "/subscribe", "/subscribe/*"] +run_worker_first = ["/api/*", "/oauth/*", "/recommend", "/recommend/*", "/subscribe", "/subscribe/*"] [[kv_namespaces]] binding = "SEQUOIA_SESSIONS" diff --git a/packages/cli/src/components/sequoia-subscribe.js b/packages/cli/src/components/sequoia-subscribe.js index 5e46ec7..becfd47 100644 --- a/packages/cli/src/components/sequoia-subscribe.js +++ b/packages/cli/src/components/sequoia-subscribe.js @@ -1,24 +1,15 @@ /** - * Sequoia Subscribe - An AT Protocol-powered subscribe component - * - * A self-contained Web Component that lets users subscribe to a publication - * via the AT Protocol by creating a site.standard.graph.subscription record. + * Sequoia Web Components — AT Protocol-powered engagement components * - * Usage: - * + * Self-contained Web Components for subscribing to publications and + * recommending documents via the AT Protocol. * - * The component resolves the publication AT URI from the host site's - * /.well-known/site.standard.publication endpoint. + * Both components share: + * - OAuth redirect flow via a hosted callback endpoint + * - DID caching in a cookie (primary) and localStorage (fallback) + * - A common visual style driven by CSS custom properties * - * Attributes: - * - publication-uri: Override the publication AT URI (optional) - * - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/subscribe") - * - button-type: Branding style — "sequoia" (default), "bluesky", "blacksky", "atmosphere", or "plain" - * - label: Override the subscribe button label text - * - unsubscribe-label: Override the unsubscribe button label text - * - hide: Set to "auto" to hide if no publication URI is detected - * - * CSS Custom Properties: + * CSS Custom Properties (apply to both components): * - --sequoia-fg-color: Text color (default: #1f2937) * - --sequoia-bg-color: Background color (default: #ffffff) * - --sequoia-border-color: Border color (default: #e5e7eb) @@ -26,12 +17,6 @@ * - --sequoia-secondary-color: Secondary text color (default: #6b7280) * - --sequoia-border-radius: Border radius (default: 8px) * - --sequoia-icon-display: Icon display mode (default: inline-block) — set to "none" to hide - * - * Events: - * - sequoia-subscribed: Fired when the subscription is created successfully. - * detail: { publicationUri: string, recordUri: string } - * - sequoia-subscribe-error: Fired when the subscription fails. - * detail: { message: string } */ // ============================================================================ @@ -50,7 +35,7 @@ const styles = ` box-sizing: border-box; } -.sequoia-subscribe-button { +.sequoia-button { display: inline-flex; align-items: center; gap: 0.375rem; @@ -67,16 +52,16 @@ const styles = ` font-family: inherit; } -.sequoia-subscribe-button:hover:not(:disabled) { +.sequoia-button:hover:not(:disabled) { background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); } -.sequoia-subscribe-button:disabled { +.sequoia-button:disabled { opacity: 0.6; cursor: not-allowed; } -.sequoia-subscribe-button svg { +.sequoia-button svg { display: var(--sequoia-icon-display, inline-block); width: 1rem; height: 1rem; @@ -148,6 +133,47 @@ const BUTTON_TYPES = { plain: { icon: "", subscribe: "Subscribe", unsubscribe: "Unsubscribe" }, }; +// ============================================================================ +// Recommend Icon Configuration +// ============================================================================ + +const HEART_PATH = + "M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"; +const HEART_ICON_OUTLINED = ``; +const HEART_ICON_FILLED = ``; + +const STAR_PATH = + "M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"; +const STAR_ICON_OUTLINED = ``; +const STAR_ICON_FILLED = ``; + +const THUMBS_UP_RECT_PATH = "M1 21h4V9H1v12z"; +const THUMBS_UP_HAND_PATH = + "M23 10c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"; +const THUMBS_UP_ICON_OUTLINED = ``; +const THUMBS_UP_ICON_FILLED = ``; + +const RECOMMEND_ICON_TYPES = { + heart: { + icon: HEART_ICON_OUTLINED, + iconActioned: HEART_ICON_FILLED, + action: "Recommend", + unaction: "Unrecommend", + }, + star: { + icon: STAR_ICON_OUTLINED, + iconActioned: STAR_ICON_FILLED, + action: "Recommend", + unaction: "Unrecommend", + }, + "thumbs-up": { + icon: THUMBS_UP_ICON_OUTLINED, + iconActioned: THUMBS_UP_ICON_FILLED, + action: "Recommend", + unaction: "Unrecommend", + }, +}; + // ============================================================================ // DID Storage // ============================================================================ @@ -289,7 +315,13 @@ async function fetchPublicationUri(origin) { // SSR-safe base class - use HTMLElement in browser, empty class in Node.js const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; -class SequoiaSubscribe extends BaseElement { +/** + * Abstract base class shared by SequoiaSubscribe and SequoiaRecommend. + * Handles shadow DOM setup, state management, the OAuth redirect flow, + * DID storage, and button rendering. Subclasses implement template methods + * to provide resource-specific behaviour. + */ +class SequoiaActionBase extends BaseElement { constructor() { super(); const shadow = this.attachShadow({ mode: "open" }); @@ -303,82 +335,104 @@ class SequoiaSubscribe extends BaseElement { wrapper.part = "container"; this.wrapper = wrapper; - this.subscribed = false; + this.actioned = false; this.state = { type: "idle" }; this.abortController = null; this.render(); } - static get observedAttributes() { - return [ - "publication-uri", - "callback-uri", - "label", - "unsubscribe-label", - "button-type", - "hide", - ]; - } - - connectedCallback() { - consumeReturnParams(); - this.checkPublication(); - } - disconnectedCallback() { this.abortController?.abort(); } attributeChangedCallback() { - if (this.state.type === "error" || this.state.type === "no-publication") { + if (this.state.type === "error" || this.state.type === "no-resource") { this.state = { type: "idle" }; } this.render(); } - get publicationUri() { - return this.getAttribute("publication-uri") ?? null; - } + // ── Shared getters ─────────────────────────────────────────────────────── get callbackUri() { - return this.getAttribute("callback-uri") ?? "https://sequoia.pub/subscribe"; + return this.getAttribute("callback-uri") ?? this.defaultCallbackUri; } - get label() { - return this.getAttribute("label") ?? null; + get hide() { + return this.getAttribute("hide") === "auto"; } - get unsubscribeLabel() { - return this.getAttribute("unsubscribe-label") ?? null; + // ── Template methods (override in subclasses) ──────────────────────────── + + /** @returns {string} Default callback URI when the attribute is absent */ + get defaultCallbackUri() { + return ""; } - get buttonType() { - const val = this.getAttribute("button-type"); - return val && val in BUTTON_TYPES ? val : "sequoia"; + /** @returns {string} Query-parameter name for the resource URI */ + get resourceParam() { + return "resourceUri"; } - get hide() { - const hideAttr = this.getAttribute("hide"); - return hideAttr === "auto"; + /** + * Value of the `action` query-parameter used in the unaction redirect. + * @returns {string} + */ + get unactionValue() { + return "unaction"; } - async checkPublication() { - this.abortController?.abort(); - this.abortController = new AbortController(); + /** @returns {string} Key in the /check response that signals the action was taken */ + get actionedKey() { + return "actioned"; + } - try { - const uri = this.publicationUri ?? (await fetchPublicationUri()); - this.checkSubscription(uri); - } catch { - this.state = { type: "no-publication" }; - this.render(); - } + /** @returns {string} CustomEvent name dispatched on success */ + get actionedEventName() { + return "sequoia-actioned"; + } + + /** @returns {string} CustomEvent name dispatched on error */ + get errorEventName() { + return "sequoia-action-error"; + } + + /** @returns {string} Fallback error message when the thrown value has no message */ + get defaultErrorMessage() { + return "Action failed"; } - async checkSubscription(publicationUri) { + /** @returns {string} SVG string for the button icon */ + getIcon() { + return ""; + } + + /** @returns {string} Accessible label for the button (defaults to the visible label) */ + getAriaLabel() { + return this.actioned + ? (this.getUnactionLabel?.() ?? this.getDefaultUnactionLabel?.() ?? "") + : (this.label ?? this.getDefaultActionLabel?.() ?? ""); + } + + /** + * Resolve the resource URI for this action. May perform async network calls. + * @returns {Promise} + */ + async resolveResourceUri() { + throw new Error("resolveResourceUri() must be implemented by subclass"); + } + + // ── Shared logic ───────────────────────────────────────────────────────── + + /** + * Check whether the current user has already taken this action for the + * given resource URI. Updates this.actioned and re-renders on success. + * @param {string} resourceUri + */ + async checkStatusFor(resourceUri) { try { const checkUrl = new URL(`${this.callbackUri}/check`); - checkUrl.searchParams.set("publicationUri", publicationUri); + checkUrl.searchParams.set(this.resourceParam, resourceUri); // Pass the stored DID so the server can check without a session cookie const storedDid = getStoredSubscriberDid(); @@ -391,12 +445,12 @@ class SequoiaSubscribe extends BaseElement { }); if (!res.ok) return; const data = await res.json(); - if (data.subscribed) { - this.subscribed = true; + if (data[this.actionedKey]) { + this.actioned = true; this.render(); } } catch { - // Ignore errors — show default subscribe button + // Ignore errors — show default action button } } @@ -405,11 +459,10 @@ class SequoiaSubscribe extends BaseElement { 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`; + // Unaction: redirect to the full-page unaction flow + if (this.actioned) { + const resourceUri = await this.resolveResourceUri(); + window.location.href = `${this.callbackUri}?${this.resourceParam}=${encodeURIComponent(resourceUri)}&action=${this.unactionValue}`; return; } @@ -417,28 +470,27 @@ class SequoiaSubscribe extends BaseElement { this.render(); try { - const publicationUri = - this.publicationUri ?? (await fetchPublicationUri()); + const resourceUri = await this.resolveResourceUri(); const response = await fetch(this.callbackUri, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", referrerPolicy: "no-referrer-when-downgrade", - body: JSON.stringify({ publicationUri }), + body: JSON.stringify({ [this.resourceParam]: resourceUri }), }); const data = await response.json(); if (response.status === 401 && data.authenticated === false) { - // Redirect to the hosted subscribe page to complete OAuth, + // Redirect to the hosted action page to complete OAuth, // passing the current page URL (without credentials) as returnTo. - const subscribeUrl = new URL(data.subscribeUrl); + const actionUrl = new URL(data.subscribeUrl); const pageUrl = new URL(window.location.href); pageUrl.username = ""; pageUrl.password = ""; - subscribeUrl.searchParams.set("returnTo", pageUrl.toString()); - window.location.href = subscribeUrl.toString(); + actionUrl.searchParams.set("returnTo", pageUrl.toString()); + window.location.href = actionUrl.toString(); return; } @@ -456,27 +508,27 @@ class SequoiaSubscribe extends BaseElement { } } - this.subscribed = true; + this.actioned = true; this.state = { type: "idle" }; this.render(); this.dispatchEvent( - new CustomEvent("sequoia-subscribed", { + new CustomEvent(this.actionedEventName, { bubbles: true, composed: true, - detail: { publicationUri, recordUri }, + detail: { [this.resourceParam]: resourceUri, recordUri }, }), ); } catch (error) { if (this.state.type !== "loading") return; const message = - error instanceof Error ? error.message : "Failed to subscribe"; + error instanceof Error ? error.message : this.defaultErrorMessage; this.state = { type: "error", message }; this.render(); this.dispatchEvent( - new CustomEvent("sequoia-subscribe-error", { + new CustomEvent(this.errorEventName, { bubbles: true, composed: true, detail: { message }, @@ -488,7 +540,7 @@ class SequoiaSubscribe extends BaseElement { render() { const { type } = this.state; - if (type === "no-publication") { + if (type === "no-resource") { if (this.hide) { this.wrapper.innerHTML = ""; this.wrapper.style.display = "none"; @@ -497,15 +549,15 @@ class SequoiaSubscribe extends BaseElement { } const isLoading = type === "loading"; - const config = BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia; - const icon = isLoading ? `` - : config.icon; + : this.getIcon(); + + const label = this.actioned + ? (this.getUnactionLabel?.() ?? this.getDefaultUnactionLabel?.() ?? "") + : (this.label ?? this.getDefaultActionLabel?.() ?? ""); - const label = this.subscribed - ? (this.unsubscribeLabel ?? config.unsubscribe) - : (this.label ?? config.subscribe); + const ariaLabel = this.getAriaLabel(); const errorHtml = type === "error" @@ -514,11 +566,11 @@ class SequoiaSubscribe extends BaseElement { this.wrapper.innerHTML = ` + + `); +} + +export function renderSuccess(params: { + resourceUri: string; + resourceLabel: string; + recordUri: string | null; + heading: string; + msg: string; + returnTo?: string; +}): string { + const { resourceUri, resourceLabel, recordUri, heading, msg, returnTo } = + params; + const escapedResourceUri = escapeHtml(resourceUri); + const escapedReturnTo = returnTo ? escapeHtml(returnTo) : ""; + + const redirectHtml = returnTo + ? `

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

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

${escapeHtml(heading)}

+

${msg}

+ ${redirectHtml} + + + + + + + + ${ + recordUri + ? ` + + + ` + : "" + } + +
${escapeHtml(resourceLabel)} + +
Record + +
+ `, + headExtra, + ); +} + +export function renderError(message: string): string { + return page(`

Error

${escapeHtml(message)}

`); +} diff --git a/packages/server/src/routes/recommend.ts b/packages/server/src/routes/recommend.ts new file mode 100644 index 0000000..5e80c19 --- /dev/null +++ b/packages/server/src/routes/recommend.ts @@ -0,0 +1,291 @@ +import { Agent } from "@atproto/api"; +import { Hono } from "hono"; +import type { Database } from "bun:sqlite"; +import { createOAuthClient } from "../lib/oauth-client"; +import { getSessionDid, setReturnToCookie } from "../lib/session"; +import type { Env } from "../env"; +import { + findExistingRecord, + renderError, + renderHandleForm, + renderSuccess, + withReturnToParam, +} from "./lib"; + +type Variables = { env: Env; db: Database }; + +const recommend = new Hono<{ Variables: Variables }>(); + +const COLLECTION = "site.standard.graph.recommend"; + +// ============================================================================ +// POST /recommend +// ============================================================================ + +recommend.post("/", async (c) => { + const env = c.get("env"); + const db = c.get("db"); + + let documentUri: string; + try { + const body = await c.req.json<{ documentUri?: string }>(); + documentUri = body.documentUri ?? ""; + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (!documentUri || !documentUri.startsWith("at://")) { + return c.json({ error: "Missing or invalid documentUri" }, 400); + } + + const did = getSessionDid(c); + if (!did) { + const subscribeUrl = `${env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}`; + return c.json({ authenticated: false, subscribeUrl }, 401); + } + + try { + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); + const session = await client.restore(did); + const agent = new Agent(session); + + const existingUri = await findExistingRecord( + agent, + did, + COLLECTION, + "document", + documentUri, + ); + if (existingUri) { + return c.json({ + recommended: true, + existing: true, + recordUri: existingUri, + }); + } + + const result = await agent.com.atproto.repo.createRecord({ + repo: did, + collection: COLLECTION, + record: { + $type: COLLECTION, + document: documentUri, + createdAt: new Date().toISOString(), + }, + }); + + return c.json({ + recommended: true, + existing: false, + recordUri: result.data.uri, + }); + } catch (error) { + console.error("Recommend POST error:", error); + const subscribeUrl = `${env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}`; + return c.json({ authenticated: false, subscribeUrl }, 401); + } +}); + +// ============================================================================ +// GET /recommend +// ============================================================================ + +recommend.get("/", async (c) => { + const env = c.get("env"); + const db = c.get("db"); + + const documentUri = c.req.query("documentUri"); + const action = c.req.query("action"); + + if (action && action !== "remove") { + return c.html(renderError(`Unsupported action: ${action}`), 400); + } + + if (!documentUri || !documentUri.startsWith("at://")) { + return c.html(renderError("Missing or invalid document URI."), 400); + } + + const referer = c.req.header("referer"); + const returnTo = + c.req.query("returnTo") ?? + (referer && !referer.includes("/recommend") ? referer : undefined); + + const did = getSessionDid(c); + if (!did) { + return c.html( + renderHandleForm({ + resourceUri: documentUri, + resourceField: "documentUri", + loginPath: "/recommend/login", + title: "Recommend on Sequoia", + description: "Enter your Bluesky handle to recommend this document.", + buttonLabel: "Continue on Bluesky", + returnTo, + action, + }), + ); + } + + try { + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); + const session = await client.restore(did); + const agent = new Agent(session); + + if (action === "remove") { + const existingUri = await findExistingRecord( + agent, + did, + COLLECTION, + "document", + documentUri, + ); + if (existingUri) { + const rkey = existingUri.split("/").pop()!; + await agent.com.atproto.repo.deleteRecord({ + repo: did, + collection: COLLECTION, + rkey, + }); + } + + return c.html( + renderSuccess({ + resourceUri: documentUri, + resourceLabel: "Document", + recordUri: null, + heading: "Recommendation Removed", + msg: existingUri + ? "You've successfully removed your recommendation." + : "You hadn't recommended this document.", + returnTo: withReturnToParam(returnTo, "sequoia_did", did), + }), + ); + } + + const existingUri = await findExistingRecord( + agent, + did, + COLLECTION, + "document", + documentUri, + ); + const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did); + + if (existingUri) { + return c.html( + renderSuccess({ + resourceUri: documentUri, + resourceLabel: "Document", + recordUri: existingUri, + heading: "Recommended", + msg: "You've already recommended this document.", + returnTo: returnToWithDid, + }), + ); + } + + const result = await agent.com.atproto.repo.createRecord({ + repo: did, + collection: COLLECTION, + record: { + $type: COLLECTION, + document: documentUri, + createdAt: new Date().toISOString(), + }, + }); + + return c.html( + renderSuccess({ + resourceUri: documentUri, + resourceLabel: "Document", + recordUri: result.data.uri, + heading: "Recommended", + msg: "You've successfully recommended this document!", + returnTo: returnToWithDid, + }), + ); + } catch (error) { + console.error("Recommend GET error:", error); + return c.html( + renderHandleForm({ + resourceUri: documentUri, + resourceField: "documentUri", + loginPath: "/recommend/login", + title: "Recommend on Sequoia", + description: "Enter your Bluesky handle to recommend this document.", + buttonLabel: "Continue on Bluesky", + returnTo, + error: "Session expired. Please sign in again.", + action, + }), + ); + } +}); + +// ============================================================================ +// GET /recommend/check +// ============================================================================ + +recommend.get("/check", async (c) => { + const env = c.get("env"); + const db = c.get("db"); + + const documentUri = c.req.query("documentUri"); + + if (!documentUri || !documentUri.startsWith("at://")) { + return c.json({ error: "Missing or invalid documentUri" }, 400); + } + + const did = getSessionDid(c) ?? c.req.query("did") ?? null; + if (!did || !did.startsWith("did:")) { + return c.json({ authenticated: false }, 401); + } + + try { + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); + const session = await client.restore(did); + const agent = new Agent(session); + const recordUri = await findExistingRecord( + agent, + did, + COLLECTION, + "document", + documentUri, + ); + return recordUri + ? c.json({ recommended: true, recordUri }) + : c.json({ recommended: false }); + } catch { + return c.json({ authenticated: false }, 401); + } +}); + +// ============================================================================ +// POST /recommend/login +// ============================================================================ + +recommend.post("/login", async (c) => { + const env = c.get("env"); + + const body = await c.req.parseBody(); + const handle = (body.handle as string | undefined)?.trim(); + const documentUri = body.documentUri as string | undefined; + const formReturnTo = (body.returnTo as string | undefined) || undefined; + const formAction = (body.action as string | undefined) || undefined; + + if (!handle || !documentUri) { + return c.html(renderError("Missing handle or document URI."), 400); + } + + const returnTo = + `${env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}` + + (formAction ? `&action=${encodeURIComponent(formAction)}` : "") + + (formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : ""); + setReturnToCookie(c, returnTo, env.CLIENT_URL); + + return c.redirect( + `${env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`, + ); +}); + +export default recommend; diff --git a/packages/server/src/routes/subscribe.ts b/packages/server/src/routes/subscribe.ts index bcd449c..73f331d 100644 --- a/packages/server/src/routes/subscribe.ts +++ b/packages/server/src/routes/subscribe.ts @@ -3,62 +3,20 @@ import { Hono } from "hono"; import type { Database } from "bun:sqlite"; import { createOAuthClient } from "../lib/oauth-client"; import { getSessionDid, setReturnToCookie } from "../lib/session"; -import { page, escapeHtml } from "../lib/theme"; import type { Env } from "../env"; +import { + findExistingRecord, + renderError, + renderHandleForm, + renderSuccess, + withReturnToParam, +} from "./lib"; type Variables = { env: Env; db: Database }; const subscribe = new Hono<{ Variables: Variables }>(); const COLLECTION = "site.standard.graph.subscription"; -const REDIRECT_DELAY_SECONDS = 5; - -// ============================================================================ -// Helpers -// ============================================================================ - -function withReturnToParam( - returnTo: string | undefined, - key: string, - value: string, -): string | undefined { - if (!returnTo) return undefined; - try { - const url = new URL(returnTo); - url.searchParams.set(key, value); - return url.toString(); - } catch { - return returnTo; - } -} - -async function findExistingSubscription( - agent: Agent, - did: string, - publicationUri: string, -): Promise { - let cursor: string | undefined; - - do { - const result = await agent.com.atproto.repo.listRecords({ - repo: did, - collection: COLLECTION, - limit: 100, - cursor, - }); - - for (const record of result.data.records) { - const value = record.value as { publication?: string }; - if (value.publication === publicationUri) { - return record.uri; - } - } - - cursor = result.data.cursor; - } while (cursor); - - return null; -} // ============================================================================ // POST /subscribe @@ -91,9 +49,11 @@ subscribe.post("/", async (c) => { const session = await client.restore(did); const agent = new Agent(session); - const existingUri = await findExistingSubscription( + const existingUri = await findExistingRecord( agent, did, + COLLECTION, + "publication", publicationUri, ); if (existingUri) { @@ -152,7 +112,17 @@ subscribe.get("/", async (c) => { const did = getSessionDid(c); if (!did) { return c.html( - renderHandleForm(publicationUri, returnTo, undefined, action), + renderHandleForm({ + resourceUri: publicationUri, + resourceField: "publicationUri", + loginPath: "/subscribe/login", + title: "Subscribe on Sequoia", + description: + "Enter your Bluesky handle to subscribe to this publication.", + buttonLabel: "Continue on Bluesky", + returnTo, + action, + }), ); } @@ -162,9 +132,11 @@ subscribe.get("/", async (c) => { const agent = new Agent(session); if (action === "unsubscribe") { - const existingUri = await findExistingSubscription( + const existingUri = await findExistingRecord( agent, did, + COLLECTION, + "publication", publicationUri, ); if (existingUri) { @@ -188,34 +160,42 @@ subscribe.get("/", async (c) => { } return c.html( - renderSuccess( - publicationUri, - null, - "Unsubscribed", - existingUri + renderSuccess({ + resourceUri: publicationUri, + resourceLabel: "Publication", + recordUri: null, + heading: "Unsubscribed", + msg: existingUri ? "You've successfully unsubscribed!" : "You weren't subscribed to this publication.", - withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"), - ), + returnTo: withReturnToParam( + cleanReturnTo, + "sequoia_unsubscribed", + "1", + ), + }), ); } - const existingUri = await findExistingSubscription( + const existingUri = await findExistingRecord( agent, did, + COLLECTION, + "publication", publicationUri, ); const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did); if (existingUri) { return c.html( - renderSuccess( - publicationUri, - existingUri, - "Subscribed", - "You're already subscribed to this publication.", - returnToWithDid, - ), + renderSuccess({ + resourceUri: publicationUri, + resourceLabel: "Publication", + recordUri: existingUri, + heading: "Subscribed", + msg: "You're already subscribed to this publication.", + returnTo: returnToWithDid, + }), ); } @@ -229,23 +209,30 @@ subscribe.get("/", async (c) => { }); return c.html( - renderSuccess( - publicationUri, - result.data.uri, - "Subscribed", - "You've successfully subscribed!", - returnToWithDid, - ), + renderSuccess({ + resourceUri: publicationUri, + resourceLabel: "Publication", + recordUri: result.data.uri, + heading: "Subscribed", + msg: "You've successfully subscribed!", + returnTo: returnToWithDid, + }), ); } catch (error) { console.error("Subscribe GET error:", error); return c.html( - renderHandleForm( - publicationUri, + renderHandleForm({ + resourceUri: publicationUri, + resourceField: "publicationUri", + loginPath: "/subscribe/login", + title: "Subscribe on Sequoia", + description: + "Enter your Bluesky handle to subscribe to this publication.", + buttonLabel: "Continue on Bluesky", returnTo, - "Session expired. Please sign in again.", + error: "Session expired. Please sign in again.", action, - ), + }), ); } }); @@ -273,9 +260,11 @@ subscribe.get("/check", async (c) => { const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); const session = await client.restore(did); const agent = new Agent(session); - const recordUri = await findExistingSubscription( + const recordUri = await findExistingRecord( agent, did, + COLLECTION, + "publication", publicationUri, ); return recordUri @@ -314,106 +303,4 @@ subscribe.post("/login", async (c) => { ); }); -// ============================================================================ -// HTML rendering -// ============================================================================ - -function renderHandleForm( - publicationUri: string, - returnTo?: string, - error?: string, - action?: string, -): string { - const errorHtml = error ? `

${escapeHtml(error)}

` : ""; - const returnToInput = returnTo - ? `` - : ""; - const actionInput = action - ? `` - : ""; - - return page(` -

Subscribe on Bluesky

-

Enter your Bluesky handle to subscribe to this publication.

- ${errorHtml} -
- - ${returnToInput} - ${actionInput} - - -
- `); -} - -function renderSuccess( - publicationUri: string, - recordUri: string | null, - heading: string, - msg: string, - returnTo?: string, -): string { - const escapedPublicationUri = escapeHtml(publicationUri); - const escapedReturnTo = returnTo ? escapeHtml(returnTo) : ""; - - const redirectHtml = returnTo - ? `

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

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

${escapeHtml(heading)}

-

${msg}

- ${redirectHtml} - - - - - - - - ${ - recordUri - ? ` - - - ` - : "" - } - -
Publication - -
Record - -
- `, - headExtra, - ); -} - -function renderError(message: string): string { - return page(`

Error

${escapeHtml(message)}

`); -} - export default subscribe;