eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// The /poll mini-app: quick polls in the current chat. A command emits a
2// poll.create event, votes are poll.vote events, and a pure reducer folds them
3// into per-poll tallies keyed by voter uid — so votes are one-per-user and
4// changeable, and re-folding the same events never double-counts. Every event
5// is a normal encrypted message: the server sees only ciphertext, never a
6// question, an option, or who voted for what.
7//
8// Relative import (not "/static/apps.js") so the reducer is exercisable in Node
9// tests while still resolving to /static/apps.js in the browser.
10import { defineApp, namespace, addItem, keyedList } from "../apps.js";
11import { ICON_CHART_COLUMN } from "../icons.js";
12
13// One declaration of the app's prefix, reused everywhere: N.ev("vote") → the
14// "poll.vote" wire type (used as the `events` keys and in every emit/compare),
15// N.cls("bar") → the "poll-bar" CSS class. Change "poll" here and every string
16// below follows — the prefix can't drift. (Inside render/commands the same helper
17// is on ctx as ctx.ev / ctx.cls.)
18const N = namespace("poll");
19
20// pollSummary turns a finished poll into the plain-text results message that
21// gets posted back into the chat — options with their counts, then the verdict
22// (winner, tie, or no votes). Pure and exported for tests.
23export function pollSummary(poll) {
24 const counts = poll.options.map(() => 0);
25 for (const choice of Object.values(poll.votes)) counts[choice]++;
26 const total = Object.keys(poll.votes).length;
27 const lines = [`📊 Poll closed: ${poll.q}`];
28 poll.options.forEach((label, i) => lines.push(`• ${label} — ${counts[i]}`));
29 if (total === 0) {
30 lines.push("No votes were cast.");
31 } else {
32 const top = Math.max(...counts);
33 const winners = poll.options.filter((_, i) => counts[i] === top);
34 const votes = total === 1 ? "1 vote" : `${total} votes`;
35 lines.push(winners.length === 1 ? `${winners[0]} wins (${votes})` : `Tied: ${winners.join(" & ")} (${votes})`);
36 }
37 return lines.join("\n");
38}
39
40// State is { polls: [{ id, q, options, votes, by }] }, where votes maps a
41// voter's uid (injected by the transport as e.uid) to their chosen option index
42// and `by` is the creator's uid. This is a "custom fold" example: poll.create
43// uses addItem (dedupe by id, snapshot-safe), but poll.vote can't use the list
44// helpers — it reaches into one poll's votes map, so it maps to a new poll with
45// a new votes object (never mutating the old). A vote for an unknown poll or an
46// out-of-range choice folds to a no-op; a user's later vote replaces their
47// earlier one (last-writer-wins per uid). poll.close removes the poll from the
48// panel — creator-only, enforced in the fold so a stray close from someone else
49// is a no-op on every honest client (the posted summary is the durable record).
50export const app = defineApp({
51 name: "poll",
52 title: "Poll",
53 blurb: "Start a poll", // sidebar subtitle before first use (sync w/ native)
54 glyph: "📊",
55 symbol: "chart.bar", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md)
56 logo: ICON_CHART_COLUMN,
57 // A poll needs an audience — it lives in a chat (the places model; the old
58 // always-on panel above every feed retired with #405). Using /poll in a
59 // group ADOPTS the app there (runCommand's chat-place auto-add), so the
60 // widget bar appears for everyone the moment the first poll exists.
61 places: ["chat"],
62 placeNote: "A poll needs an audience — it lives in a group chat.",
63 initialState: () => ({ polls: [] }),
64 events: {
65 [N.ev("create")]: (s, e) => {
66 const options = Array.isArray(e.options) ? e.options.filter((o) => typeof o === "string" && o.trim()).map((o) => o.trim()) : [];
67 if (!e.id || typeof e.q !== "string" || !e.q.trim() || options.length < 2) return s;
68 return { polls: addItem(s.polls, { id: e.id, q: e.q.trim(), options, votes: {}, by: e.uid || "" }) };
69 },
70 [N.ev("vote")]: (s, e) => {
71 const poll = s.polls.find((p) => p.id === e.pollId);
72 const choice = e.choice;
73 if (!poll || !e.uid || !Number.isInteger(choice) || choice < 0 || choice >= poll.options.length) return s;
74 return { polls: s.polls.map((p) => (p.id === e.pollId ? { ...p, votes: { ...p.votes, [e.uid]: choice } } : p)) };
75 },
76 [N.ev("close")]: (s, e) => ({
77 // Only the creator may close (a legacy poll with no `by` accepts anyone).
78 polls: s.polls.filter((p) => !(p.id === e.pollId && (!p.by || p.by === e.uid))),
79 }),
80 },
81 commands: {
82 // /poll Question | Option A | Option B — no "|" means a Yes/No poll.
83 "/poll": (args, ctx) => {
84 const parts = args.split("|").map((s) => s.trim());
85 const q = parts.shift() || "";
86 const hadSeparators = args.includes("|");
87 const options = hadSeparators ? parts.filter(Boolean) : ["Yes", "No"];
88 if (!q || options.length < 2) return;
89 ctx.emit({ type: N.ev("create"), id: ctx.id(), q, options });
90 },
91 },
92
93 // Sidebar presence: a new poll is worth surfacing — preview the question and
94 // light the unread dot (votes stay silent; the tally updates live anyway).
95 preview: (e) => (e.type === N.ev("create") && e.q ? `📊 ${e.q}` : null),
96 unread: (e) => e.type === N.ev("create"),
97
98 // This app's CSS travels with it — the framework injects it once. It reads the
99 // theme's CSS variables (var(--accent) etc.) so it follows the chosen theme.
100 styles: `
101.poll-card { display: flex; flex-direction: column; gap: 6px; }
102.poll-card + .poll-card { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
103.poll-q { font-size: 13px; font-weight: 600; color: var(--ink); overflow-wrap: anywhere; }
104.poll-opts { display: flex; flex-direction: column; gap: 4px; }
105.poll-opt {
106 position: relative; overflow: hidden; display: flex; align-items: center; gap: 8px;
107 width: 100%; padding: 5px 8px; border: 1px solid var(--border); border-radius: 8px;
108 background: transparent; color: var(--ink); font: inherit; font-size: 13px;
109 text-align: left; cursor: var(--ui-cursor, pointer);
110}
111.poll-opt.mine { border-color: var(--accent); }
112.poll-fill {
113 position: absolute; inset: 0 auto 0 0; background: var(--accent);
114 opacity: 0.15; transition: width 0.2s; pointer-events: none;
115}
116.poll-opt.mine .poll-fill { opacity: 0.3; }
117.poll-label { position: relative; flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
118.poll-opt.mine .poll-label { font-weight: 600; }
119.poll-tally { position: relative; flex: 0 0 auto; font-size: 11px; color: var(--muted); white-space: nowrap; }
120.poll-foot { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
121.poll-total { font-size: 11px; color: var(--muted); }
122.poll-close {
123 border: none; background: transparent; padding: 0; font: inherit; font-size: 11px;
124 color: var(--link); cursor: var(--ui-cursor, pointer); text-decoration: underline;
125 text-underline-offset: 2px;
126}
127`,
128
129 // Panel surface. Incremental: the framework does NOT wipe #app-surface between
130 // renders (see apps.js), so each poll card is built ONCE and reconciled in
131 // place. This is load-bearing for the two-step "Close poll" confirm: its armed
132 // state lives only on the button (close.dataset.armed), and every vote or chat
133 // message folds in and re-renders the panel. A wipe-and-rebuild would discard
134 // the armed button — so on an actively-voted poll the creator could rarely
135 // close it, exactly when they want to. board.js keeps its own DOM the same way.
136 incremental: true,
137 render(state, el, ctx) {
138 // In the widget pane an empty poll list deserves a hint, not a blank.
139 if (!state || !Array.isArray(state.polls) || !state.polls.length) {
140 let hint = el.querySelector(".poll-empty");
141 if (!hint) {
142 el.textContent = "";
143 hint = document.createElement("p");
144 hint.className = "poll-empty";
145 hint.style.cssText = "color:var(--muted);font-size:13px;padding:8px 2px;";
146 hint.textContent = "No polls yet — /poll question | option | option starts one.";
147 el.appendChild(hint);
148 }
149 return;
150 }
151 const stale = el.querySelector(".poll-empty");
152 if (stale) stale.remove();
153 // Newest poll first (unchanged order). keyedList adds new cards, removes
154 // closed ones, and reorders by id — but with no `rev` it NEVER rebuilds a
155 // surviving card, so its armed Close button and focus ride through. An empty
156 // list removes every card, collapsing the panel like the old early-return.
157 const polls = (state && state.polls) || [];
158 const ordered = [...polls].reverse();
159 keyedList(el, ordered, {
160 key: (p) => p.id,
161 render: (p) => buildCard(p, ctx),
162 });
163 // keyedList settled which cards exist; now refresh each surviving card's live
164 // figures + its stashed poll/ctx in place (votes moved the numbers but must
165 // not rebuild the card). Match cards by the data-k keyedList stamped on them.
166 const byKey = new Map();
167 for (const card of el.children) byKey.set(card.dataset.k, card);
168 for (const p of ordered) {
169 const card = byKey.get(String(p.id));
170 if (card) updateCard(card, p, ctx);
171 }
172 },
173});
174
175// buildCard constructs one poll card's DOM ONCE: the question, an option button
176// per choice, and the footer (vote count + the creator's Close link). The mutable
177// parts (fill bars, tallies, the "mine" highlight, the total) are refreshed by
178// updateCard on every fold — the card node itself is kept, so the two-step Close
179// confirm's armed state survives the votes/messages that land between the two
180// clicks. Handlers read card._ctx / card._poll (both refreshed each render) rather
181// than a build-time ctx/poll, so a vote or close always targets the active thread
182// and the posted summary counts every vote right up to the close — the same reason
183// board.js's handlers read wrap._ctx (#app-surface is reused across threads).
184function buildCard(poll, ctx) {
185 const card = document.createElement("div");
186 card.className = N.cls("card");
187 card._poll = poll;
188 card._ctx = ctx;
189
190 const q = document.createElement("div");
191 q.className = N.cls("q");
192 q.textContent = `📊 ${poll.q}`; // textContent — never innerHTML for user content
193 card.appendChild(q);
194
195 const opts = document.createElement("div");
196 opts.className = N.cls("opts");
197 // One button per option, built once; updateCard fills in fill/tally/mine.
198 card._optNodes = poll.options.map((label, i) => {
199 const btn = document.createElement("button");
200 btn.type = "button";
201 btn.className = N.cls("opt");
202 btn.onclick = () => card._ctx.emit({ type: N.ev("vote"), pollId: poll.id, choice: i });
203
204 const fill = document.createElement("span");
205 fill.className = N.cls("fill");
206
207 const text = document.createElement("span");
208 text.className = N.cls("label");
209 text.textContent = label; // static — an option's label never changes
210
211 const tally = document.createElement("span");
212 tally.className = N.cls("tally");
213
214 btn.append(fill, text, tally);
215 opts.appendChild(btn);
216 return { btn, fill, tally };
217 });
218 card.appendChild(opts);
219
220 // Footer: the vote count, and — for the poll's creator — a small Close link.
221 // Closing removes the poll from everyone's panel (poll.close) and posts the
222 // results back into the chat as a NORMAL message, so classic and lab feeds
223 // alike keep a durable record of the outcome.
224 const foot = document.createElement("div");
225 foot.className = N.cls("foot");
226 card._totalLine = document.createElement("div");
227 card._totalLine.className = N.cls("total");
228 foot.appendChild(card._totalLine);
229 if (!poll.by || poll.by === ctx.me) {
230 const close = document.createElement("button");
231 close.type = "button";
232 close.className = N.cls("close");
233 close.textContent = "Close poll";
234 // Two-step inline confirm instead of confirm(): embedded webviews (the
235 // Mac shell) have no confirm panel — the dialog silently answers false
236 // and the click looks dead. The link itself asks: first click arms it,
237 // a second click within a few seconds posts the results. The armed state
238 // now survives intervening votes/messages because this button is never
239 // rebuilt (see the incremental note above); the summary reads card._poll so
240 // it counts every vote right up to the moment of closing.
241 close.onclick = () => {
242 if (!close.dataset.armed) {
243 close.dataset.armed = "1";
244 close.textContent = "Post results & close?";
245 setTimeout(() => {
246 delete close.dataset.armed;
247 if (close.isConnected) close.textContent = "Close poll";
248 }, 4000);
249 return;
250 }
251 card._ctx.emit({ type: N.ev("close"), pollId: card._poll.id });
252 card._ctx.emit({ text: pollSummary(card._poll) }); // a plain chat message — the durable record
253 };
254 foot.appendChild(close);
255 }
256 card.appendChild(foot);
257 return card;
258}
259
260// updateCard refreshes one card's live figures in place — per-option fill bar,
261// tally and "mine" highlight, plus the total line — and re-stashes the fresh
262// poll/ctx the click handlers read. It never touches the Close button, so an
263// armed two-step confirm rides through every vote and message.
264function updateCard(card, poll, ctx) {
265 card._poll = poll;
266 card._ctx = ctx;
267
268 const counts = poll.options.map(() => 0);
269 for (const choice of Object.values(poll.votes)) counts[choice]++;
270 const total = Object.keys(poll.votes).length;
271 const mine = poll.votes[ctx.me];
272
273 card._optNodes.forEach(({ btn, fill, tally }, i) => {
274 const pct = total ? Math.round((counts[i] / total) * 100) : 0;
275 btn.classList.toggle("mine", mine === i);
276 fill.style.width = `${pct}%`;
277 tally.textContent = `${counts[i]} · ${pct}%`;
278 });
279 card._totalLine.textContent = total === 1 ? "1 vote" : `${total} votes`;
280}
281