import { 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 ignored const 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; } }