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/App.tsx
// The app's pages: your lists, a list (opening its share link joins it), and the admin link.
import { useEffect, useState } from "react";
import { get } from "@bounded-sh/client";
import { me, explain } from "./lib/bounded";
import { navigate } from "./lib/router";
import { useMine, joinList, claimAdmin, remember } from "./lib/data";
import { ListPage } from "./ListPage";
import { NewList } from "./Start";
import { toast } from "./ui";
import type { ListDoc } from "./lib/data";

export default function App({ path }: { path: string }) {
  const m = path.match(/^\/l\/([A-Za-z0-9]{20})(\/admin)?$/);
  if (!m) return <ListsPage />;
  if (m[2]) return <AdminLink key={m[1]} id={m[1]} />;
  return <ListGate key={m[1]} id={m[1]} />;
}

const go = (to: string) => (e: React.MouseEvent) => { e.preventDefault(); navigate(to); };

function ListsPage() {
  const mine = Object.values(useMine()).sort((a, b) => (b.opened || 0) - (a.opened || 0));
  useEffect(() => { document.title = "Your lists | Lists"; }, []);
  return (
    <div className="page narrow">
      <h1 className="page-title">Your lists</h1>
      {mine.length ? (
        <ul className="lists">
          {mine.map((l) => (
            <li key={l.id}>
              <a href={`/l/${l.id}`} onClick={go(`/l/${l.id}`)}>
                <span className="lists-name">{l.name}</span>
                <span className="lists-meta">{l.toGet ? `${l.toGet} ${l.kind === "todo" ? "left" : "to get"}` : "Nothing left"}{l.ownerKey ? " · yours" : ""}</span>
              </a>
            </li>
          ))}
        </ul>
      ) : (
        <div className="empty">
          <img className="art" src="/art/card-lists.webp" alt="" width="200" height="150" />
          <p className="muted">Lists you make or open on this device show up here. To open a list someone shared, use the link they sent.</p>
        </div>
      )}
      <section className="card new-card">
        <h2 className="h3">New list</h2>
        <NewList compact />
      </section>
    </div>
  );
}

/**
 * Opens a list. With a share link (#secret) it joins first; a list already on this device opens right away,
 * even offline. A share link opened online still joins on a device that has the list, so someone who was
 * removed and sent the new link gets back in.
 */
function ListGate({ id }: { id: string }) {
  const known = Boolean(useMine()[id]);
  const [secret] = useState(() => location.hash.slice(1));
  const [opensNow] = useState(() => known && !(/^[A-Za-z0-9]{24}$/.test(secret) && navigator.onLine));
  const [state, setState] = useState<"ready" | "joining" | "failed" | "missing">(opensNow ? "ready" : "joining");
  const [error, setError] = useState("");

  useEffect(() => {
    // The link's secret is only needed once: keep it out of the address bar and history.
    const done = () => { if (location.hash) history.replaceState(null, "", `/l/${id}`); };
    if (opensNow) return done();
    let on = true;
    (async () => {
      try {
        if (/^[A-Za-z0-9]{24}$/.test(secret)) {
          await joinList(id, secret);
        } else {
          // No link secret: this device may have joined before and lost its list of lists.
          const uid = await me();
          if (!(await get(`lists/${id}/members/${uid}`))) { if (on) setState("missing"); return; }
          const list = (await get(`lists/${id}`)) as ListDoc | null;
          remember(id, { name: list?.name || "Shared list", kind: list?.kind || "grocery", opened: Math.floor(Date.now() / 1000) });
        }
        done();
        if (on) setState("ready");
      } catch (err) {
        if (!on) return;
        // The device has this list: open its copy, and the list page says so if you are not on it any more.
        if (known) { done(); setState("ready"); return; }
        setError(err instanceof Error && !/offline/.test(explain(err)) ? err.message : explain(err));
        setState("failed");
      }
    })();
    return () => { on = false; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [id]);

  if (state === "ready") return <ListPage id={id} />;
  if (state === "joining") return <div className="page narrow"><div className="loading"><span className="spinner" /> Opening the list...</div></div>;
  return (
    <div className="page narrow">
      <h1 className="page-title">{state === "missing" ? "This list is not on this device" : "Could not open the list"}</h1>
      <p className="lede">{state === "missing" ? "Open the share link someone on the list sent you. It ends with a long code after a #." : error}</p>
      <a className="btn" href="/lists" onClick={go("/lists")}>Your lists</a>
    </div>
  );
}

function AdminLink({ id }: { id: string }) {
  const [error, setError] = useState("");
  useEffect(() => {
    const key = location.hash.slice(1);
    if (!/^[A-Za-z0-9]{24}$/.test(key)) { setError("This admin link is not complete. Copy all of it, including the part after the #."); return; }
    claimAdmin(id, key)
      .then(() => { navigate(`/l/${id}`, true); toast("You can manage this list on this device now."); })
      .catch((err) => setError(err instanceof Error && !/offline/.test(explain(err)) ? err.message : explain(err)));
  }, [id]);
  return (
    <div className="page narrow">
      {error ? (
        <>
          <h1 className="page-title">Could not open the admin link</h1>
          <p className="lede">{error}</p>
          <a className="btn" href="/lists" onClick={go("/lists")}>Your lists</a>
        </>
      ) : <div className="loading"><span className="spinner" /> Opening the admin link...</div>}
    </div>
  );
}

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