polls-free.openapps.xyz

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.
Owned app · rev c06cea4889e9
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.
← All files
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&amp;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
c06cea4889 · 17 hours ago
Bind agent address in policy constants
a7de116d52 · 19 hours ago
Use OpenApps service URLs and add contribution strip
e8ce0b0bf0 · 19 hours ago
Bounded source sync
dcdc4d56e6 · 1 day ago
Bounded source sync
7acdace1e1 · 1 day ago
Bounded source sync
2140a66ea0 · 1 day ago
Bounded source sync
0db2fe3ca0 · 1 day ago
Bounded source sync
996f633a3c · 1 day ago