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/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
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