+1
.gitignore
+1
.gitignore
+1
-1
index.html
+1
-1
index.html
-29
src/lib/api.ts
-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
+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="/">← 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="/">← back</a></p>${renderPollCard(poll)}`;
···
430
(async () => {
431
await handleOAuthCallback();
432
await restoreSession();
433
render();
434
})();