polls on atproto pollz.waow.tech
atproto zig

link to src

Changed files
+11 -104
src
+1
.gitignore
··· 1 1 node_modules 2 2 dist 3 3 .vite 4 + .wrangler 4 5 .zig-cache 5 6 zig-out 6 7 *.db
+1 -1
index.html
··· 135 135 </head> 136 136 <body> 137 137 <header> 138 - <h1><a href="/">pollz</a></h1> 138 + <h1><a href="/">pollz</a> <a href="https://tangled.sh/@zzstoatzz.io/pollz" target="_blank" style="font-size:11px;color:#555">[src]</a></h1> 139 139 <nav id="nav"></nav> 140 140 </header> 141 141 <main id="app"></main>
-29
src/lib/api.ts
··· 63 63 text: string; 64 64 options: string[]; 65 65 createdAt: string; 66 - votes: Map<string, number>; 67 66 voteCount?: number; 68 67 }; 69 68 ··· 139 138 text: p.text, 140 139 options: p.options, 141 140 createdAt: p.createdAt, 142 - votes: new Map(), 143 141 voteCount: p.voteCount, 144 142 }); 145 143 } ··· 165 163 return res.json() as Promise<Array<{ voter: string; option: number; uri: string; createdAt?: string }>>; 166 164 }; 167 165 168 - // user votes 169 - export const loadUserVotes = async (): Promise<void> => { 170 - if (!agent || !currentDid) return; 171 - 172 - try { 173 - const rpc = new Client({ handler: agent }); 174 - const res = await rpc.get("com.atproto.repo.listRecords", { 175 - params: { repo: currentDid, collection: VOTE, limit: 100 }, 176 - }); 177 - 178 - if (res.ok) { 179 - for (const record of res.data.records) { 180 - const val = record.value as { subject?: string; option?: number }; 181 - if (val.subject && typeof val.option === "number") { 182 - const poll = polls.get(val.subject); 183 - if (poll) { 184 - poll.votes.set(record.uri, val.option); 185 - } 186 - } 187 - } 188 - } 189 - } catch (e) { 190 - console.error("failed to load user votes:", e); 191 - } 192 - }; 193 - 194 166 // create poll 195 167 export const createPoll = async (text: string, options: string[]): Promise<string | null> => { 196 168 if (!agent || !currentDid) return null; ··· 214 186 text, 215 187 options, 216 188 createdAt: new Date().toISOString(), 217 - votes: new Map(), 218 189 }); 219 190 220 191 return res.data.uri;
+9 -74
src/main.ts
··· 1 1 import { 2 2 POLL, 3 - VOTE, 4 3 agent, 5 4 currentDid, 6 5 setAgent, ··· 13 12 fetchPolls, 14 13 fetchPoll, 15 14 fetchVoters, 16 - loadUserVotes, 17 15 createPoll, 18 16 vote, 19 17 resolveHandle, ··· 31 29 // track if a vote is in progress to prevent double-clicks 32 30 let votingInProgress = false; 33 31 34 - // jetstream - replay last 24h on connect, then live updates 35 - let jetstream: WebSocket | null = null; 36 - 37 - const connectJetstream = () => { 38 - if (jetstream?.readyState === WebSocket.OPEN) return; 39 - 40 - const cursor = (Date.now() - 24 * 60 * 60 * 1000) * 1000; 41 - const url = `wss://jetstream1.us-east.bsky.network/subscribe?wantedCollections=${POLL}&wantedCollections=${VOTE}&cursor=${cursor}`; 42 - jetstream = new WebSocket(url); 43 - 44 - jetstream.onmessage = (event) => { 45 - const msg = JSON.parse(event.data); 46 - if (msg.kind !== "commit") return; 47 - 48 - const { commit } = msg; 49 - const uri = `at://${msg.did}/${commit.collection}/${commit.rkey}`; 50 - 51 - if (commit.collection === POLL) { 52 - if (commit.operation === "create" && commit.record) { 53 - polls.set(uri, { 54 - uri, 55 - repo: msg.did, 56 - rkey: commit.rkey, 57 - text: commit.record.text, 58 - options: commit.record.options, 59 - createdAt: commit.record.createdAt, 60 - votes: new Map(), 61 - }); 62 - render(); 63 - } else if (commit.operation === "delete") { 64 - polls.delete(uri); 65 - render(); 66 - } 67 - } 68 - 69 - if (commit.collection === VOTE) { 70 - if (commit.operation === "create" && commit.record) { 71 - const poll = polls.get(commit.record.subject); 72 - if (poll && !poll.votes.has(uri)) { 73 - poll.votes.set(uri, commit.record.option); 74 - render(); 75 - } 76 - } else if (commit.operation === "delete") { 77 - for (const poll of polls.values()) { 78 - if (poll.votes.has(uri)) { 79 - poll.votes.delete(uri); 80 - render(); 81 - break; 82 - } 83 - } 84 - } 85 - } 86 - }; 87 - 88 - jetstream.onclose = () => setTimeout(connectJetstream, 3000); 89 - }; 90 - 91 32 // render 92 33 const render = () => { 93 34 renderNav(); ··· 136 77 137 78 try { 138 79 await fetchPolls(); 139 - await loadUserVotes(); 140 80 141 81 let filteredPolls = Array.from(polls.values()); 142 82 if (mineOnly && currentDid) { ··· 184 124 // voters tooltip 185 125 type VoteInfo = { voter: string; option: number; uri: string; createdAt?: string; handle?: string }; 186 126 const votersCache = new Map<string, VoteInfo[]>(); 127 + const pollOptionsCache = new Map<string, string[]>(); // for tooltip option names 187 128 let activeTooltip: HTMLElement | null = null; 188 129 let tooltipTimeout: ReturnType<typeof setTimeout> | null = null; 189 130 ··· 217 158 })); 218 159 219 160 const poll = polls.get(pollUri); 220 - const options = poll?.options || []; 161 + const options = poll?.options || pollOptionsCache.get(pollUri) || []; 221 162 222 163 if (activeTooltip) activeTooltip.remove(); 223 164 ··· 275 216 }; 276 217 277 218 const handleVote = async (pollUri: string, option: number) => { 278 - console.log("[handleVote] called", { pollUri, option, votingInProgress, agent: !!agent, currentDid }); 279 - 280 219 if (!agent || !currentDid) { 281 220 setStatus("login to vote"); 282 - console.log("[handleVote] not logged in, returning"); 283 221 return; 284 222 } 285 223 286 224 if (votingInProgress) { 287 - console.log("[handleVote] vote already in progress, returning"); 288 225 return; 289 226 } 290 227 291 228 votingInProgress = true; 292 229 setStatus("voting..."); 293 - console.log("[handleVote] set votingInProgress=true, calling vote()"); 294 230 295 231 // disable all vote options visually 296 232 app.querySelectorAll("[data-vote]").forEach((el) => { ··· 299 235 300 236 try { 301 237 await vote(pollUri, option); 302 - console.log("[handleVote] vote() completed successfully"); 303 238 setStatus("confirming..."); 304 239 305 240 // poll backend until vote is confirmed (tap needs time to process) ··· 310 245 while (Date.now() - start < maxWait) { 311 246 const voters = await fetchVoters(pollUri); 312 247 const myVote = voters.find(v => v.voter === currentDid); 313 - console.log("[handleVote] polling backend", { myVote, elapsed: Date.now() - start }); 314 248 if (myVote && myVote.option === option) { 315 - console.log("[handleVote] vote confirmed in backend"); 316 249 break; 317 250 } 318 251 await new Promise(r => setTimeout(r, pollInterval)); 319 252 } 320 253 254 + // clear voters cache so tooltip shows fresh data 255 + votersCache.delete(pollUri); 256 + 321 257 setStatus(""); 322 - console.log("[handleVote] calling render()"); 323 258 render(); 324 259 } catch (e) { 325 - console.error("[handleVote] error:", e); 260 + console.error("vote error:", e); 326 261 setStatus(`error: ${e}`); 327 262 setTimeout(() => { 328 263 setStatus(""); ··· 330 265 }, 2000); 331 266 } finally { 332 267 votingInProgress = false; 333 - console.log("[handleVote] finally, votingInProgress=false"); 334 268 } 335 269 }; 336 270 ··· 373 307 const data = await fetchPoll(uri); 374 308 375 309 if (data) { 310 + // cache options for tooltip 311 + pollOptionsCache.set(uri, data.options.map(o => o.text)); 376 312 const total = data.options.reduce((sum, o) => sum + o.count, 0); 377 313 const disabled = votingInProgress ? " disabled" : ""; 378 314 ··· 411 347 return; 412 348 } 413 349 414 - const poll: Poll = { ...pdsData, votes: new Map() }; 350 + const poll: Poll = { ...pdsData }; 415 351 polls.set(uri, poll); 416 352 417 353 app.innerHTML = `<p><a href="/">&larr; back</a></p>${renderPollCard(poll)}`; ··· 494 430 (async () => { 495 431 await handleOAuthCallback(); 496 432 await restoreSession(); 497 - connectJetstream(); 498 433 render(); 499 434 })();