lists-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 ebccaf0b13fe
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.
← All files
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
ebccaf0b13 · 17 hours ago
Bind agent address in policy constants
519855687c · 19 hours ago
Use OpenApps service URLs and add contribution strip
2c353edfdc · 19 hours ago
Bounded source sync
58ac7e08ec · 1 day ago
Bounded source sync
150962f3d9 · 1 day ago
Bounded source sync
e52e4676b9 · 1 day ago