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 ee59501e91bae6d2c23a8370
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/data.ts
// A list on the device and on Bounded. What you see is the last copy the server sent (kept on the
// device, so it opens offline) with your unsent changes on top. Item changes are queued in the outbox
// (sync.ts) and work offline; joining, the admin link and the owner's powers need a connection.
import { useEffect, useMemo, useState } from "react";
import { get, set, setMany, useQuery, serverTimestamp, increment } from "@bounded-sh/client";
import { me, meSoon, explain } from "./bounded";
import { read, write, useStored } from "./local";
import { enqueue, dropList, onSent, useOutbox, type Doc, type Op, type Write } from "./sync";
import {
guessSection, itemKey, parseEntry, randomId, splitEntries, tidyName, LIMITS,
type Item, type Kind, type Known,
} from "../../functions/lib/lists";
export interface ListDoc { name: string; kind: Kind; order: string; createdBy: string; createdAt: number; deleted?: boolean | null }
export interface Member { id: string; name: string; at: number }
export interface Mine { id: string; name: string; kind: Kind; ownerKey?: string; opened: number; toGet?: number }
const nowSec = () => Math.floor(Date.now() / 1000);
const MINE = "lists:mine";
const SNAP = (id: string) => `lists:snap:${id}`;
// ---------- Your lists on this device ----------
export const useMine = () => useStored<Record<string, Mine>>(MINE, {});
export function remember(id: string, patch: Partial<Mine>) {
const all = read<Record<string, Mine>>(MINE, {});
const next = { ...all[id], ...patch, id } as Mine;
if (JSON.stringify(all[id]) !== JSON.stringify(next)) write(MINE, { ...all, [id]: next });
}
/** Takes a list off this device: its saved copy, unsent changes and the admin key. */
export function forget(id: string) {
const { [id]: _, ...rest } = read<Record<string, Mine>>(MINE, {});
write(MINE, rest);
write(SNAP(id), undefined);
dropList(id);
}
// ---------- Session ----------
/** The guest id: right away from this device if it had one, then confirmed by the server when online. */
export function useSession() {
const [state, setState] = useState(() => ({ uid: read<string | null>("lists:uid", null), ready: false }));
useEffect(() => {
let on = true;
const start = () => me().then((uid) => { if (on) setState({ uid, ready: true }); }).catch(() => null);
start();
window.addEventListener("online", start);
return () => { on = false; window.removeEventListener("online", start); };
}, []);
return state;
}
// ---------- Reading a list ----------
interface Snap { list: ListDoc | null; items: Record<string, Item>; known: Record<string, Known>; key: string | null; members: Member[]; owners: string[] }
const EMPTY: Snap = { list: null, items: {}, known: {}, key: null, members: [], owners: [] };
function patchSnap(id: string, patch: Partial<Snap>) {
write(SNAP(id), { ...read<Snap>(SNAP(id), EMPTY), ...patch });
}
const bare = <T,>(rows: T[] | null | undefined) => (rows || []).map((r: any) => ({ ...r, id: r.id ?? String(r._id ?? "").split("/").pop() })) as (T & { id: string })[];
const byId = <T extends { id: string }>(rows: T[]) => Object.fromEntries(rows.map((r) => [r.id, r]));
/** A queued document change applied to a copy: server time is now, increments add, null clears a field. */
function apply(doc: Doc, prev: Doc | undefined): Doc {
const out: Doc = { ...(prev || {}) };
for (const [k, v] of Object.entries(doc)) {
const op = v as { operation?: string; value?: unknown } | null;
if (op && typeof op === "object" && op.operation === "time") out[k] = nowSec();
else if (op && typeof op === "object" && op.operation === "increment") out[k] = Number(prev?.[k] || 0) + Number(op.value);
else if (v === null) delete out[k];
else out[k] = v;
}
return out;
}
/** The saved copy with changes applied (server time is now, increments add, null deletes). */
function applyOps(snap: Snap, ops: Op[]) {
let list = snap.list as Doc | null;
const items: Record<string, Doc> = { ...snap.items } as never;
const known: Record<string, Doc> = { ...snap.known } as never;
for (const op of ops) {
for (const w of op.writes) {
const seg = w.path.split("/");
if (seg[0] !== "lists") continue;
if (seg.length === 2) list = w.document ? apply(w.document, list ?? undefined) : null;
else if (seg[2] === "items" || seg[2] === "known") {
const into = seg[2] === "items" ? items : known;
if (w.document) into[seg[3]] = { ...apply(w.document, into[seg[3]]), id: seg[3] };
else delete into[seg[3]];
}
}
}
return { list: list as ListDoc | null, items: items as unknown as Record<string, Item>, known: known as unknown as Record<string, Known> };
}
function overlay(snap: Snap, ops: Op[]) {
const v = applyOps(snap, ops);
// A queued change to an item someone else removed is only a fragment: leave it out.
return { ...v, items: Object.values(v.items).filter((i) => typeof i.name === "string" && i.name) };
}
// A sent change goes into the saved copy right away, so a checked item does not flicker back
// before the live update arrives.
onSent((op) => {
const snap = read<Snap | null>(SNAP(op.listId), null);
if (snap) write(SNAP(op.listId), { ...snap, ...applyOps(snap, [op]) });
});
export type ListState = "loading" | "ready" | "gone" | "deleted";
const LIMIT = { limit: 1000 };
const LIMIT_PEOPLE = { limit: 500 };
/** Everything the list page shows, live when online and from the device when not. */
export function useList(id: string) {
const { uid, ready } = useSession();
const ops = useOutbox();
const snap = useStored<Snap>(SNAP(id), EMPTY);
const mine = useMemo(() => ops.filter((o) => o.listId === id), [ops, id]);
const live = ready && !mine.some((o) => o.create);
const listQ = useQuery<ListDoc | null>(live ? `lists/${id}` : null);
const itemsQ = useQuery<Item[]>(live ? `lists/${id}/items` : null, LIMIT);
const knownQ = useQuery<Known[]>(live ? `lists/${id}/known` : null, LIMIT);
const membersQ = useQuery<Member[]>(live ? `lists/${id}/members` : null, LIMIT_PEOPLE);
const ownersQ = useQuery<{ id: string }[]>(live ? `lists/${id}/owners` : null, LIMIT_PEOPLE);
const keyQ = useQuery<{ key: string } | null>(live ? `listkeys/${id}` : null);
useEffect(() => { if (listQ.data !== undefined) patchSnap(id, { list: listQ.data }); }, [id, listQ.data]);
useEffect(() => { if (itemsQ.data) patchSnap(id, { items: byId(bare(itemsQ.data)) }); }, [id, itemsQ.data]);
useEffect(() => { if (knownQ.data) patchSnap(id, { known: byId(bare(knownQ.data)) }); }, [id, knownQ.data]);
useEffect(() => { if (membersQ.data) patchSnap(id, { members: bare(membersQ.data) }); }, [id, membersQ.data]);
useEffect(() => { if (ownersQ.data) patchSnap(id, { owners: bare(ownersQ.data).map((o) => o.id) }); }, [id, ownersQ.data]);
useEffect(() => { if (keyQ.data?.key) patchSnap(id, { key: keyQ.data.key }); }, [id, keyQ.data]);
const view = useMemo(() => overlay(snap, mine), [snap, mine]);
// A refused read arrives as null when a query starts, but an open query on the list is not told when its
// reader is removed. The members query is: it comes back without them.
const removed = Boolean(uid && membersQ.data && !bare(membersQ.data).some((m) => m.id === uid));
const state: ListState = listQ.data === null || removed ? "gone" : view.list?.deleted ? "deleted" : view.list ? "ready" : "loading";
useEffect(() => {
if (state === "ready" && view.list) remember(id, { name: view.list.name, kind: view.list.kind, toGet: view.items.filter((i) => !i.checked).length });
}, [id, state, view]);
return {
uid,
state,
list: view.list,
items: view.items,
known: view.known,
members: [...snap.members].sort((a, b) => a.at - b.at),
owners: new Set(snap.owners),
secret: snap.key,
pending: mine.length,
live: listQ.data != null,
};
}
// ---------- Making, joining and owning lists (online) ----------
/** Makes a list. Works offline for a browser that has used Lists before: it is sent when there is a connection. */
export async function createList(name: string, kind: Kind): Promise<string> {
const uid = await meSoon();
if (!uid) throw new Error("Connect to the internet once to make your first list. After that, lists work offline too.");
const id = randomId(20);
const secret = randomId(24);
const ownerKey = randomId(24);
const now = serverTimestamp();
enqueue(id, [
{ path: `lists/${id}`, document: { name, kind, order: "", createdBy: uid, createdAt: now } },
{ path: `lists/${id}/members/${uid}`, document: { name: "", at: now } },
{ path: `lists/${id}/owners/${uid}`, document: { at: now } },
{ path: `listkeys/${id}`, document: { key: secret } },
{ path: `ownerkeys/${id}`, document: { key: ownerKey } },
], true);
write(SNAP(id), { ...EMPTY, key: secret, members: [{ id: uid, name: "", at: nowSec() }], owners: [uid] });
remember(id, { name, kind, ownerKey, opened: nowSec(), toGet: 0 });
return id;
}
const offline = (err: unknown) => /offline/.test(explain(err));
/** Joins a list from its share link. Already a member: nothing to do. */
export async function joinList(id: string, secret: string): Promise<void> {
const uid = await me();
if (!(await get(`lists/${id}/members/${uid}`))) {
try {
await setMany([
{ path: `lists/${id}/claims/${uid}`, document: { key: secret, n: randomId(12) } },
{ path: `lists/${id}/members/${uid}`, document: { name: "", at: serverTimestamp() } },
]);
} catch (err) {
if (offline(err)) throw err;
throw new Error("This link does not work any more. Ask someone on the list for the current link.");
}
}
const list = (await get(`lists/${id}`)) as ListDoc | null;
remember(id, { name: list?.name || "Shared list", kind: list?.kind || "grocery", opened: nowSec() });
}
/** Opens the admin link on this device: becomes an owner (and a member) with the owner key. */
export async function claimAdmin(id: string, ownerKey: string): Promise<void> {
const uid = await me();
const [owner, member] = await Promise.all([get(`lists/${id}/owners/${uid}`), get(`lists/${id}/members/${uid}`)]);
if (!owner || !member) {
try {
await setMany([
{ path: `lists/${id}/ownerclaims/${uid}`, document: { key: ownerKey, n: randomId(12) } },
...(member ? [] : [{ path: `lists/${id}/members/${uid}`, document: { name: "", at: serverTimestamp() } }]),
...(owner ? [] : [{ path: `lists/${id}/owners/${uid}`, document: { at: serverTimestamp() } }]),
]);
} catch (err) {
if (offline(err)) throw err;
throw new Error("This admin link does not work. Check that you copied all of it, or that the list still exists.");
}
}
const list = (await get(`lists/${id}`)) as ListDoc | null;
remember(id, { name: list?.name || "Shared list", kind: list?.kind || "grocery", ownerKey, opened: nowSec() });
}
/** An owner removes someone, and by default changes the link so they cannot come back with it. */
export async function removeMember(id: string, memberId: string, newLink: boolean) {
await setMany([
{ path: `lists/${id}/members/${memberId}`, document: null },
...(newLink ? [{ path: `listkeys/${id}`, document: { key: randomId(24) } }] : []),
]);
}
/** A new share link. People already on the list stay; the old link and the old assistant key stop working. */
export const changeLink = (id: string) => set(`listkeys/${id}`, { key: randomId(24) });
export async function leaveList(id: string, uid: string) {
await set(`lists/${id}/members/${uid}`, null);
forget(id);
}
/** Deletes the list for everyone, then erases its items and history. */
export async function deleteList(id: string, itemIds: string[], knownIds: string[]) {
await set(`lists/${id}`, { deleted: true });
const paths = [...itemIds.map((i) => `lists/${id}/items/${i}`), ...knownIds.map((k) => `lists/${id}/known/${k}`)];
for (let i = 0; i < paths.length; i += 100) {
await setMany(paths.slice(i, i + 100).map((path) => ({ path, document: null }))).catch(() => null);
}
forget(id);
}
// ---------- Changing items (works offline) ----------
const item = (id: string, itemId: string) => `lists/${id}/items/${itemId}`;
const knownPath = (id: string, itemId: string) => `lists/${id}/known/${itemId}`;
const remembered = (id: string, itemId: string, name: string, section: string): Write =>
({ path: knownPath(id, itemId), document: { name, section, uses: increment(1), at: nowSec() } });
/** Adds what was typed ("milk, 2 lb chicken and eggs"). Items already on the list are not added twice. */
export function addItems(id: string, kind: Kind, text: string, items: Item[], known: Record<string, Known>) {
const added: string[] = [];
const already: string[] = [];
let addedAt = Date.now();
for (const part of splitEntries(text)) {
const e = parseEntry(part);
const name = tidyName(e.name).slice(0, LIMITS.itemName);
if (!name) continue;
const key = itemKey(name);
const existing = items.find((i) => i.id === key);
const section = existing?.section || known[key]?.section || (kind === "todo" ? "other" : guessSection(name));
const qty = e.qty.slice(0, LIMITS.qty);
if (existing && !existing.checked && (!qty || qty === existing.qty)) {
already.push(existing.name);
continue;
}
const doc = existing
? { checked: false, checkedAt: null, ...(qty ? { qty } : {}) }
: { name, qty, note: "", section, checked: false, addedAt: addedAt++ };
enqueue(id, [{ path: item(id, key), document: doc }, remembered(id, key, existing?.name ?? name, section)]);
added.push(existing?.name ?? name);
}
return { added, already };
}
export function toggleItem(id: string, it: Item) {
enqueue(id, [{ path: item(id, it.id), document: { checked: !it.checked, checkedAt: it.checked ? null : nowSec() } }]);
}
/** Saves the edit sheet. A new name is a new item id (ids come from names), so the item moves. */
export function updateItem(id: string, it: Item, next: { name: string; qty: string; note: string; section: string }, items: Item[]): string | null {
const name = tidyName(next.name);
if (!name) return "Give the item a name.";
const key = itemKey(name);
const fields = { name, qty: next.qty.trim(), note: next.note.trim(), section: next.section };
const writes: Write[] = [];
if (key !== it.id) {
if (items.some((i) => i.id === key)) return `${name} is already on the list.`;
writes.push({ path: item(id, it.id), document: null });
writes.push({ path: item(id, key), document: { ...fields, checked: it.checked, ...(it.checked ? { checkedAt: it.checkedAt ?? nowSec() } : {}), addedAt: it.addedAt ?? Date.now() } });
} else {
writes.push({ path: item(id, key), document: fields });
}
// Remember the section someone chose, so this item lands there next time.
if (key !== it.id || next.section !== it.section) writes.push(remembered(id, key, name, next.section));
enqueue(id, writes);
return null;
}
export const removeItem = (id: string, it: Item) => enqueue(id, [{ path: item(id, it.id), document: null }]);
export function clearChecked(id: string, done: Item[]) {
for (let i = 0; i < done.length; i += 100) enqueue(id, done.slice(i, i + 100).map((it) => ({ path: item(id, it.id), document: null })));
}
/** Puts items back after "Clear checked" was undone. */
export function restoreItems(id: string, done: Item[]) {
for (let i = 0; i < done.length; i += 100) {
enqueue(id, done.slice(i, i + 100).map((it) => ({
path: item(id, it.id),
document: { name: it.name, qty: it.qty || "", note: it.note || "", section: it.section, checked: true, checkedAt: it.checkedAt ?? nowSec(), addedAt: it.addedAt ?? Date.now() },
})));
}
}
export const forgetSuggestion = (id: string, k: Known) => enqueue(id, [{ path: knownPath(id, k.id), document: null }]);
export function updateList(id: string, patch: Partial<Pick<ListDoc, "name" | "kind" | "order">>) {
enqueue(id, [{ path: `lists/${id}`, document: patch }]);
if (patch.name || patch.kind) remember(id, { ...(patch.name ? { name: patch.name } : {}), ...(patch.kind ? { kind: patch.kind } : {}) });
}
export const setMyName = (id: string, uid: string, name: string) => enqueue(id, [{ path: `lists/${id}/members/${uid}`, document: { name: name.trim().slice(0, LIMITS.memberName) } }]);
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