Something went wrong. Try again.
Voting service on AT Protocol.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233import { 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 enforcementconst 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); }}