// The /poll mini-app: quick polls in the current chat. A command emits a // poll.create event, votes are poll.vote events, and a pure reducer folds them // into per-poll tallies keyed by voter uid — so votes are one-per-user and // changeable, and re-folding the same events never double-counts. Every event // is a normal encrypted message: the server sees only ciphertext, never a // question, an option, or who voted for what. // // Relative import (not "/static/apps.js") so the reducer is exercisable in Node // tests while still resolving to /static/apps.js in the browser. import { defineApp, namespace, addItem, keyedList } from "../apps.js"; import { ICON_CHART_COLUMN } from "../icons.js"; // One declaration of the app's prefix, reused everywhere: N.ev("vote") → the // "poll.vote" wire type (used as the `events` keys and in every emit/compare), // N.cls("bar") → the "poll-bar" CSS class. Change "poll" here and every string // below follows — the prefix can't drift. (Inside render/commands the same helper // is on ctx as ctx.ev / ctx.cls.) const N = namespace("poll"); // pollSummary turns a finished poll into the plain-text results message that // gets posted back into the chat — options with their counts, then the verdict // (winner, tie, or no votes). Pure and exported for tests. export function pollSummary(poll) { const counts = poll.options.map(() => 0); for (const choice of Object.values(poll.votes)) counts[choice]++; const total = Object.keys(poll.votes).length; const lines = [`📊 Poll closed: ${poll.q}`]; poll.options.forEach((label, i) => lines.push(`• ${label} — ${counts[i]}`)); if (total === 0) { lines.push("No votes were cast."); } else { const top = Math.max(...counts); const winners = poll.options.filter((_, i) => counts[i] === top); const votes = total === 1 ? "1 vote" : `${total} votes`; lines.push(winners.length === 1 ? `${winners[0]} wins (${votes})` : `Tied: ${winners.join(" & ")} (${votes})`); } return lines.join("\n"); } // State is { polls: [{ id, q, options, votes, by }] }, where votes maps a // voter's uid (injected by the transport as e.uid) to their chosen option index // and `by` is the creator's uid. This is a "custom fold" example: poll.create // uses addItem (dedupe by id, snapshot-safe), but poll.vote can't use the list // helpers — it reaches into one poll's votes map, so it maps to a new poll with // a new votes object (never mutating the old). A vote for an unknown poll or an // out-of-range choice folds to a no-op; a user's later vote replaces their // earlier one (last-writer-wins per uid). poll.close removes the poll from the // panel — creator-only, enforced in the fold so a stray close from someone else // is a no-op on every honest client (the posted summary is the durable record). export const app = defineApp({ name: "poll", title: "Poll", blurb: "Start a poll", // sidebar subtitle before first use (sync w/ native) glyph: "📊", symbol: "chart.bar", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md) logo: ICON_CHART_COLUMN, // A poll needs an audience — it lives in a chat (the places model; the old // always-on panel above every feed retired with #405). Using /poll in a // group ADOPTS the app there (runCommand's chat-place auto-add), so the // widget bar appears for everyone the moment the first poll exists. places: ["chat"], placeNote: "A poll needs an audience — it lives in a group chat.", initialState: () => ({ polls: [] }), events: { [N.ev("create")]: (s, e) => { const options = Array.isArray(e.options) ? e.options.filter((o) => typeof o === "string" && o.trim()).map((o) => o.trim()) : []; if (!e.id || typeof e.q !== "string" || !e.q.trim() || options.length < 2) return s; return { polls: addItem(s.polls, { id: e.id, q: e.q.trim(), options, votes: {}, by: e.uid || "" }) }; }, [N.ev("vote")]: (s, e) => { const poll = s.polls.find((p) => p.id === e.pollId); const choice = e.choice; if (!poll || !e.uid || !Number.isInteger(choice) || choice < 0 || choice >= poll.options.length) return s; return { polls: s.polls.map((p) => (p.id === e.pollId ? { ...p, votes: { ...p.votes, [e.uid]: choice } } : p)) }; }, [N.ev("close")]: (s, e) => ({ // Only the creator may close (a legacy poll with no `by` accepts anyone). polls: s.polls.filter((p) => !(p.id === e.pollId && (!p.by || p.by === e.uid))), }), }, commands: { // /poll Question | Option A | Option B — no "|" means a Yes/No poll. "/poll": (args, ctx) => { const parts = args.split("|").map((s) => s.trim()); const q = parts.shift() || ""; const hadSeparators = args.includes("|"); const options = hadSeparators ? parts.filter(Boolean) : ["Yes", "No"]; if (!q || options.length < 2) return; ctx.emit({ type: N.ev("create"), id: ctx.id(), q, options }); }, }, // Sidebar presence: a new poll is worth surfacing — preview the question and // light the unread dot (votes stay silent; the tally updates live anyway). preview: (e) => (e.type === N.ev("create") && e.q ? `📊 ${e.q}` : null), unread: (e) => e.type === N.ev("create"), // This app's CSS travels with it — the framework injects it once. It reads the // theme's CSS variables (var(--accent) etc.) so it follows the chosen theme. styles: ` .poll-card { display: flex; flex-direction: column; gap: 6px; } .poll-card + .poll-card { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); } .poll-q { font-size: 13px; font-weight: 600; color: var(--ink); overflow-wrap: anywhere; } .poll-opts { display: flex; flex-direction: column; gap: 4px; } .poll-opt { position: relative; overflow: hidden; display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 8px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: var(--ink); font: inherit; font-size: 13px; text-align: left; cursor: var(--ui-cursor, pointer); } .poll-opt.mine { border-color: var(--accent); } .poll-fill { position: absolute; inset: 0 auto 0 0; background: var(--accent); opacity: 0.15; transition: width 0.2s; pointer-events: none; } .poll-opt.mine .poll-fill { opacity: 0.3; } .poll-label { position: relative; flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; } .poll-opt.mine .poll-label { font-weight: 600; } .poll-tally { position: relative; flex: 0 0 auto; font-size: 11px; color: var(--muted); white-space: nowrap; } .poll-foot { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } .poll-total { font-size: 11px; color: var(--muted); } .poll-close { border: none; background: transparent; padding: 0; font: inherit; font-size: 11px; color: var(--link); cursor: var(--ui-cursor, pointer); text-decoration: underline; text-underline-offset: 2px; } `, // Panel surface. Incremental: the framework does NOT wipe #app-surface between // renders (see apps.js), so each poll card is built ONCE and reconciled in // place. This is load-bearing for the two-step "Close poll" confirm: its armed // state lives only on the button (close.dataset.armed), and every vote or chat // message folds in and re-renders the panel. A wipe-and-rebuild would discard // the armed button — so on an actively-voted poll the creator could rarely // close it, exactly when they want to. board.js keeps its own DOM the same way. incremental: true, render(state, el, ctx) { // In the widget pane an empty poll list deserves a hint, not a blank. if (!state || !Array.isArray(state.polls) || !state.polls.length) { let hint = el.querySelector(".poll-empty"); if (!hint) { el.textContent = ""; hint = document.createElement("p"); hint.className = "poll-empty"; hint.style.cssText = "color:var(--muted);font-size:13px;padding:8px 2px;"; hint.textContent = "No polls yet — /poll question | option | option starts one."; el.appendChild(hint); } return; } const stale = el.querySelector(".poll-empty"); if (stale) stale.remove(); // Newest poll first (unchanged order). keyedList adds new cards, removes // closed ones, and reorders by id — but with no `rev` it NEVER rebuilds a // surviving card, so its armed Close button and focus ride through. An empty // list removes every card, collapsing the panel like the old early-return. const polls = (state && state.polls) || []; const ordered = [...polls].reverse(); keyedList(el, ordered, { key: (p) => p.id, render: (p) => buildCard(p, ctx), }); // keyedList settled which cards exist; now refresh each surviving card's live // figures + its stashed poll/ctx in place (votes moved the numbers but must // not rebuild the card). Match cards by the data-k keyedList stamped on them. const byKey = new Map(); for (const card of el.children) byKey.set(card.dataset.k, card); for (const p of ordered) { const card = byKey.get(String(p.id)); if (card) updateCard(card, p, ctx); } }, }); // buildCard constructs one poll card's DOM ONCE: the question, an option button // per choice, and the footer (vote count + the creator's Close link). The mutable // parts (fill bars, tallies, the "mine" highlight, the total) are refreshed by // updateCard on every fold — the card node itself is kept, so the two-step Close // confirm's armed state survives the votes/messages that land between the two // clicks. Handlers read card._ctx / card._poll (both refreshed each render) rather // than a build-time ctx/poll, so a vote or close always targets the active thread // and the posted summary counts every vote right up to the close — the same reason // board.js's handlers read wrap._ctx (#app-surface is reused across threads). function buildCard(poll, ctx) { const card = document.createElement("div"); card.className = N.cls("card"); card._poll = poll; card._ctx = ctx; const q = document.createElement("div"); q.className = N.cls("q"); q.textContent = `📊 ${poll.q}`; // textContent — never innerHTML for user content card.appendChild(q); const opts = document.createElement("div"); opts.className = N.cls("opts"); // One button per option, built once; updateCard fills in fill/tally/mine. card._optNodes = poll.options.map((label, i) => { const btn = document.createElement("button"); btn.type = "button"; btn.className = N.cls("opt"); btn.onclick = () => card._ctx.emit({ type: N.ev("vote"), pollId: poll.id, choice: i }); const fill = document.createElement("span"); fill.className = N.cls("fill"); const text = document.createElement("span"); text.className = N.cls("label"); text.textContent = label; // static — an option's label never changes const tally = document.createElement("span"); tally.className = N.cls("tally"); btn.append(fill, text, tally); opts.appendChild(btn); return { btn, fill, tally }; }); card.appendChild(opts); // Footer: the vote count, and — for the poll's creator — a small Close link. // Closing removes the poll from everyone's panel (poll.close) and posts the // results back into the chat as a NORMAL message, so classic and lab feeds // alike keep a durable record of the outcome. const foot = document.createElement("div"); foot.className = N.cls("foot"); card._totalLine = document.createElement("div"); card._totalLine.className = N.cls("total"); foot.appendChild(card._totalLine); if (!poll.by || poll.by === ctx.me) { const close = document.createElement("button"); close.type = "button"; close.className = N.cls("close"); close.textContent = "Close poll"; // Two-step inline confirm instead of confirm(): embedded webviews (the // Mac shell) have no confirm panel — the dialog silently answers false // and the click looks dead. The link itself asks: first click arms it, // a second click within a few seconds posts the results. The armed state // now survives intervening votes/messages because this button is never // rebuilt (see the incremental note above); the summary reads card._poll so // it counts every vote right up to the moment of closing. close.onclick = () => { if (!close.dataset.armed) { close.dataset.armed = "1"; close.textContent = "Post results & close?"; setTimeout(() => { delete close.dataset.armed; if (close.isConnected) close.textContent = "Close poll"; }, 4000); return; } card._ctx.emit({ type: N.ev("close"), pollId: card._poll.id }); card._ctx.emit({ text: pollSummary(card._poll) }); // a plain chat message — the durable record }; foot.appendChild(close); } card.appendChild(foot); return card; } // updateCard refreshes one card's live figures in place — per-option fill bar, // tally and "mine" highlight, plus the total line — and re-stashes the fresh // poll/ctx the click handlers read. It never touches the Close button, so an // armed two-step confirm rides through every vote and message. function updateCard(card, poll, ctx) { card._poll = poll; card._ctx = ctx; const counts = poll.options.map(() => 0); for (const choice of Object.values(poll.votes)) counts[choice]++; const total = Object.keys(poll.votes).length; const mine = poll.votes[ctx.me]; card._optNodes.forEach(({ btn, fill, tally }, i) => { const pct = total ? Math.round((counts[i] / total) * 100) : 0; btn.classList.toggle("mine", mine === i); fill.style.width = `${pct}%`; tally.textContent = `${counts[i]} · ${pct}%`; }); card._totalLine.textContent = total === 1 ? "1 vote" : `${total} votes`; }