'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 ActionType = 'recommend' | 'subscribe'; interface EngagementActionsProps { documentUri?: string; publicationUri?: string; } export function EngagementActions({ documentUri, publicationUri }: EngagementActionsProps) { const clientRef = useRef(null); const [session, setSession] = useState(null); const [ready, setReady] = useState(false); 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); const [actionError, setActionError] = useState(null); const [toast, setToast] = useState(null); const toastTimerRef = useRef>(undefined); 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 } finally { if (!cancelled) setReady(true); } })(); 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]); useEffect(() => { if (!actionError) return; const timer = setTimeout(() => setActionError(null), 3000); return () => clearTimeout(timer); }, [actionError]); const showToast = useCallback((message: string) => { clearTimeout(toastTimerRef.current); setToast(message); toastTimerRef.current = setTimeout(() => setToast(null), 3000); }, []); const handleAction = useCallback((action: ActionType) => { if (!session) { setPendingAction(action); setShowLogin(true); return; } performAction(session, action, documentUri, publicationUri, { setRecommended, setSubscribed, setLoading, setActionError, showToast, }); }, [session, documentUri, publicationUri, showToast]); 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); }; if (!ready) { return (
{documentUri && } {publicationUri && }
); } const actionLabel = pendingAction === 'subscribe' ? 'subscribe for updates' : 'recommend this article'; return (
{documentUri && ( )} {publicationUri && ( )} {session && ( )}
{ setShowLogin(false); setPendingAction(null); }} visible={showLogin} />
{toast && (
{toast}
)}
); } function SkeletonPill() { return (
); } function InlineLogin({ action, onSubmit, onClose, visible, }: { action: string; onSubmit: (handle: string) => void; onClose: () => void; visible: boolean; }) { const [handle, setHandle] = useState(''); const [validationError, setValidationError] = useState(''); const inputRef = useRef(null); useEffect(() => { if (visible) { const raf = requestAnimationFrame(() => inputRef.current?.focus()); return () => cancelAnimationFrame(raf); } }, [visible]); useEffect(() => { if (!visible) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [visible, onClose]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const trimmed = handle.trim(); if (!trimmed) return; if (!trimmed.includes('.') && !trimmed.startsWith('did:')) { setValidationError('Enter a full handle (e.g. yourname.bsky.social) or a DID.'); return; } setValidationError(''); onSubmit(trimmed); }; return (

Sign in with Bluesky to {action}.

{ setHandle(e.target.value); if (validationError) setValidationError(''); }} placeholder="yourname.bsky.social" tabIndex={visible ? 0 : -1} className="w-full rounded-lg px-3 py-2 text-sm outline-none" style={{ backgroundColor: 'rgb(var(--color-bg))', color: 'rgb(var(--color-fg))', borderWidth: '1px', borderColor: validationError ? 'rgb(var(--color-magenta) / 0.5)' : 'rgb(var(--color-border))', }} aria-invalid={!!validationError} aria-describedby={validationError ? 'inline-handle-error' : undefined} /> {validationError && (

{validationError}

)}
); } 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: ActionType, documentUri: string | undefined, publicationUri: string | undefined, callbacks: { setRecommended: (v: boolean) => void; setSubscribed: (v: boolean) => void; setLoading: (v: ActionType | null) => void; setActionError: (v: ActionType | null) => void; showToast: (message: string) => 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); callbacks.showToast('Recommendation removed'); } 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); callbacks.showToast('Recommended!'); } } 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); callbacks.showToast('Unsubscribed'); } 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); callbacks.showToast('Subscribed to updates'); } } } catch (err) { console.error(`Failed to ${action}:`, err); callbacks.setActionError(action); } 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 ( ); }