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/Present.tsx
// Present mode at /e/<id>/present: the screen the host shares. Large live results, the join code and QR
// code always in view, and keyboard controls: arrows (or a clicker) move between polls, R shows results,
// O opens or closes voting, Q shows the Q&A, F goes full screen.
import { useEffect, useMemo, useRef, useState } from "react";
import { explain } from "./lib/bounded";
import { useAll, useEvent, usePolls, usePollResults, showOnScreen, setResultsShown, setVoting } from "./lib/event";
import { toast } from "./ui";
import { Qr } from "./Qr";
import { Loading } from "./Event";
import { ResultsView } from "./Results";
import { audienceUrl, SITE_HOST } from "./Admin";
import { byVotes, formatCode, POLL_TYPES, type Poll, type QuestionDoc } from "../functions/lib/polls";

function Publisher({ eventId, poll }: { eventId: string; poll: Poll }) {
  usePollResults(eventId, poll);
  return null;
}

export function Present({ eventId }: { eventId: string }) {
  const event = useEvent(eventId);
  const polls = usePolls(eventId);
  const questions = useAll<QuestionDoc>(event?.current === "qa" ? `events/${eventId}/questions` : null);
  // Moves show at once here; the shared document catches up a moment later.
  const [pending, setPending] = useState<string | null>(null);
  useEffect(() => setPending(null), [event?.current]);
  const current = pending ?? event?.current ?? "";
  const poll = polls?.find((p) => p.id === current) ?? null;
  const { summary } = usePollResults(eventId, poll);
  const lastPoll = useRef<string>("");
  if (poll) lastPoll.current = poll.id;

  useEffect(() => { if (event?.title) document.title = `${event.title} | Present | Polls`; }, [event?.title]);

  const act = (job: () => Promise<unknown>) => { job().catch((err) => { setPending(null); toast(explain(err)); }); };
  const go = (target: Poll | "qa" | "") => {
    setPending(typeof target === "string" ? target : target.id);
    act(() => showOnScreen(eventId, target));
  };
  const step = (dir: 1 | -1) => {
    if (!polls) return;
    // Position 0 is the welcome screen, then each poll. From the Q&A, arrows return next to the last poll shown.
    const last = polls.findIndex((p) => p.id === lastPoll.current) + 1;
    const pos = current === "qa" ? last + 0.5 : poll ? polls.indexOf(poll) + 1 : 0;
    const target = Math.min(polls.length, Math.max(0, dir > 0 ? Math.floor(pos) + 1 : Math.ceil(pos) - 1));
    if (target === pos) return;
    go(target === 0 ? "" : polls[target - 1]);
  };
  const toggleResults = () => { if (poll) act(() => setResultsShown(eventId, poll, !poll.showResults)); };
  const toggleVoting = () => { if (poll) act(() => setVoting(eventId, poll, poll.status !== "open")); };
  const toggleQa = () => go(current === "qa" ? (polls?.find((p) => p.id === lastPoll.current) ?? "") : "qa");
  const toggleFull = () => {
    if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
    else document.documentElement.requestFullscreen?.().catch(() => {});
  };

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.metaKey || e.ctrlKey || e.altKey || (e.target as HTMLElement | null)?.closest?.("input, textarea, select, [role=dialog]")) return;
      const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
      const run = ({ ArrowRight: () => step(1), ArrowDown: () => step(1), PageDown: () => step(1), " ": () => step(1),
        ArrowLeft: () => step(-1), ArrowUp: () => step(-1), PageUp: () => step(-1),
        r: toggleResults, o: toggleVoting, q: toggleQa, f: toggleFull } as Record<string, () => void>)[k];
      if (!run) return;
      e.preventDefault();
      if (!e.repeat || k.startsWith("Arrow") || k.startsWith("Page")) run();
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  });

  const others = useMemo(() => (polls || []).filter((p) => p.showResults && p.status !== "draft" && p.id !== poll?.id), [polls, poll?.id]);

  if (event === undefined || polls === undefined) return <Loading text="Loading..." />;
  if (!event) return null;
  const url = audienceUrl(eventId);
  const index = poll ? polls.indexOf(poll) : -1;
  const n = summary?.n ?? 0;

  return (
    <div className="present">
      {others.map((p) => <Publisher key={p.id} eventId={eventId} poll={p} />)}
      {current !== "" && (
        <header className="pr-top">
          <p className="pr-join">Join at <strong>{SITE_HOST}</strong><span className="pr-code">{formatCode(event.code)}</span></p>
          <Qr text={url} size={132} label="QR code that opens this event" />
        </header>
      )}
      <main className="pr-main">
        {current === "" ? (
          <div className="pr-welcome">
            <div>
              <p className="eyebrow">Join with your phone</p>
              <h1 className="pr-title">{event.title}</h1>
              <p className="pr-how">Go to <strong>{SITE_HOST}</strong> and type</p>
              <p className="pr-bigcode">{formatCode(event.code)}</p>
              <p className="pr-how muted">or scan the code. No app, no account.</p>
            </div>
            <Qr text={url} size={340} label="QR code that opens this event" />
          </div>
        ) : current === "qa" ? (
          <PresentQA questions={questions} />
        ) : poll ? (
          <div className="pr-poll">
            <p className="eyebrow">Poll {index + 1} of {polls.length} · {POLL_TYPES[poll.type]}</p>
            <h1 className="pr-question">{poll.question}</h1>
            {poll.showResults && summary ? (
              <ResultsView poll={poll} summary={summary} big />
            ) : (
              <div className="pr-waiting">
                {(poll.type === "choice" || poll.type === "ranking") && (
                  <ul className="pr-options">{poll.options.map((o, i) => <li key={i}>{o}</li>)}</ul>
                )}
                {summary && <p className="pr-count"><strong>{n.toLocaleString()}</strong> {n === 1 ? "answer" : "answers"}</p>}
              </div>
            )}
          </div>
        ) : (
          <div className="pr-poll"><h1 className="pr-question">That poll was deleted.</h1></div>
        )}
      </main>
      <footer className="pr-bar">
        <p className="pr-status">
          {current === "" ? "Welcome screen" : current === "qa" ? "Q&A" : poll ? (
            <>
              <span className={`dot dot-${poll.status}`} />{poll.status === "open" ? "Voting open" : poll.status === "closed" ? "Voting closed" : "Draft"}
              {poll.showResults ? " · Results shown" : " · Results hidden"}
            </>
          ) : ""}
        </p>
        <div className="pr-controls">
          <button type="button" className="pr-btn" onClick={() => step(-1)} aria-label="Previous (left arrow)" title="Previous (left arrow)">
            <svg width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><path d="M12.5 4.5 7 10l5.5 5.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
          </button>
          <button type="button" className="pr-btn" onClick={() => step(1)} aria-label="Next (right arrow)" title="Next (right arrow)">
            <svg width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><path d="M7.5 4.5 13 10l-5.5 5.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
          </button>
          {poll && <button type="button" className="pr-btn" onClick={toggleResults}>{poll.showResults ? "Hide results" : "Show results"} <kbd>R</kbd></button>}
          {poll && <button type="button" className="pr-btn" onClick={toggleVoting}>{poll.status === "open" ? "Close voting" : "Open voting"} <kbd>O</kbd></button>}
          {event.qa && <button type="button" className="pr-btn" onClick={toggleQa}>{current === "qa" ? "Back to polls" : "Q&A"} <kbd>Q</kbd></button>}
          <button type="button" className="pr-btn pr-optional" onClick={toggleFull}>Full screen <kbd>F</kbd></button>
          <a className="pr-btn pr-optional" href={`/e/${eventId}/admin`} data-nav>Exit</a>
        </div>
      </footer>
    </div>
  );
}

function PresentQA({ questions }: { questions: QuestionDoc[] | undefined }) {
  const live = useMemo(() => (questions || []).filter((q) => q.status === "live").sort(byVotes).slice(0, 8), [questions]);
  return (
    <div className="pr-qa">
      <p className="eyebrow">Questions from the audience</p>
      {questions === undefined ? null : live.length ? (
        <ol className="pr-qa-list">
          {live.map((q) => (
            <li key={q.id}>
              <span className="pr-votes"><strong>{q.upvotes}</strong></span>
              <div><p className="pr-qa-text">{q.text}</p><p className="muted">{q.name || "Anonymous"}</p></div>
            </li>
          ))}
        </ol>
      ) : <h1 className="pr-question">No questions yet. Ask from your phone.</h1>}
    </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