Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 334801de44cb0fe911d5a88b
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Audience.tsx
// The audience's view at /e/<id>: the polls that are open or on screen, and the Q&A. No account: each
// device is a guest, and the rules allow one vote per guest per poll.
import { useEffect, useMemo, useState } from "react";
import { get, set, setMany, useQuery, increment, serverTimestamp } from "@bounded-sh/client";
import { explain, SERVED_APP_ID } from "./lib/bounded";
import { useAll, useEvent, usePolls } from "./lib/event";
import { toast } from "./ui";
import { Loading } from "./Event";
import { ResultsView } from "./Results";
import {
checkAnswer, checkAsk, readAnswer, parseSummary, byPos, byVotes, formatCode, randomId, LIMITS,
type Answer, type EventDoc, type Poll, type QuestionDoc,
} from "../functions/lib/polls";
export function Audience({ eventId, uid, owner }: { eventId: string; uid: string; owner: boolean }) {
const event = useEvent(eventId);
const polls = usePolls(eventId);
const [tab, setTab] = useState<"polls" | "qa">("polls");
const current = event?.current;
// Follow the presenter: when the screen moves to the Q&A or a poll, so does this tab.
useEffect(() => { if (current === "qa") setTab("qa"); else if (current) setTab("polls"); }, [current]);
useEffect(() => { if (event?.title) document.title = `${event.title} | Polls`; }, [event?.title]);
const visible = useMemo(() => (polls || [])
.filter((p) => p.status !== "draft" && (p.status === "open" || p.id === current || p.showResults))
.sort((a, b) => Number(b.id === current) - Number(a.id === current) || byPos(a, b)), [polls, current]);
if (event === undefined || polls === undefined) return <Loading text="Loading..." />;
if (!event) return null;
const qaTab = event.qa && tab === "qa";
return (
<div className="aud">
{owner && (
<div className="owner-bar">
<span>This is your event.</span>
<span className="row"><a href={`/e/${eventId}/admin`} data-nav>Run it</a><a href={`/e/${eventId}/present`} target="_blank" rel="noopener">Present</a></span>
</div>
)}
<header className="aud-head">
<p className="eyebrow">Code {formatCode(event.code)}</p>
<h1 className="aud-title">{event.title}</h1>
</header>
{event.qa && (
<div className="seg" role="tablist" aria-label="Show">
<button type="button" role="tab" aria-selected={!qaTab} onClick={() => setTab("polls")}>Polls{visible.length ? ` · ${visible.length}` : ""}</button>
<button type="button" role="tab" aria-selected={qaTab} onClick={() => setTab("qa")}>Q&A</button>
</div>
)}
{qaTab ? <AudienceQA eventId={eventId} uid={uid} event={event} /> : visible.length ? (
<div className="aud-polls">
{visible.map((p) => <AudiencePoll key={p.id} eventId={eventId} uid={uid} poll={p} event={event} onScreen={p.id === current} />)}
</div>
) : (
<div className="card aud-wait">
<img className="art" src="/art/card-polls.webp" alt="" width="150" height="150" />
<h2 className="h3">Waiting for the presenter</h2>
<p className="muted">Polls show up here when they open. Keep this page open; it updates by itself.</p>
{event.qa && <button className="btn btn-line" type="button" onClick={() => setTab("qa")}>Ask a question meanwhile</button>}
</div>
)}
<p className="made-with">Made with <a href="/" data-nav>Polls</a>, free from OpenApps. No account, no tracking.</p>
</div>
);
}
const HINT: Record<Poll["type"], (p: Poll) => string> = {
choice: (p) => (p.multi ? "Pick all that apply." : "Pick one."),
wordcloud: (p) => (p.max === 1 ? "Type one word." : `Type up to ${p.max} words.`),
rating: (p) => `Pick from 1 to ${p.max}.`,
open: () => "Type your answer.",
ranking: () => "Tap the options from best to worst.",
};
function formatAnswer(poll: Poll, a: Answer): string {
switch (poll.type) {
case "choice": return (a as number[]).map((i) => poll.options[i]).join(", ");
case "wordcloud": return (a as string[]).join(", ");
case "rating": return `${a} out of ${poll.max}`;
case "open": return String(a);
case "ranking": return (a as number[]).map((i, place) => `${place + 1}. ${poll.options[i]}`).join(" ");
}
}
function AudiencePoll({ eventId, uid, poll, event, onScreen }: { eventId: string; uid: string; poll: Poll; event: EventDoc; onScreen: boolean }) {
const path = `events/${eventId}/polls/${poll.id}/votes/${uid}`;
const { data: vote } = useQuery(path) as { data: { answer: string } | null | undefined };
const mine = vote ? readAnswer(poll, vote.answer) : null;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<unknown>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const summary = poll.showResults ? parseSummary(poll.results) : null;
const open = poll.status === "open";
// A stored answer that no longer fits the poll (its options changed) counts as no answer.
const answering = vote !== undefined && open && (!vote || editing || mine === null);
const submit = async () => {
const checked = checkAnswer(poll, draft);
if ("error" in checked) { setError(checked.error); return; }
setBusy(true);
setError("");
try {
await set(path, { answer: JSON.stringify(checked.value), at: serverTimestamp() });
// Only leave the editor this submit opened: the live vote can arrive before the write returns.
if (editing) setEditing(false);
toast(vote ? "Answer changed" : "Your vote is in");
} catch (err) {
const now = (await get(`events/${eventId}/polls/${poll.id}`).catch(() => null)) as { status?: string } | null;
setError(now?.status !== "open" ? "Voting just closed, so this answer was not counted." : explain(err));
}
setBusy(false);
};
return (
<section className={`card aud-poll${onScreen ? " on-screen" : ""}`} aria-label={poll.question}>
<p className="eyebrow">{onScreen ? "On screen now" : open ? "Open" : "Closed"}{vote && !editing ? " · You answered" : ""}</p>
<h2 className="aud-q">{poll.question}</h2>
{answering ? (
<>
<p className="hint">{HINT[poll.type](poll)}</p>
<AnswerInput poll={poll} value={draft} onChange={(v) => { setDraft(v); setError(""); }} />
{error && <p className="q-error" role="alert">{error}</p>}
<div className="row">
<button className="btn btn-lg vote-btn" type="button" onClick={submit} disabled={busy}>{busy ? "Sending..." : editing ? "Save answer" : "Send"}</button>
{editing && <button className="btn btn-quiet" type="button" onClick={() => { setEditing(false); setError(""); }}>Cancel</button>}
</div>
</>
) : vote && mine !== null ? (
<div className="mine">
<p><span className="muted">Your answer: </span>{formatAnswer(poll, mine)}</p>
{open && event.allowChange && (
<button className="btn btn-sm btn-line" type="button" onClick={() => { setDraft(mine); setEditing(true); }}>Change answer</button>
)}
</div>
) : vote === undefined ? null : !open && <p className="muted">Voting is closed.</p>}
{summary && (
<div className="aud-results">
<p className="label">Results · {summary.n.toLocaleString()} {summary.n === 1 ? "answer" : "answers"}</p>
<ResultsView poll={poll} summary={summary} />
</div>
)}
</section>
);
}
function AnswerInput({ poll, value, onChange }: { poll: Poll; value: unknown; onChange: (v: unknown) => void }) {
switch (poll.type) {
case "choice": {
const picked = Array.isArray(value) ? (value as number[]) : [];
return (
<div className="choices" role={poll.multi ? "group" : "radiogroup"} aria-label={poll.question}>
{poll.options.map((o, i) => {
const on = picked.includes(i);
return (
<button key={i} type="button" role={poll.multi ? "checkbox" : "radio"} aria-checked={on} className={`choice${on ? " on" : ""}${poll.multi ? " multi" : ""}`}
onClick={() => onChange(poll.multi ? (on ? picked.filter((x) => x !== i) : [...picked, i]) : [i])}>
<span className="choice-mark" aria-hidden="true" />
<span>{o}</span>
</button>
);
})}
</div>
);
}
case "wordcloud": {
const words = Array.isArray(value) ? (value as string[]) : [];
return (
<div className="word-inputs">
{Array.from({ length: poll.max }, (_, i) => (
<input key={i} className="input" value={words[i] ?? ""} maxLength={LIMITS.word} placeholder={poll.max === 1 ? "One word" : `Word ${i + 1}`} aria-label={`Word ${i + 1}`}
autoCapitalize="none" enterKeyHint="send"
onChange={(e) => { const next = [...words]; next[i] = e.target.value; onChange(next); }} />
))}
</div>
);
}
case "rating":
return (
<div className={`scale scale-${poll.max > 5 ? "wide" : "narrow"}`} role="radiogroup" aria-label={poll.question}>
{Array.from({ length: poll.max }, (_, i) => i + 1).map((n) => (
<button key={n} type="button" role="radio" aria-checked={value === n} className={value === n ? "on" : ""} onClick={() => onChange(n)}>{n}</button>
))}
</div>
);
case "open": {
const text = typeof value === "string" ? value : "";
return (
<div className="stack" style={{ ["--gap" as string]: "6px" }}>
<textarea className="textarea" value={text} maxLength={LIMITS.text} rows={3} placeholder="Your answer" aria-label="Your answer" onChange={(e) => onChange(e.target.value)} />
<span className="hint counter">{text.length} / {LIMITS.text}</span>
</div>
);
}
case "ranking": {
const order = Array.isArray(value) ? (value as number[]) : [];
const rest = poll.options.map((_, i) => i).filter((i) => !order.includes(i));
// Once one option is left, it is last: place it without another tap.
const add = (i: number) => {
const next = [...order, i];
const left = poll.options.map((_, j) => j).filter((j) => !next.includes(j));
onChange(left.length === 1 ? [...next, left[0]] : next);
};
return (
<div className="rank-input">
{order.length > 0 && (
<ol className="ranked">
{order.map((i, place) => (
<li key={i}>
<button type="button" onClick={() => onChange(order.filter((x) => x !== i))} aria-label={`${poll.options[i]}, place ${place + 1}. Tap to take it out.`}>
<span className="rank-place">{place + 1}</span><span>{poll.options[i]}</span><span className="rank-x" aria-hidden="true">×</span>
</button>
</li>
))}
</ol>
)}
{rest.length > 0 && (
<div className="choices">
{rest.map((i) => (
<button key={i} type="button" className="choice" onClick={() => add(i)}>
<span className="rank-next" aria-hidden="true">{order.length + 1}</span><span>{poll.options[i]}</span>
</button>
))}
</div>
)}
{order.length > 0 && <button className="btn btn-sm btn-quiet" type="button" onClick={() => onChange([])}>Start over</button>}
</div>
);
}
}
}
const NAME_KEY = "polls:name";
const API_BASE = `https://functions.openapps.xyz/apps/${SERVED_APP_ID}/api`;
function AudienceQA({ eventId, uid, event }: { eventId: string; uid: string; event: EventDoc }) {
const questions = useAll<QuestionDoc>(`events/${eventId}/questions`);
const upvoted = useAll<{ id: string }>(`events/${eventId}/upvotes/${uid}/q`);
const asked = useAll<{ id: string }>(`events/${eventId}/asked/${uid}/q`);
const [sort, setSort] = useState<"top" | "new">("top");
const [text, setText] = useState("");
const [name, setName] = useState(() => { try { return localStorage.getItem(NAME_KEY) || ""; } catch { return ""; } });
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const mineUp = useMemo(() => new Set((upvoted || []).map((u) => u.id)), [upvoted]);
const mineAsked = useMemo(() => new Set((asked || []).map((a) => a.id)), [asked]);
const list = useMemo(() => {
const shown = (questions || []).filter((q) => q.status === "live" || q.status === "answered" || mineAsked.has(q.id));
const order = sort === "top" ? byVotes : (a: QuestionDoc, b: QuestionDoc) => b.createdAt - a.createdAt;
return [...shown.filter((q) => q.status !== "answered").sort(order), ...shown.filter((q) => q.status === "answered").sort(order)];
}, [questions, sort, mineAsked]);
const ask = async () => {
const checked = checkAsk(text, name);
if ("error" in checked) { setError(checked.error); return; }
setBusy(true);
setError("");
try {
try { localStorage.setItem(NAME_KEY, checked.name); } catch { /* fine without it */ }
// Every stored document names the account that wrote it, so the app's API stores the question, not this
// device: a question without a name stays anonymous. The asked record, readable only here, comes first
// and lets this device see its own question while it waits for approval.
const id = randomId(16);
await set(`events/${eventId}/asked/${uid}/q/${id}`, { at: serverTimestamp() });
const res = await fetch(`${API_BASE}/events/${eventId}/questions`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id, ...checked }) });
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error || "Your question did not go through. Try again.");
setText("");
toast(event.moderate ? "Sent. It shows once the host approves it." : "Question sent");
} catch (err) {
setError(explain(err));
}
setBusy(false);
};
const upvote = async (q: QuestionDoc) => {
try {
await setMany([
{ path: `events/${eventId}/questions/${q.id}`, document: { upvotes: increment(1) } },
{ path: `events/${eventId}/upvotes/${uid}/q/${q.id}`, document: { at: serverTimestamp() } },
]);
} catch (err) {
toast(q.status !== "live" ? "That question is no longer open for votes." : explain(err));
}
};
if (!event.qa) return null;
return (
<div className="aud-qa">
<form className="card ask" onSubmit={(e) => { e.preventDefault(); ask(); }}>
<label className="field">
<span>Ask the speaker</span>
<textarea className="textarea" value={text} maxLength={LIMITS.ask} rows={3} placeholder="Type your question" onChange={(e) => { setText(e.target.value); setError(""); }} />
</label>
<div className="ask-row">
<input className="input" value={name} maxLength={LIMITS.name} placeholder="Your name (optional)" aria-label="Your name (optional)" autoComplete="name" onChange={(e) => setName(e.target.value)} />
<button className="btn" type="submit" disabled={busy}>{busy ? "Sending..." : "Send"}</button>
</div>
{error && <p className="q-error" role="alert">{error}</p>}
{event.moderate && <p className="hint">The host approves questions before everyone sees them.</p>}
</form>
<div className="spread qa-bar">
<p className="label">{list.length ? `${list.length} ${list.length === 1 ? "question" : "questions"}` : "No questions yet"}</p>
<div className="seg seg-sm" role="tablist" aria-label="Sort">
<button type="button" role="tab" aria-selected={sort === "top"} onClick={() => setSort("top")}>Popular</button>
<button type="button" role="tab" aria-selected={sort === "new"} onClick={() => setSort("new")}>Recent</button>
</div>
</div>
{questions === undefined ? <Loading text="Loading questions..." /> : (
<ul className="qa-list">
{list.map((q) => {
const voted = mineUp.has(q.id);
return (
<li key={q.id} className={`card qa-item${q.status === "answered" ? " answered" : ""}`}>
<div className="qa-text">
<p>{q.text}</p>
<p className="qa-meta">
{q.name || "Anonymous"}
{q.status === "answered" && <span className="chip chip-free">Answered</span>}
{q.status === "pending" && <span className="chip">Waiting for approval</span>}
{q.status === "hidden" && <span className="chip">Hidden by the host</span>}
</p>
</div>
<button type="button" className={`upvote${voted ? " on" : ""}`} aria-pressed={voted} disabled={voted || q.status !== "live"} onClick={() => upvote(q)}
aria-label={voted ? `You upvoted this. ${q.upvotes} upvotes` : `Upvote. ${q.upvotes} upvotes`}>
<svg width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4l6 7h-4v5H8v-5H4z" fill="currentColor" /></svg>
<span>{q.upvotes}</span>
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy constants
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync