import { configureOAuth, createAuthorizationUrl, finalizeAuthorization, OAuthUserAgent, getSession, deleteStoredSession, } from '@atcute/oauth-browser-client'; import { Client } from '@atcute/client'; import { LocalActorResolver, CompositeDidDocumentResolver, PlcDidDocumentResolver, WebDidDocumentResolver, XrpcHandleResolver, } from '@atcute/identity-resolver'; import { getProfile, createPoll, deletePoll, findPollPosts, listMyPolls, getOptionRecord, countVotes, pollPostGraphemes, MAX_POST_GRAPHEMES, POLL_COLLECTION, OPTION_COLLECTION, POST_COLLECTION, } from './api.js'; import { showLoading, showMain, showLoginError, showError, setWhoami, setLoginLoading, setPublishLoading, setPollsDimmed, resetPollForm, readPollForm, updatePostBudget, renderPolls, bindEvents, } from './ui.js'; // ── Config ───────────────────────────────────────────────────────────────── // Polls, options and the announcement post are written to the signed-in // user's repo. Votes are NOT — they go to the voting authority's repo via // the vote server (see worker/). const SCOPE = [ 'atproto', ...[POLL_COLLECTION, OPTION_COLLECTION, POST_COLLECTION].flatMap(c => [`repo:${c}?action=create`, `repo:${c}?action=delete`]), ].join(' '); const IS_LOCAL = location.hostname === 'localhost' || location.hostname === '127.0.0.1'; const PROD_ORIGIN = 'https://poll.brookie.blog'; const REDIRECT_URI = IS_LOCAL ? location.origin.replace('localhost', '127.0.0.1') + '/' : PROD_ORIGIN + '/'; const CLIENT_ID = IS_LOCAL ? `http://localhost?redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=${encodeURIComponent(SCOPE)}` : `${PROD_ORIGIN}/oauth-client-metadata.json`; configureOAuth({ metadata: { client_id: CLIENT_ID, redirect_uri: REDIRECT_URI }, identityResolver: new LocalActorResolver({ handleResolver: new XrpcHandleResolver({ serviceUrl: 'https://public.api.bsky.app' }), didDocumentResolver: new CompositeDidDocumentResolver({ methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver(), }, }), }), }); // ── State ────────────────────────────────────────────────────────────────── let rpc = null; let myDid = null; let polls = []; let busy = false; // ── Startup & UI Binding ─────────────────────────────────────────────────── bindEvents({ onLogin: async (handle) => { if (!handle) return; setLoginLoading(true); try { const url = await createAuthorizationUrl({ target: { type: 'account', identifier: handle }, scope: SCOPE }); await new Promise(r => setTimeout(r, 100)); // let browser persist localStorage location.href = url.toString(); } catch (e) { showLoginError(e.message); setLoginLoading(false); } }, onLogout: async () => { try { const session = await getSession(myDid, { allowStale: true }); await new OAuthUserAgent(session).signOut(); } catch { deleteStoredSession(myDid); } localStorage.removeItem('did'); location.reload(); }, onCreatePoll: async ({ question, options }) => { if (busy) return; showError(''); if (!question) return showError('a poll needs a question'); if (options.length < 2) return showError('a poll needs at least two options'); const graphemes = pollPostGraphemes(question, options); if (graphemes > MAX_POST_GRAPHEMES) { return showError(`too long for a bluesky post (${graphemes}/${MAX_POST_GRAPHEMES} graphemes)`); } busy = true; setPublishLoading(true); try { await createPoll(rpc, myDid, question, options); resetPollForm(); refreshPostBudget(); await loadPolls(); } catch (e) { showError(e.message); } finally { busy = false; setPublishLoading(false); } }, onPollInput: refreshPostBudget, }); function refreshPostBudget() { const { question, options } = readPollForm(); updatePostBudget(pollPostGraphemes(question, options), MAX_POST_GRAPHEMES); } (async () => { const params = new URLSearchParams(decodeURIComponent(location.hash.slice(1))); if (params.has('state') && (params.has('code') || params.has('error'))) { history.replaceState(null, '', location.pathname); try { const { session } = await finalizeAuthorization(params); await initSession(session); } catch (e) { showLoginError('OAuth failed: ' + e.message); } return; } // Resume from stored session const storedDid = localStorage.getItem('did'); if (storedDid) { try { const session = await getSession(storedDid, { allowStale: true }); await initSession(session); return; } catch { localStorage.removeItem('did'); } } })(); async function initSession(session) { myDid = session.info.sub; localStorage.setItem('did', myDid); rpc = new Client({ handler: new OAuthUserAgent(session) }); showLoading('loading your polls…'); const profile = await getProfile(myDid); setWhoami(profile); showMain(); await loadPolls(); } // ── Polls ────────────────────────────────────────────────────────────────── // courtesy double-vote check: per-browser, clearable, not enforcement const votedKey = pollUri => `voted:${pollUri}`; async function loadPolls() { try { const records = await listMyPolls(rpc, myDid); const postUris = await findPollPosts(rpc, myDid, records.map(r => r.uri)); polls = await Promise.all(records.map(async r => { const options = await Promise.all((r.value.options ?? []).map(async ref => { const [rec, count] = await Promise.all([getOptionRecord(rpc, ref.uri), countVotes(ref.uri)]); return { uri: ref.uri, cid: ref.cid, text: rec?.value?.text ?? '(missing option)', count }; })); return { uri: r.uri, cid: r.cid, question: r.value.question, createdAt: r.value.createdAt, options, votedFor: localStorage.getItem(votedKey(r.uri)), postUri: postUris.get(r.uri) ?? null, }; })); rerender(); } catch (e) { showError(e.message); } } function rerender() { renderPolls(polls, removePoll); } async function removePoll(pollUri) { if (busy) return; const poll = polls.find(p => p.uri === pollUri); if (!poll) return; busy = true; showError(''); setPollsDimmed(true); try { await deletePoll(rpc, myDid, poll); localStorage.removeItem(votedKey(poll.uri)); polls = polls.filter(p => p.uri !== pollUri); rerender(); } catch (e) { showError(e.message); } finally { busy = false; setPollsDimmed(false); } }