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]
+
+
+
+
+ My Post
+
+
+
+
+```
+
+```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}
+
+ `,
+ 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}
+
+
+
+
+ ${escapeHtml(resourceLabel)}
+
+
+
+
+ ${
+ recordUri
+ ? `
+ 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}
-
- `,
- 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}
-
-
-
-
- Publication
-
-
-
-
- ${
- recordUri
- ? `
- 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 = `
${icon}
${label}
@@ -531,6 +583,197 @@ class SequoiaSubscribe extends BaseElement {
}
}
+class SequoiaSubscribe extends SequoiaActionBase {
+ static get observedAttributes() {
+ return [
+ "publication-uri",
+ "callback-uri",
+ "label",
+ "unsubscribe-label",
+ "button-type",
+ "hide",
+ ];
+ }
+
+ connectedCallback() {
+ consumeReturnParams();
+ this.checkPublication();
+ }
+
+ get publicationUri() {
+ return this.getAttribute("publication-uri") ?? null;
+ }
+
+ get label() {
+ return this.getAttribute("label") ?? null;
+ }
+
+ get buttonType() {
+ const val = this.getAttribute("button-type");
+ return val && val in BUTTON_TYPES ? val : "sequoia";
+ }
+
+ get unsubscribeLabel() {
+ return this.getAttribute("unsubscribe-label") ?? null;
+ }
+
+ // ── Template method overrides ────────────────────────────────────────────
+
+ get defaultCallbackUri() {
+ return "https://sequoia.pub/subscribe";
+ }
+ get resourceParam() {
+ return "publicationUri";
+ }
+ get unactionValue() {
+ return "unsubscribe";
+ }
+ get actionedKey() {
+ return "subscribed";
+ }
+ get actionedEventName() {
+ return "sequoia-subscribed";
+ }
+ get errorEventName() {
+ return "sequoia-subscribe-error";
+ }
+ get defaultErrorMessage() {
+ return "Failed to subscribe";
+ }
+
+ getDefaultActionLabel() {
+ return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).subscribe;
+ }
+
+ getDefaultUnactionLabel() {
+ return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).unsubscribe;
+ }
+
+ getUnactionLabel() {
+ return this.unsubscribeLabel;
+ }
+
+ getIcon() {
+ return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).icon;
+ }
+
+ async resolveResourceUri() {
+ return this.publicationUri ?? (await fetchPublicationUri());
+ }
+
+ // ── SequoiaSubscribe-specific logic ──────────────────────────────────────
+
+ /** @returns {boolean} Whether the user is currently subscribed. Alias for this.actioned. */
+ get subscribed() {
+ return this.actioned;
+ }
+
+ /**
+ * Check whether the current user is subscribed to the given publication URI.
+ * Forwards to the shared checkStatusFor() method.
+ * @param {string} publicationUri
+ */
+ checkSubscription(publicationUri) {
+ return this.checkStatusFor(publicationUri);
+ }
+
+ async checkPublication() {
+ this.abortController?.abort();
+ this.abortController = new AbortController();
+
+ try {
+ const uri = await this.resolveResourceUri();
+ this.checkStatusFor(uri);
+ } catch {
+ this.state = { type: "no-resource" };
+ this.render();
+ }
+ }
+}
+
+class SequoiaRecommend extends SequoiaActionBase {
+ static get observedAttributes() {
+ return ["document-uri", "callback-uri", "button-type", "hide"];
+ }
+
+ connectedCallback() {
+ consumeReturnParams();
+ this.checkDocument();
+ }
+
+ get documentUri() {
+ const attrUri = this.getAttribute("document-uri");
+ if (attrUri) return attrUri;
+ const linkTag = document.querySelector(
+ 'link[rel="site.standard.document"]',
+ );
+ return linkTag?.href ?? null;
+ }
+
+ get buttonType() {
+ const val = this.getAttribute("button-type");
+ return val && val in RECOMMEND_ICON_TYPES ? val : "heart";
+ }
+
+ // ── Template method overrides ────────────────────────────────────────────
+
+ get defaultCallbackUri() {
+ return "https://sequoia.pub/recommend";
+ }
+ get resourceParam() {
+ return "documentUri";
+ }
+ get unactionValue() {
+ return "remove";
+ }
+ get actionedKey() {
+ return "recommended";
+ }
+ get actionedEventName() {
+ return "sequoia-recommended";
+ }
+ get errorEventName() {
+ return "sequoia-recommend-error";
+ }
+ get defaultErrorMessage() {
+ return "Failed to recommend";
+ }
+
+ getAriaLabel() {
+ const config =
+ RECOMMEND_ICON_TYPES[this.buttonType] ?? RECOMMEND_ICON_TYPES.heart;
+ return this.actioned ? config.unaction : config.action;
+ }
+
+ getIcon() {
+ const config =
+ RECOMMEND_ICON_TYPES[this.buttonType] ?? RECOMMEND_ICON_TYPES.heart;
+ return this.actioned ? config.iconActioned : config.icon;
+ }
+
+ async resolveResourceUri() {
+ const uri = this.documentUri;
+ if (!uri) throw new Error("No document URI found");
+ return uri;
+ }
+
+ // ── SequoiaRecommend-specific logic ──────────────────────────────────────
+
+ async checkDocument() {
+ this.abortController?.abort();
+ this.abortController = new AbortController();
+
+ const uri = this.documentUri;
+ if (!uri) {
+ this.state = { type: "no-resource" };
+ this.render();
+ return;
+ }
+
+ this.checkStatusFor(uri);
+ }
+}
+
/**
* Escape HTML special characters (no DOM dependency for SSR).
* @param {string} text
@@ -544,10 +787,62 @@ function escapeHtml(text) {
.replace(/"/g, """);
}
-// Register the custom element
+// Register the custom elements
if (typeof customElements !== "undefined") {
customElements.define("sequoia-subscribe", SequoiaSubscribe);
+ customElements.define("sequoia-recommend", SequoiaRecommend);
}
-// Export for module usage
+/**
+ * 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.
+ *
+ * Usage:
+ *
+ *
+ * The component resolves the publication AT URI from the host site's
+ * /.well-known/site.standard.publication endpoint.
+ *
+ * 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
+ *
+ * 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 }
+ */
export { SequoiaSubscribe };
+
+/**
+ * Sequoia Recommend - An AT Protocol-powered recommend component
+ *
+ * A self-contained Web Component that lets users recommend a document
+ * via the AT Protocol by creating a site.standard.graph.recommend record.
+ *
+ * Usage:
+ *
+ *
+ * The component resolves the document AT URI from the `document-uri` attribute
+ * or a tag in the page head.
+ *
+ * Attributes:
+ * - document-uri: AT Protocol URI of the document to recommend (optional if link tag present)
+ * - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/recommend")
+ * - button-type: Icon style — "heart" (default), "star", or "thumbs-up"
+ * - hide: Set to "auto" to hide if no document URI is detected
+ *
+ * Events:
+ * - sequoia-recommended: Fired when the recommendation is created successfully.
+ * detail: { documentUri: string, recordUri: string }
+ * - sequoia-recommend-error: Fired when the recommendation fails.
+ * detail: { message: string }
+ */
+export { SequoiaRecommend };
diff --git a/packages/server/README.md b/packages/server/README.md
index b6e71dc..74955d3 100644
--- a/packages/server/README.md
+++ b/packages/server/README.md
@@ -1,6 +1,6 @@
# Sequoia Server
-Self-hostable AT Protocol OAuth and subscription server. Handles Bluesky login and manages `site.standard.graph.subscription` records on behalf of users. Built with Bun, Hono, and Redis.
+Self-hostable AT Protocol OAuth and subscription/recommendation server. Handles Bluesky login and manages `site.standard.graph.subscription` and `site.standard.graph.recommend` records on behalf of users. Built with Bun, Hono, and Redis.
## Quickstart
@@ -23,10 +23,10 @@ CLIENT_URL=http://localhost:3000 bun run dev
## How it works
-1. A user visits `/subscribe?publicationUri=at://...` and enters their Bluesky handle
+1. A user visits `/subscribe?publicationUri=at://...` or `/recommend?documentUri=at://...` and enters their Bluesky handle
2. The server initiates an AT Protocol OAuth flow — the user authorizes on Bluesky
-3. After callback, the server creates a `site.standard.graph.subscription` record in the user's repo
-4. The [sequoia-subscribe](https://github.com/standard-schema/sequoia) web component can point to this server for the full flow
+3. After callback, the server creates a `site.standard.graph.subscription` or `site.standard.graph.recommend` record in the user's repo
+4. The [sequoia-subscribe](https://github.com/standard-schema/sequoia) web components can point to this server for the full flow
### Routes
@@ -42,6 +42,10 @@ CLIENT_URL=http://localhost:3000 bun run dev
| `/subscribe` | POST | Subscribe via API (JSON) |
| `/subscribe/check` | GET | Check subscription status |
| `/subscribe/login` | POST | Handle form submission |
+| `/recommend` | GET | Recommend page (HTML) |
+| `/recommend` | POST | Recommend via API (JSON) |
+| `/recommend/check` | GET | Check recommendation status |
+| `/recommend/login` | POST | Handle form submission |
## Configuration
@@ -54,7 +58,7 @@ CLIENT_URL=http://localhost:3000 bun run dev
### Theming
-The subscribe pages use CSS custom properties that can be overridden via environment variables:
+The subscribe and recommend pages use CSS custom properties that can be overridden via environment variables:
| Variable | Default |
|----------|---------|
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 5e8a907..bcd8395 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -5,6 +5,7 @@ import type { Env } from "./env";
import { openDatabase } from "./lib/db";
import auth from "./routes/auth";
import subscribe from "./routes/subscribe";
+import recommend from "./routes/recommend";
const env = loadEnv();
@@ -44,6 +45,23 @@ app.use(
);
app.route("/subscribe", subscribe);
+// Recommend routes with CORS
+app.use(
+ "/recommend/*",
+ cors({
+ origin: (origin) => origin,
+ credentials: true,
+ }),
+);
+app.use(
+ "/recommend",
+ cors({
+ origin: (origin) => origin,
+ credentials: true,
+ }),
+);
+app.route("/recommend", recommend);
+
console.log(`Sequoia server listening on port ${env.PORT}`);
export default {
diff --git a/packages/server/src/lib/oauth-client.ts b/packages/server/src/lib/oauth-client.ts
index a46ba85..f874f37 100644
--- a/packages/server/src/lib/oauth-client.ts
+++ b/packages/server/src/lib/oauth-client.ts
@@ -5,7 +5,7 @@ import type { Database } from "bun:sqlite";
import { createStateStore, createSessionStore } from "./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(
db: Database,
diff --git a/packages/server/src/routes/lib.ts b/packages/server/src/routes/lib.ts
new file mode 100644
index 0000000..94fdb4a
--- /dev/null
+++ b/packages/server/src/routes/lib.ts
@@ -0,0 +1,179 @@
+import type { Agent } from "@atproto/api";
+import { escapeHtml, page } from "../lib/theme";
+
+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;
+}): 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}
+
+ `);
+}
+
+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}
+
+
+
+
+ ${escapeHtml(resourceLabel)}
+
+
+
+
+ ${
+ recordUri
+ ? `
+ 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}
-
- `);
-}
-
-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}
-
-
-
-
- Publication
-
-
-
-
- ${
- recordUri
- ? `
- Record
-
-
-
- `
- : ""
- }
-
-
- `,
- headExtra,
- );
-}
-
-function renderError(message: string): string {
- return page(`Error ${escapeHtml(message)}
`);
-}
-
export default subscribe;