| 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. |
| 10 | import { defineApp, namespace, addItem, keyedList } from "../apps.js"; |
| 11 | import { 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.) |
| 18 | const 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. |
| 23 | export 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). |
| 50 | export 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). |
| 184 | function 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. |
| 264 | function 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 | |