+1
.gitignore
+1
.gitignore
+1
-1
index.html
+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
-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
+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="/">← back</a></p>${renderPollCard(poll)}`;
···
494
430
(async () => {
495
431
await handleOAuthCallback();
496
432
await restoreSession();
497
-
connectJetstream();
498
433
render();
499
434
})();