polls on atproto pollz.waow.tech
atproto zig

link to src

Changed files
+11 -104
src
+1
.gitignore
··· 1 node_modules 2 dist 3 .vite 4 .zig-cache 5 zig-out 6 *.db
··· 1 node_modules 2 dist 3 .vite 4 + .wrangler 5 .zig-cache 6 zig-out 7 *.db
+1 -1
index.html
··· 135 </head> 136 <body> 137 <header> 138 - <h1><a href="/">pollz</a></h1> 139 <nav id="nav"></nav> 140 </header> 141 <main id="app"></main>
··· 135 </head> 136 <body> 137 <header> 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 <nav id="nav"></nav> 140 </header> 141 <main id="app"></main>
-29
src/lib/api.ts
··· 63 text: string; 64 options: string[]; 65 createdAt: string; 66 - votes: Map<string, number>; 67 voteCount?: number; 68 }; 69 ··· 139 text: p.text, 140 options: p.options, 141 createdAt: p.createdAt, 142 - votes: new Map(), 143 voteCount: p.voteCount, 144 }); 145 } ··· 165 return res.json() as Promise<Array<{ voter: string; option: number; uri: string; createdAt?: string }>>; 166 }; 167 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 // create poll 195 export const createPoll = async (text: string, options: string[]): Promise<string | null> => { 196 if (!agent || !currentDid) return null; ··· 214 text, 215 options, 216 createdAt: new Date().toISOString(), 217 - votes: new Map(), 218 }); 219 220 return res.data.uri;
··· 63 text: string; 64 options: string[]; 65 createdAt: string; 66 voteCount?: number; 67 }; 68 ··· 138 text: p.text, 139 options: p.options, 140 createdAt: p.createdAt, 141 voteCount: p.voteCount, 142 }); 143 } ··· 163 return res.json() as Promise<Array<{ voter: string; option: number; uri: string; createdAt?: string }>>; 164 }; 165 166 // create poll 167 export const createPoll = async (text: string, options: string[]): Promise<string | null> => { 168 if (!agent || !currentDid) return null; ··· 186 text, 187 options, 188 createdAt: new Date().toISOString(), 189 }); 190 191 return res.data.uri;
+9 -74
src/main.ts
··· 1 import { 2 POLL, 3 - VOTE, 4 agent, 5 currentDid, 6 setAgent, ··· 13 fetchPolls, 14 fetchPoll, 15 fetchVoters, 16 - loadUserVotes, 17 createPoll, 18 vote, 19 resolveHandle, ··· 31 // track if a vote is in progress to prevent double-clicks 32 let votingInProgress = false; 33 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 // render 92 const render = () => { 93 renderNav(); ··· 136 137 try { 138 await fetchPolls(); 139 - await loadUserVotes(); 140 141 let filteredPolls = Array.from(polls.values()); 142 if (mineOnly && currentDid) { ··· 184 // voters tooltip 185 type VoteInfo = { voter: string; option: number; uri: string; createdAt?: string; handle?: string }; 186 const votersCache = new Map<string, VoteInfo[]>(); 187 let activeTooltip: HTMLElement | null = null; 188 let tooltipTimeout: ReturnType<typeof setTimeout> | null = null; 189 ··· 217 })); 218 219 const poll = polls.get(pollUri); 220 - const options = poll?.options || []; 221 222 if (activeTooltip) activeTooltip.remove(); 223 ··· 275 }; 276 277 const handleVote = async (pollUri: string, option: number) => { 278 - console.log("[handleVote] called", { pollUri, option, votingInProgress, agent: !!agent, currentDid }); 279 - 280 if (!agent || !currentDid) { 281 setStatus("login to vote"); 282 - console.log("[handleVote] not logged in, returning"); 283 return; 284 } 285 286 if (votingInProgress) { 287 - console.log("[handleVote] vote already in progress, returning"); 288 return; 289 } 290 291 votingInProgress = true; 292 setStatus("voting..."); 293 - console.log("[handleVote] set votingInProgress=true, calling vote()"); 294 295 // disable all vote options visually 296 app.querySelectorAll("[data-vote]").forEach((el) => { ··· 299 300 try { 301 await vote(pollUri, option); 302 - console.log("[handleVote] vote() completed successfully"); 303 setStatus("confirming..."); 304 305 // poll backend until vote is confirmed (tap needs time to process) ··· 310 while (Date.now() - start < maxWait) { 311 const voters = await fetchVoters(pollUri); 312 const myVote = voters.find(v => v.voter === currentDid); 313 - console.log("[handleVote] polling backend", { myVote, elapsed: Date.now() - start }); 314 if (myVote && myVote.option === option) { 315 - console.log("[handleVote] vote confirmed in backend"); 316 break; 317 } 318 await new Promise(r => setTimeout(r, pollInterval)); 319 } 320 321 setStatus(""); 322 - console.log("[handleVote] calling render()"); 323 render(); 324 } catch (e) { 325 - console.error("[handleVote] error:", e); 326 setStatus(`error: ${e}`); 327 setTimeout(() => { 328 setStatus(""); ··· 330 }, 2000); 331 } finally { 332 votingInProgress = false; 333 - console.log("[handleVote] finally, votingInProgress=false"); 334 } 335 }; 336 ··· 373 const data = await fetchPoll(uri); 374 375 if (data) { 376 const total = data.options.reduce((sum, o) => sum + o.count, 0); 377 const disabled = votingInProgress ? " disabled" : ""; 378 ··· 411 return; 412 } 413 414 - const poll: Poll = { ...pdsData, votes: new Map() }; 415 polls.set(uri, poll); 416 417 app.innerHTML = `<p><a href="/">&larr; back</a></p>${renderPollCard(poll)}`; ··· 494 (async () => { 495 await handleOAuthCallback(); 496 await restoreSession(); 497 - connectJetstream(); 498 render(); 499 })();
··· 1 import { 2 POLL, 3 agent, 4 currentDid, 5 setAgent, ··· 12 fetchPolls, 13 fetchPoll, 14 fetchVoters, 15 createPoll, 16 vote, 17 resolveHandle, ··· 29 // track if a vote is in progress to prevent double-clicks 30 let votingInProgress = false; 31 32 // render 33 const render = () => { 34 renderNav(); ··· 77 78 try { 79 await fetchPolls(); 80 81 let filteredPolls = Array.from(polls.values()); 82 if (mineOnly && currentDid) { ··· 124 // voters tooltip 125 type VoteInfo = { voter: string; option: number; uri: string; createdAt?: string; handle?: string }; 126 const votersCache = new Map<string, VoteInfo[]>(); 127 + const pollOptionsCache = new Map<string, string[]>(); // for tooltip option names 128 let activeTooltip: HTMLElement | null = null; 129 let tooltipTimeout: ReturnType<typeof setTimeout> | null = null; 130 ··· 158 })); 159 160 const poll = polls.get(pollUri); 161 + const options = poll?.options || pollOptionsCache.get(pollUri) || []; 162 163 if (activeTooltip) activeTooltip.remove(); 164 ··· 216 }; 217 218 const handleVote = async (pollUri: string, option: number) => { 219 if (!agent || !currentDid) { 220 setStatus("login to vote"); 221 return; 222 } 223 224 if (votingInProgress) { 225 return; 226 } 227 228 votingInProgress = true; 229 setStatus("voting..."); 230 231 // disable all vote options visually 232 app.querySelectorAll("[data-vote]").forEach((el) => { ··· 235 236 try { 237 await vote(pollUri, option); 238 setStatus("confirming..."); 239 240 // poll backend until vote is confirmed (tap needs time to process) ··· 245 while (Date.now() - start < maxWait) { 246 const voters = await fetchVoters(pollUri); 247 const myVote = voters.find(v => v.voter === currentDid); 248 if (myVote && myVote.option === option) { 249 break; 250 } 251 await new Promise(r => setTimeout(r, pollInterval)); 252 } 253 254 + // clear voters cache so tooltip shows fresh data 255 + votersCache.delete(pollUri); 256 + 257 setStatus(""); 258 render(); 259 } catch (e) { 260 + console.error("vote error:", e); 261 setStatus(`error: ${e}`); 262 setTimeout(() => { 263 setStatus(""); ··· 265 }, 2000); 266 } finally { 267 votingInProgress = false; 268 } 269 }; 270 ··· 307 const data = await fetchPoll(uri); 308 309 if (data) { 310 + // cache options for tooltip 311 + pollOptionsCache.set(uri, data.options.map(o => o.text)); 312 const total = data.options.reduce((sum, o) => sum + o.count, 0); 313 const disabled = votingInProgress ? " disabled" : ""; 314 ··· 347 return; 348 } 349 350 + const poll: Poll = { ...pdsData }; 351 polls.set(uri, poll); 352 353 app.innerHTML = `<p><a href="/">&larr; back</a></p>${renderPollCard(poll)}`; ··· 430 (async () => { 431 await handleOAuthCallback(); 432 await restoreSession(); 433 render(); 434 })();