-
+
+
diff --git a/packages/docs/src/app/oauth-client-metadata.json/route.ts b/packages/docs/src/app/oauth-client-metadata.json/route.ts
new file mode 100644
index 0000000..058e142
--- /dev/null
+++ b/packages/docs/src/app/oauth-client-metadata.json/route.ts
@@ -0,0 +1,18 @@
+import { NextRequest } from 'next/server';
+
+export function GET(request: NextRequest) {
+ const origin = new URL(request.url).origin;
+
+ return Response.json({
+ client_id: `${origin}/oauth-client-metadata.json`,
+ client_name: 'HappyView',
+ client_uri: origin,
+ redirect_uris: [`${origin}/oauth/callback`],
+ grant_types: ['authorization_code'],
+ response_types: ['code'],
+ scope: 'atproto transition:generic',
+ token_endpoint_auth_method: 'none',
+ application_type: 'web',
+ dpop_bound_access_tokens: true,
+ });
+}
diff --git a/packages/docs/src/app/oauth/callback/page.tsx b/packages/docs/src/app/oauth/callback/page.tsx
new file mode 100644
index 0000000..1fe9825
--- /dev/null
+++ b/packages/docs/src/app/oauth/callback/page.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { getOAuthClient } from '@/lib/atproto-oauth';
+
+export default function OAuthCallback() {
+ const [error, setError] = useState
(null);
+
+ useEffect(() => {
+ getOAuthClient()
+ .then((client) => client.init())
+ .then((result) => {
+ const returnUrl = result?.state ?? '/blog';
+ window.location.replace(returnUrl);
+ })
+ .catch((err) => {
+ setError(err instanceof Error ? err.message : 'Login failed');
+ });
+ }, []);
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ Completing login...
+
+
+ );
+}
diff --git a/packages/docs/src/components/engagement-actions.tsx b/packages/docs/src/components/engagement-actions.tsx
new file mode 100644
index 0000000..8abb09b
--- /dev/null
+++ b/packages/docs/src/components/engagement-actions.tsx
@@ -0,0 +1,403 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Agent } from '@atproto/api';
+import { BrowserOAuthClient } from '@atproto/oauth-client-browser';
+import { getOAuthClient } from '@/lib/atproto-oauth';
+
+type OAuthSession = Awaited>;
+type PendingAction = 'recommend' | 'subscribe';
+
+interface EngagementActionsProps {
+ documentUri?: string;
+ publicationUri?: string;
+}
+
+export function EngagementActions({ documentUri, publicationUri }: EngagementActionsProps) {
+ const clientRef = useRef(null);
+ const [session, setSession] = useState(null);
+ const [showLogin, setShowLogin] = useState(false);
+ const [pendingAction, setPendingAction] = useState(null);
+ const [recommended, setRecommended] = useState(false);
+ const [subscribed, setSubscribed] = useState(false);
+ const [loading, setLoading] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ (async () => {
+ try {
+ const client = await getOAuthClient();
+ if (cancelled) return;
+ clientRef.current = client;
+ const result = await client.init();
+ if (!cancelled && result?.session) {
+ setSession(result.session);
+ }
+ } catch {
+ // OAuth init can fail on localhost — use http://127.0.0.1:PORT instead
+ }
+ })();
+
+ return () => { cancelled = true; };
+ }, []);
+
+ useEffect(() => {
+ if (!session) return;
+
+ const agent = new Agent(session);
+
+ if (documentUri) {
+ checkExistingRecord(agent, session.did, 'site.standard.graph.recommend', documentUri, 'document')
+ .then(setRecommended);
+ }
+
+ if (publicationUri) {
+ checkExistingRecord(agent, session.did, 'site.standard.graph.subscription', publicationUri, 'publication')
+ .then(setSubscribed);
+ }
+ }, [session, documentUri, publicationUri]);
+
+ const handleAction = useCallback((action: PendingAction) => {
+ if (!session) {
+ setPendingAction(action);
+ setShowLogin(true);
+ return;
+ }
+
+ performAction(session, action, documentUri, publicationUri, {
+ setRecommended,
+ setSubscribed,
+ setLoading,
+ });
+ }, [session, documentUri, publicationUri]);
+
+ useEffect(() => {
+ if (session && pendingAction) {
+ setPendingAction(null);
+ handleAction(pendingAction);
+ }
+ }, [session, pendingAction, handleAction]);
+
+ const handleLogin = async (handle: string) => {
+ const client = clientRef.current;
+ if (!client) return;
+
+ setShowLogin(false);
+ await client.signInRedirect(handle, {
+ state: window.location.href,
+ });
+ };
+
+ const handleLogout = async () => {
+ if (!session || !clientRef.current) return;
+ await clientRef.current.revoke(session.did);
+ setSession(null);
+ setRecommended(false);
+ setSubscribed(false);
+ };
+
+ return (
+
+ {documentUri && (
+
+ )}
+
+ {publicationUri && (
+
+ )}
+
+ {session && (
+
+ )}
+
+ {showLogin && (
+ {
+ setShowLogin(false);
+ setPendingAction(null);
+ }}
+ />
+ )}
+
+ );
+}
+
+function LoginDialog({
+ onSubmit,
+ onClose,
+}: {
+ onSubmit: (handle: string) => void;
+ onClose: () => void;
+}) {
+ const [handle, setHandle] = useState('');
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ useEffect(() => {
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [onClose]);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const trimmed = handle.trim();
+ if (trimmed) onSubmit(trimmed);
+ };
+
+ return (
+ {
+ if (e.target === e.currentTarget) onClose();
+ }}
+ >
+
+
+ Log in with AT Protocol
+
+
+ Enter your handle to continue.
+
+
+
+
+ );
+}
+
+async function checkExistingRecord(
+ agent: Agent,
+ did: string,
+ collection: string,
+ targetUri: string,
+ subjectField: string,
+): Promise {
+ try {
+ const { data } = await agent.com.atproto.repo.listRecords({
+ repo: did,
+ collection,
+ limit: 100,
+ });
+ return data.records.some(
+ (r) => (r.value as Record)[subjectField] === targetUri,
+ );
+ } catch {
+ return false;
+ }
+}
+
+async function performAction(
+ session: OAuthSession,
+ action: PendingAction,
+ documentUri: string | undefined,
+ publicationUri: string | undefined,
+ callbacks: {
+ setRecommended: (v: boolean) => void;
+ setSubscribed: (v: boolean) => void;
+ setLoading: (v: PendingAction | null) => void;
+ },
+) {
+ const agent = new Agent(session);
+ callbacks.setLoading(action);
+
+ try {
+ if (action === 'recommend' && documentUri) {
+ const existing = await findExistingRecord(
+ agent, session.did, 'site.standard.graph.recommend', documentUri, 'document',
+ );
+
+ if (existing) {
+ await agent.com.atproto.repo.deleteRecord({
+ repo: session.did,
+ collection: 'site.standard.graph.recommend',
+ rkey: existing.split('/').pop()!,
+ });
+ callbacks.setRecommended(false);
+ } else {
+ await agent.com.atproto.repo.createRecord({
+ repo: session.did,
+ collection: 'site.standard.graph.recommend',
+ record: {
+ $type: 'site.standard.graph.recommend',
+ document: documentUri,
+ createdAt: new Date().toISOString(),
+ },
+ });
+ callbacks.setRecommended(true);
+ }
+ }
+
+ if (action === 'subscribe' && publicationUri) {
+ const existing = await findExistingRecord(
+ agent, session.did, 'site.standard.graph.subscription', publicationUri, 'publication',
+ );
+
+ if (existing) {
+ await agent.com.atproto.repo.deleteRecord({
+ repo: session.did,
+ collection: 'site.standard.graph.subscription',
+ rkey: existing.split('/').pop()!,
+ });
+ callbacks.setSubscribed(false);
+ } else {
+ await agent.com.atproto.repo.createRecord({
+ repo: session.did,
+ collection: 'site.standard.graph.subscription',
+ record: {
+ $type: 'site.standard.graph.subscription',
+ publication: publicationUri,
+ createdAt: new Date().toISOString(),
+ },
+ });
+ callbacks.setSubscribed(true);
+ }
+ }
+ } catch (err) {
+ console.error(`Failed to ${action}:`, err);
+ } finally {
+ callbacks.setLoading(null);
+ }
+}
+
+async function findExistingRecord(
+ agent: Agent,
+ did: string,
+ collection: string,
+ targetUri: string,
+ subjectField: string,
+): Promise {
+ try {
+ const { data } = await agent.com.atproto.repo.listRecords({
+ repo: did,
+ collection,
+ limit: 100,
+ });
+ const match = data.records.find(
+ (r) => (r.value as Record)[subjectField] === targetUri,
+ );
+ return match?.uri ?? null;
+ } catch {
+ return null;
+ }
+}
+
+function HeartIcon({ filled }: { filled: boolean }) {
+ return (
+
+ );
+}
+
+function BellIcon({ filled }: { filled: boolean }) {
+ return (
+
+ );
+}
diff --git a/packages/docs/src/components/sequoia-comments-loader.tsx b/packages/docs/src/components/sequoia-loader.tsx
similarity index 76%
rename from packages/docs/src/components/sequoia-comments-loader.tsx
rename to packages/docs/src/components/sequoia-loader.tsx
index 5256cbf..15fad0c 100644
--- a/packages/docs/src/components/sequoia-comments-loader.tsx
+++ b/packages/docs/src/components/sequoia-loader.tsx
@@ -2,7 +2,7 @@
import { useEffect } from 'react';
-export function SequoiaCommentsLoader() {
+export function SequoiaLoader() {
useEffect(() => {
import('./sequoia-comments.js');
}, []);
diff --git a/packages/docs/src/lib/atproto-oauth.ts b/packages/docs/src/lib/atproto-oauth.ts
new file mode 100644
index 0000000..83b4c45
--- /dev/null
+++ b/packages/docs/src/lib/atproto-oauth.ts
@@ -0,0 +1,28 @@
+import { BrowserOAuthClient } from '@atproto/oauth-client-browser';
+
+let clientPromise: Promise | null = null;
+
+export function getOAuthClient(): Promise {
+ if (!clientPromise) {
+ const origin = window.location.origin;
+ const isLoopback = origin.startsWith('http://localhost') || origin.startsWith('http://127.0.0.1');
+
+ if (isLoopback) {
+ const port = window.location.port;
+ const redirectUri = `http://127.0.0.1:${port}/oauth/callback`;
+ const clientId = `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent('atproto transition:generic')}`;
+
+ clientPromise = BrowserOAuthClient.load({
+ clientId,
+ handleResolver: 'https://bsky.social',
+ });
+ } else {
+ clientPromise = BrowserOAuthClient.load({
+ clientId: `${origin}/oauth-client-metadata.json`,
+ handleResolver: 'https://bsky.social',
+ });
+ }
+ }
+
+ return clientPromise;
+}