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/lib/event.ts
// Opening an event on this device, live reads that stay complete past 1,000 rows, and the presenter's actions.
import { useEffect, useMemo, useState } from "react";
import { get, set, setMany, useQuery, serverTimestamp } from "@bounded-sh/client";
import { me, explain } from "./bounded";
import { keyFor, remember } from "./mine";
import { toPoll, byPos, summarize, publishable, type EventDoc, type PollDoc, type Poll, type VoteRow, type Summary } from "../../functions/lib/polls";

export type Access =
  | { state: "opening" }
  | { state: "missing" }
  | { state: "badkey" }
  | { state: "error"; error: string }
  | { state: "ready"; uid: string; owner: boolean };

/**
 * Joins the event (knowing its id is the permission to read it). An admin link (#k=<key>) or a key this
 * browser kept makes this device an owner too. The key never stays in the address bar.
 */
export function useEventAccess(eventId: string): Access {
  const [access, setAccess] = useState<Access>({ state: "opening" });
  // Pasting the admin link into a tab already on this page only changes the hash: check again.
  const [attempt, setAttempt] = useState(0);
  useEffect(() => {
    const onHash = () => { if (/(^|[#&])k=/.test(location.hash)) { setAccess({ state: "opening" }); setAttempt((n) => n + 1); } };
    window.addEventListener("hashchange", onHash);
    return () => window.removeEventListener("hashchange", onHash);
  }, []);
  useEffect(() => {
    let cancelled = false;
    const done = (a: Access) => { if (!cancelled) setAccess(a); };
    // Drop the key from the address bar at once, before signing in: present mode is often on a shared screen.
    const linkKey = new URLSearchParams(location.hash.slice(1)).get("k");
    if (linkKey) history.replaceState(null, "", location.pathname + location.search);
    (async () => {
      try {
        const uid = await me();
        if (!(await get(`events/${eventId}/joins/${uid}`))) {
          try { await set(`events/${eventId}/joins/${uid}`, { at: serverTimestamp() }); } catch { return done({ state: "missing" }); }
        }
        const event = (await get(`events/${eventId}`)) as EventDoc | null;
        if (!event) return done({ state: "missing" });
        const key = linkKey || keyFor(eventId);
        let owner = Boolean(await get(`events/${eventId}/owners/${uid}`));
        if (!owner && key) {
          try {
            await setMany([
              { path: `eventclaims/${eventId}/c/${uid}`, document: { key } },
              { path: `events/${eventId}/owners/${uid}`, document: { at: serverTimestamp() } },
            ]);
            owner = true;
          } catch {
            if (linkKey) return done({ state: "badkey" });
          }
        }
        if (owner && key) remember(eventId, key, event.title, event.code);
        done({ state: "ready", uid, owner });
      } catch (err) {
        done({ state: "error", error: explain(err) });
      }
    })();
    return () => { cancelled = true; };
  }, [eventId, attempt]);
  return access;
}

// Live reads return at most 1,000 rows. Below that the live set is complete; past it, changes reload
// every page, at most once every few seconds.
const PAGE = 1000;
// One batch holds at most 100 writes.
const PAGE_WRITES = 100;

export function useAll<T>(path: string | null): T[] | undefined {
  const { data: live } = useQuery(path, { limit: PAGE }) as { data: T[] | undefined };
  const [all, setAll] = useState<{ path: string; rows: T[] }>();
  const [lastPaged, setLastPaged] = useState(0);
  useEffect(() => {
    if (!path || !live) return;
    if (live.length < PAGE) { setAll({ path, rows: live }); return; }
    let cancelled = false;
    const timer = window.setTimeout(async () => {
      let rows: T[];
      try {
        rows = await readAll<T>(path);
      } catch {
        if (!cancelled) setAll({ path, rows: live });
        return;
      }
      if (!cancelled) { setAll({ path, rows }); setLastPaged(Date.now()); }
    }, Math.max(0, lastPaged + 3000 - Date.now()));
    return () => { cancelled = true; window.clearTimeout(timer); };
  }, [path, live]); // eslint-disable-line react-hooks/exhaustive-deps
  return path && all?.path === path ? all.rows : undefined;
}

export function useEvent(eventId: string) {
  return (useQuery(`events/${eventId}`) as { data: EventDoc | null | undefined }).data;
}

/** The polls this device can read, in order. The audience never receives drafts (the read rule hides them). */
export function usePolls(eventId: string): Poll[] | undefined {
  const rows = useAll<PollDoc & { id: string }>(`events/${eventId}/polls`);
  return useMemo(() => rows?.map((r) => toPoll(r.id, r)).sort(byPos), [rows]);
}

const lastPublish = new Map<string, number>();

/**
 * Owner devices only: every vote for a poll, its summary, and, while results are shown, the summary
 * published into the poll so audience phones read one small document instead of every vote.
 */
export function usePollResults(eventId: string, poll: Poll | null): { summary: Summary | null } {
  const votes = useAll<VoteRow>(poll && poll.status !== "draft" ? `events/${eventId}/polls/${poll.id}/votes` : null);
  // No summary until the votes arrive, so a screen never flashes zero answers.
  const summary = useMemo(() => (poll && (votes || poll.status === "draft") ? summarize(poll, votes || []) : null), [poll, votes]);
  const json = poll?.showResults && votes && summary ? publishable(summary) : null;
  useEffect(() => {
    if (!poll || json === null || json === poll.results) return;
    const path = `events/${eventId}/polls/${poll.id}`;
    const timer = window.setTimeout(() => {
      lastPublish.set(path, Date.now());
      // Another owner device may have published the same thing a moment ago; the next change publishes again.
      set(path, { results: json }).catch(() => {});
    }, Math.max(0, (lastPublish.get(path) ?? 0) + 1500 - Date.now()));
    return () => window.clearTimeout(timer);
  }, [eventId, poll?.id, json, poll?.results]); // eslint-disable-line react-hooks/exhaustive-deps
  return { summary };
}

export const pollPath = (eventId: string, pollId: string) => `events/${eventId}/polls/${pollId}`;

/** Shows a poll on the presenter's screen and the audience's phones; a draft opens for voting. */
export function showOnScreen(eventId: string, poll: Poll | "qa" | "") {
  if (typeof poll === "string") return set(`events/${eventId}`, { current: poll });
  return setMany([
    { path: `events/${eventId}`, document: { current: poll.id } },
    ...(poll.status === "draft" ? [{ path: pollPath(eventId, poll.id), document: { status: "open" } }] : []),
  ]);
}

export function setResultsShown(eventId: string, poll: Poll, shown: boolean) {
  return set(pollPath(eventId, poll.id), shown ? { showResults: true } : { showResults: false, results: "" });
}

export function setVoting(eventId: string, poll: Poll, open: boolean) {
  return set(pollPath(eventId, poll.id), { status: open ? "open" : "closed" });
}

/** Swaps a poll with its neighbour. Positions are renumbered so ties from older events sort out too. */
export async function movePoll(eventId: string, polls: Poll[], index: number, by: -1 | 1) {
  const order = [...polls];
  const [p] = order.splice(index, 1);
  order.splice(index + by, 0, p);
  const writes = order.flatMap((q, i) => (q.pos === i ? [] : [{ path: pollPath(eventId, q.id), document: { pos: i } }]));
  for (let i = 0; i < writes.length; i += PAGE_WRITES) await setMany(writes.slice(i, i + PAGE_WRITES));
}

export async function deletePoll(eventId: string, poll: Poll, event: EventDoc) {
  await setMany([
    { path: pollPath(eventId, poll.id), document: null },
    ...(event.current === poll.id ? [{ path: `events/${eventId}`, document: { current: "" } }] : []),
  ]);
}

/** Every row of a collection, page by page. */
export async function readAll<T>(path: string): Promise<T[]> {
  const rows: T[] = [];
  let cursor: string | undefined;
  do {
    const res = (await get(path, { limit: PAGE, ...(cursor ? { cursor } : {}) })) as { data: T[]; nextCursor?: string | null };
    rows.push(...res.data);
    cursor = res.nextCursor || undefined;
  } while (cursor);
  return rows;
}

/** Deletes every vote on a poll, for a fresh start after a rehearsal. */
export async function clearVotes(eventId: string, poll: Poll) {
  const path = `${pollPath(eventId, poll.id)}/votes`;
  const votes = await readAll<{ id: string }>(path);
  for (let i = 0; i < votes.length; i += PAGE_WRITES) {
    await setMany(votes.slice(i, i + PAGE_WRITES).map((v) => ({ path: `${path}/${v.id}`, document: null })));
  }
}

Change history

Point OpenApps links to openapps.free
c06cea4889 · 1 day ago
Bind agent address in policy constants
a7de116d52 · 1 day ago
Use OpenApps service URLs and add contribution strip
e8ce0b0bf0 · 1 day ago
Bounded source sync
dcdc4d56e6 · 2 days ago
Bounded source sync
7acdace1e1 · 2 days ago
Bounded source sync
2140a66ea0 · 2 days ago
Bounded source sync
0db2fe3ca0 · 2 days ago
Bounded source sync
996f633a3c · 2 days ago