Something went wrong. Try again.
Voting service on AT Protocol.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211import { Client, simpleFetchHandler } from '@atcute/client';
export const pub = new Client({ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) });
export const POLL_COLLECTION = 'blog.brookie.poll.question';export const OPTION_COLLECTION = 'blog.brookie.poll.option';export const VOTE_COLLECTION = 'blog.brookie.poll.vote';export const POST_COLLECTION = 'app.bsky.feed.post';
const CONSTELLATION = 'https://constellation.microcosm.blue';// the worker serves app + vote routes from one origin; VITE_VOTE_ENDPOINT// only matters in vite dev, where the worker runs separately (wrangler dev)const VOTE_ENDPOINT = import.meta.env.VITE_VOTE_ENDPOINT || location.origin;// only votes published by the voting authority count — anyone can write// vote records in their own repo, but those are ignoredconst AUTHORITY_DID = import.meta.env.VITE_AUTHORITY_DID || 'did:plc:xigvguflvkv2sjvh7kxq7c3x';
export async function getProfile(actor) { const { data } = await pub.get('app.bsky.actor.getProfile', { params: { actor } }); return data;}
export async function createPoll(rpc, myDid, question, optionTexts) { // check the post budget before writing anything, so a too-long post can't // leave orphaned option/question records behind const graphemes = pollPostGraphemes(question, optionTexts); if (graphemes > MAX_POST_GRAPHEMES) { throw new Error(`too long for a bluesky post (${graphemes}/${MAX_POST_GRAPHEMES} graphemes)`); }
const createdAt = new Date().toISOString();
const options = []; for (const text of optionTexts) { const res = await rpc.post('com.atproto.repo.createRecord', { input: { repo: myDid, collection: OPTION_COLLECTION, record: { $type: OPTION_COLLECTION, text, createdAt }, }, }); if (!res.ok) throw new Error(`creating option failed: ${res.data?.message || res.data?.error}`); options.push({ uri: res.data.uri, cid: res.data.cid, text }); }
const res = await rpc.post('com.atproto.repo.createRecord', { input: { repo: myDid, collection: POLL_COLLECTION, record: { $type: POLL_COLLECTION, question, options: options.map(({ uri, cid }) => ({ uri, cid })), createdAt, }, }, }); if (!res.ok) throw new Error(`creating poll failed: ${res.data?.message || res.data?.error}`); const poll = { uri: res.data.uri, cid: res.data.cid };
// The poll goes out as a real bluesky post: each option is a rich-text // link facet pointing at the vote endpoint. const { text, facets } = buildPollPost(question, options, poll); const postRes = await rpc.post('com.atproto.repo.createRecord', { input: { repo: myDid, collection: POST_COLLECTION, record: { $type: POST_COLLECTION, text, facets, createdAt }, }, }); if (!postRes.ok) throw new Error(`poll created but posting failed: ${postRes.data?.message || postRes.data?.error}`);
return poll;}
export function voteUrl(poll, option) { const params = new URLSearchParams({ poll: poll.uri, pollCid: poll.cid, option: option.uri, optionCid: option.cid, }); return `${VOTE_ENDPOINT}/vote?${params}`;}
export function resultsUrl(poll) { return `${VOTE_ENDPOINT}/poll?${new URLSearchParams({ poll: poll.uri })}`;}
// Facet byte offsets are utf-8, not js string indices.function buildPollPost(question, options, poll) { const enc = new TextEncoder(); let text = `${question}\n\n`; const facets = []; for (const option of options) { const byteStart = enc.encode(text).byteLength; text += option.text; facets.push({ index: { byteStart, byteEnd: enc.encode(text).byteLength }, features: [{ $type: 'app.bsky.richtext.facet#link', uri: voteUrl(poll, option) }], }); text += '\n'; } text += '\ntap to vote anonymously\n'; const byteStart = enc.encode(text).byteLength; text += 'results'; facets.push({ index: { byteStart, byteEnd: enc.encode(text).byteLength }, features: [{ $type: 'app.bsky.richtext.facet#link', uri: resultsUrl(poll) }], }); return { text, facets };}
export const MAX_POST_GRAPHEMES = 300;
const grapheme = new Intl.Segmenter('en', { granularity: 'grapheme' });const graphemeLen = s => [...grapheme.segment(s)].length;
// Bluesky's limit is 300 graphemes, and facet link urls don't count toward// it — so measure the exact text buildPollPost will produce (with dummy// refs; they only affect the urls) instead of approximating.export function pollPostGraphemes(question, optionTexts) { const ref = { uri: 'at://did/x/y', cid: 'x' }; const { text } = buildPollPost(question, optionTexts.map(t => ({ ...ref, text: t })), ref); return graphemeLen(text);}
export async function listMyPolls(rpc, myDid) { const res = await rpc.get('com.atproto.repo.listRecords', { params: { repo: myDid, collection: POLL_COLLECTION, limit: 50 }, }); if (!res.ok) throw new Error(`listing polls failed: ${res.data?.message || res.data?.error}`); return res.data.records;}
export async function getOptionRecord(rpc, uri) { const [, , repo, collection, rkey] = uri.split('/'); const res = await rpc.get('com.atproto.repo.getRecord', { params: { repo, collection, rkey } }); if (!res.ok) return null; return res.data;}
async function deleteRecord(rpc, uri) { const [, , repo, collection, rkey] = uri.split('/'); const res = await rpc.post('com.atproto.repo.deleteRecord', { input: { repo, collection, rkey }, }); if (!res.ok) throw new Error(`delete failed: ${res.data?.message || res.data?.error}`);}
// Announcement posts aren't referenced by poll records, so find them by// their facet links, which embed each poll's at-uri in the vote/results// urls. One scan of recent posts matches all polls at once.export async function findPollPosts(rpc, myDid, pollUris) { const needles = new Map(pollUris.map(uri => [encodeURIComponent(uri), uri])); const found = new Map(); let cursor; for (let page = 0; page < 3 && found.size < pollUris.length; page++) { const res = await rpc.get('com.atproto.repo.listRecords', { params: { repo: myDid, collection: POST_COLLECTION, limit: 100, ...(cursor ? { cursor } : {}) }, }); if (!res.ok) break; for (const rec of res.data.records) { const links = rec.value?.facets?.flatMap(f => f.features?.map(ft => ft.uri) ?? []) ?? []; for (const [needle, pollUri] of needles) { if (!found.has(pollUri) && links.some(u => u?.includes(needle))) found.set(pollUri, rec.uri); } } cursor = res.data.cursor; if (!cursor) break; } return found;}
export function bskyPostUrl(postUri) { const [, , did, , rkey] = postUri.split('/'); return `https://bsky.app/profile/${did}/post/${rkey}`;}
// Removes the announcement post (best effort — it may already be gone),// then the question record, then its options. Vote records in the// authority's repo are left behind; they're invisible once nothing links// to their targets.export async function deletePoll(rpc, myDid, poll) { const postUri = poll.postUri ?? (await findPollPosts(rpc, myDid, [poll.uri])).get(poll.uri); if (postUri) await deleteRecord(rpc, postUri); await deleteRecord(rpc, poll.uri); for (const option of poll.options) { await deleteRecord(rpc, option.uri); }}
// Backlink count for one option record via microcosm's constellation,// filtered to votes from the authority's repo.export async function countVotes(optionUri) { const params = new URLSearchParams({ subject: optionUri, source: `${VOTE_COLLECTION}:subject.uri`, did: AUTHORITY_DID, limit: '1', }); try { const res = await fetch(`${CONSTELLATION}/xrpc/blue.microcosm.links.getBacklinks?${params}`); if (!res.ok) return 0; const data = await res.json(); return data.total ?? 0; } catch { return 0; }}