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 f6f2112e77bc8c011e38c83a
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/board.ts
// Live board state and every write the app makes. Reads are live queries, so changes from anyone
// on the board show up right away. Card edits show at once on this screen and are undone if the
// write is refused.
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import { get, set, setMany, useQuery, serverTimestamp } from "@bounded-sh/client";
import { me, explain } from "./bounded";
import { rememberBoard, forgetBoard } from "./mine";
import { toast } from "../ui";
import {
seedWrites, chunk, randomId, posBetween, tooClose, byPos, parseIds, COLOR_IDS,
type Board, type BoardSeed, type Card, type Label, type List, type Member, type Write,
} from "../../functions/lib/tasks";
type Row<T> = T & { id: string };
export type CardPatch = Partial<Omit<Card, "id" | "createdBy" | "createdAt">>;
const NOW = () => serverTimestamp();
/** Makes a board from a description in as many batches as it needs. The first batch holds the board, this device's join and ownership, and the admin key. */
export async function createBoard(seed: BoardSeed): Promise<{ boardId: string; key: string }> {
const uid = await me();
const boardId = randomId(20);
const key = randomId(24);
const { writes } = seedWrites(boardId, seed, uid, NOW());
// A batch holds at most 100 writes, and the first one also carries the board, join, owner and key.
const batches = [writes.slice(0, 96), ...chunk(writes.slice(96), 100)];
await setMany([
{ path: `boards/${boardId}`, document: { title: seed.title, archived: false, createdBy: uid, createdAt: NOW() } },
{ path: `boards/${boardId}/joins/${uid}`, document: { at: NOW() } },
{ path: `boards/${boardId}/owners/${uid}`, document: { at: NOW() } },
{ path: `boardkeys/${boardId}`, document: { key } },
...(batches.shift() || []),
]);
for (const b of batches) await setMany(b);
rememberBoard({ id: boardId, title: seed.title, key });
return { boardId, key };
}
/** Joins the board on this device (knowing the id is the permission). Returns false when the board does not exist or this device was removed. */
export async function joinBoard(boardId: string): Promise<{ uid: string; ok: boolean; removed?: boolean }> {
const uid = await me();
if (await get(`boards/${boardId}/joins/${uid}`)) return { uid, ok: true };
try {
await set(`boards/${boardId}/joins/${uid}`, { at: NOW() });
return { uid, ok: true };
} catch {
const blocked = await get(`boards/${boardId}/blocks/${uid}`).catch(() => null);
return { uid, ok: false, removed: Boolean(blocked) };
}
}
/** Presents the admin key on this device. True when this device is (now) an owner. */
export async function claimBoard(boardId: string, uid: string, key: string): Promise<boolean> {
if (await get(`boards/${boardId}/owners/${uid}`)) return true;
try {
await setMany([
{ path: `boardclaims/${boardId}/c/${uid}`, document: { key } },
{ path: `boards/${boardId}/owners/${uid}`, document: { at: NOW() } },
]);
return true;
} catch {
return false;
}
}
async function allIds(path: string): Promise<string[]> {
const ids: string[] = [];
let cursor: string | undefined;
for (let page = 0; page < 50; page++) {
const res = (await get(path, { limit: 1000, ...(cursor ? { cursor } : {}) })) as { data?: { id?: string; _id?: string }[]; nextCursor?: string } | null;
for (const r of res?.data || []) ids.push(String(r.id ?? String(r._id ?? "").split("/").pop()));
cursor = res?.nextCursor || undefined;
if (!cursor) break;
}
return ids;
}
export function useBoardState(boardId: string, uid: string) {
const base = `boards/${boardId}`;
const { data: board } = useQuery(base) as { data: Board | null | undefined };
const { data: listRows } = useQuery(`${base}/lists`, { limit: 1000 }) as { data: Row<List>[] | undefined };
// Live queries return 100 rows unless asked for more, and 1,000 at most: open cards only; archived ones load on demand.
const { data: cardRows } = useQuery(`${base}/cards`, { limit: 1000, filter: { archived: false } }) as { data: Row<Card>[] | undefined };
const { data: labelRows } = useQuery(`${base}/labels`, { limit: 1000 }) as { data: Row<Label>[] | undefined };
const { data: memberRows } = useQuery(`${base}/members`, { limit: 1000 }) as { data: Row<Member>[] | undefined };
const { data: join } = useQuery(`${base}/joins/${uid}`) as { data: { memberId?: string | null } | null | undefined };
const { data: owner } = useQuery(`${base}/owners/${uid}`) as { data: { at: number } | null | undefined };
const [patches, setPatches] = useState<Record<string, CardPatch>>({});
const patchesRef = useRef(patches);
patchesRef.current = patches;
const lastAdded = useRef<Record<string, number>>({});
// A patch goes away once the live card shows the same values.
useEffect(() => {
if (!cardRows) return;
const done = Object.keys(patchesRef.current).filter((id) => {
const live = cardRows.find((c) => c.id === id);
const p = patchesRef.current[id];
return live && Object.entries(p).every(([k, v]) => (live as any)[k] === v);
});
if (done.length) setPatches((p) => Object.fromEntries(Object.entries(p).filter(([id]) => !done.includes(id))));
}, [cardRows]);
const lists = useMemo(() => [...(listRows || [])].sort(byPos), [listRows]);
const cards = useMemo(() => (cardRows || []).map((c) => (patches[c.id] ? { ...c, ...patches[c.id] } : c)).filter((c) => !c.archived).sort(byPos), [cardRows, patches]);
const labels = useMemo(() => [...(labelRows || [])].sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1)), [labelRows]);
const members = useMemo(() => [...(memberRows || [])].sort((a, b) => a.name.localeCompare(b.name)), [memberRows]);
const openLists = useMemo(() => lists.filter((l) => !l.archived), [lists]);
const meId = join?.memberId && members.some((m) => m.id === join.memberId && !m.removed) ? join.memberId : null;
const myMember = members.find((m) => m.id === meId) || null;
const fail = (err: unknown) => toast(explain(err));
const patchCard = (id: string, fields: CardPatch, extra: Write[] = []) => {
setPatches((p) => ({ ...p, [id]: { ...p[id], ...fields } }));
const drop = () => setPatches((p) => {
const { [id]: _gone, ...rest } = p;
return rest;
});
const op = extra.length ? setMany([...extra, { path: `${base}/cards/${id}`, document: fields }]) : set(`${base}/cards/${id}`, fields);
return op.then(() => { window.setTimeout(drop, 4000); }, (err) => { drop(); fail(err); });
};
const actions = {
updateCard: (id: string, fields: CardPatch) => patchCard(id, fields),
/** Moves a card to `index` among the open cards of a list (index counts without the card itself). */
moveCard(id: string, listId: string, index: number) {
const target = cards.filter((c) => c.listId === listId && c.id !== id);
const i = Math.max(0, Math.min(index, target.length));
const before = target[i - 1]?.pos;
const after = target[i]?.pos;
if (before != null && after != null && tooClose(before, after)) {
const extra = target.map((c, j) => ({ path: `${base}/cards/${c.id}`, document: { pos: (j + (j >= i ? 2 : 1)) * 1024 } }));
return patchCard(id, { listId, pos: (i + 1) * 1024 }, extra);
}
return patchCard(id, { listId, pos: posBetween(before, after) });
},
async addCard(listId: string, title: string, extra: CardPatch = {}, at: "top" | "bottom" = "bottom") {
const inList = cards.filter((c) => c.listId === listId);
// Cards added quickly in a row may not be back from the server yet, so remember the last place used.
const last = Math.max(inList[inList.length - 1]?.pos ?? -Infinity, lastAdded.current[listId] ?? -Infinity);
const pos = at === "top" ? posBetween(undefined, inList[0]?.pos) : posBetween(Number.isFinite(last) ? last : undefined, undefined);
if (at === "bottom") lastAdded.current[listId] = pos;
const id = randomId(12);
try {
await set(`${base}/cards/${id}`, {
listId, title, description: "", pos, due: "", done: false, labels: "[]", assignees: "[]", checklist: "[]", cover: "", archived: false,
...extra, createdBy: uid, createdAt: NOW(),
});
return id;
} catch (err) {
fail(err);
return null;
}
},
async deleteCard(id: string) {
try {
// The card's comments go with it, 100 writes a batch with the card first (the policy lets anyone on the board delete comments of a deleted card).
const comments = ((await get(`${base}/comments`, { filter: { cardId: id }, limit: 1000 })) as { data?: { id: string }[] } | null)?.data?.map((c) => c.id) ?? [];
for (const b of chunk([`${base}/cards/${id}`, ...comments.map((c) => `${base}/comments/${c}`)], 100)) await setMany(b.map((path) => ({ path, document: null })));
toast("Card deleted.");
} catch (err) {
fail(err);
}
},
async addList(title: string) {
const last = Math.max(openLists[openLists.length - 1]?.pos ?? -Infinity, lastAdded.current[""] ?? -Infinity);
const pos = posBetween(Number.isFinite(last) ? last : undefined, undefined);
lastAdded.current[""] = pos;
try {
await set(`${base}/lists/${randomId(10)}`, { title, pos, archived: false, createdAt: NOW() });
} catch (err) { fail(err); }
},
updateList: (id: string, fields: Partial<Pick<List, "title" | "archived" | "pos">>) => set(`${base}/lists/${id}`, fields).catch(fail),
moveList(id: string, index: number) {
const others = openLists.filter((l) => l.id !== id);
const i = Math.max(0, Math.min(index, others.length));
const before = others[i - 1]?.pos;
const after = others[i]?.pos;
if (before != null && after != null && tooClose(before, after)) {
return setMany([
...others.map((l, j) => ({ path: `${base}/lists/${l.id}`, document: { pos: (j + (j >= i ? 2 : 1)) * 1024 } })),
{ path: `${base}/lists/${id}`, document: { pos: (i + 1) * 1024 } },
]).catch(fail);
}
return set(`${base}/lists/${id}`, { pos: posBetween(before, after) }).catch(fail);
},
async addLabel(name: string, color?: string) {
const id = randomId(10);
try {
await set(`${base}/labels/${id}`, { name, color: color || COLOR_IDS[labels.length % COLOR_IDS.length], createdAt: NOW() });
return id;
} catch (err) { fail(err); return null; }
},
updateLabel: (id: string, fields: Partial<Pick<Label, "name" | "color">>) => set(`${base}/labels/${id}`, fields).catch(fail),
deleteLabel: (id: string) => set(`${base}/labels/${id}`, null).catch(fail),
async addMember(name: string) {
const clash = members.find((m) => !m.removed && m.name.toLowerCase() === name.toLowerCase());
if (clash) return clash.id;
const id = randomId(10);
try {
await set(`${base}/members/${id}`, { name, removed: false, createdAt: NOW() });
return id;
} catch (err) { fail(err); return null; }
},
renameMember: (id: string, name: string) => set(`${base}/members/${id}`, { name }).catch(fail),
/** Says which person this device is, so comments carry the name and the owner can see who has access. */
setMe: (memberId: string | null) => set(`${base}/joins/${uid}`, { memberId }).catch(fail),
async addComment(cardId: string, text: string, name: string) {
try {
await set(`${base}/comments/${randomId(14)}`, { cardId, text, by: uid, name, createdAt: NOW() });
return true;
} catch (err) { fail(err); return false; }
},
editComment: (id: string, text: string) => set(`${base}/comments/${id}`, { text, editedAt: NOW() }).catch(fail),
deleteComment: (id: string) => set(`${base}/comments/${id}`, null).catch(fail),
// Owner actions (the admin link). The policy refuses them for anyone else.
renameBoard: (title: string) => set(base, { title }).then(() => rememberBoard({ id: boardId, title })),
setBoardArchived: (archived: boolean) => set(base, { archived }),
/** Removes a name from the board, and every device that picked it. Those devices cannot open the board again. */
async removeMember(memberId: string, joins: { id: string; memberId?: string | null }[]) {
const devices = joins.filter((j) => j.memberId === memberId && j.id !== uid);
await setMany([
{ path: `${base}/members/${memberId}`, document: { removed: true } },
...devices.map((j) => ({ path: `${base}/joins/${j.id}`, document: null })),
...devices.map((j) => ({ path: `${base}/blocks/${j.id}`, document: { at: NOW() } })),
]);
return devices.length;
},
async removeDevice(userId: string) {
await setMany([{ path: `${base}/joins/${userId}`, document: null }, { path: `${base}/blocks/${userId}`, document: { at: NOW() } }]);
},
unblock: (userId: string) => set(`${base}/blocks/${userId}`, null),
/** Deletes the board and everything on it. The board goes first, so nothing on it can be read from that moment.
* It goes on its own, before any device's access, so open pages still receive the delete and say so. */
async deleteBoard() {
const children: string[] = [];
for (const c of ["cards", "comments", "lists", "labels", "members", "joins", "blocks", "owners"]) {
for (const id of await allIds(`${base}/${c}`)) if (!((c === "joins" || c === "owners") && id === uid)) children.push(`${base}/${c}/${id}`);
}
await setMany([{ path: base, document: null }]);
for (const b of chunk(children, 100)) await setMany(b.map((path) => ({ path, document: null })));
await setMany([{ path: `${base}/joins/${uid}`, document: null }, { path: `${base}/owners/${uid}`, document: null }]);
forgetBoard(boardId);
},
};
const ready = board !== undefined && listRows !== undefined && cardRows !== undefined && labelRows !== undefined && memberRows !== undefined;
return {
boardId, uid, base, ready, board, lists, openLists, cards, labels, members, meId, myMember,
isOwner: Boolean(owner), actions,
labelOf: (id: string) => labels.find((l) => l.id === id),
memberOf: (id: string) => members.find((m) => m.id === id),
cardLabels: (c: Card) => parseIds(c.labels).map((id) => labels.find((l) => l.id === id)).filter((l): l is Row<Label> => Boolean(l)),
cardPeople: (c: Card) => parseIds(c.assignees).map((id) => members.find((m) => m.id === id)).filter((m): m is Row<Member> => Boolean(m) && !m!.removed),
};
}
export type BoardState = ReturnType<typeof useBoardState>;
export const BoardContext = createContext<BoardState | null>(null);
export function useBoard(): BoardState {
const b = useContext(BoardContext);
if (!b) throw new Error("useBoard outside a board");
return b;
}
/** Archived cards, loaded only when someone opens the archive. */
export function useArchivedCards(boardId: string, on: boolean) {
const { data } = useQuery(on ? `boards/${boardId}/cards` : null, { limit: 1000, filter: { archived: true } }) as { data: Row<Card>[] | undefined };
return data;
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync