Shared UI kit

data.js 177 lines

Files · 11
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177import { useCallback, useEffect, useRef, useState } from "react";

// One queue per mounted app keeps insert + last_insert_rowid() together even
// when UI writes and incoming events arrive at the same time.
export function useStore({ setup, load, listeners = {} }) {
  const [rows, setRows] = useState([]);
  const [ready, setReady] = useState(false);
  const [error, setError] = useState("");
  const [pending, setPending] = useState(0);
  const callbacks = useRef({ setup, load, listeners });
  callbacks.current = { setup, load, listeners };
  const queue = useRef(Promise.resolve());
  const mounted = useRef(false);
  const initialized = useRef(false);
  const enqueue = useCallback((work) => {
    const result = queue.current.then(async () => {
      setPending((n) => n + 1);
      setError("");
      try {
        if (!initialized.current) {
          await callbacks.current.setup();
          initialized.current = true;
        }
        const value = await work();
        const next = await callbacks.current.load();
        if (mounted.current) {
          setRows(next);
          setReady(true);
        }
        return value;
      } catch (err) {
        if (mounted.current) setError(err.message || String(err));
        throw err;
      } finally {
        if (mounted.current) setPending((n) => n - 1);
      }
    });
    queue.current = result.catch(() => {});
    return result;
  }, []);
  const act = useCallback(
    async (work) => {
      try {
        await enqueue(work);
        return true;
      } catch {
        return false;
      }
    },
    [enqueue],
  );
  useEffect(() => {
    mounted.current = true;
    act(async () => {});
    for (const name of Object.keys(callbacks.current.listeners)) {
      window.lattice.events.listen(name, (data, envelope) =>
        enqueue(() => callbacks.current.listeners[name](data, envelope)),
      );
    }
    // The host bridge has no unlisten API. Register once per app mount.
    return () => {
      mounted.current = false;
    };
  }, [act, enqueue]);
  return {
    rows,
    ready,
    error,
    busy: pending > 0,
    act,
  };
}

export async function inserted(table, sql, params) {
  await window.lattice.db.exec(sql, params);
  const rows = await window.lattice.db.query(
    `SELECT * FROM ${table} WHERE rowid = last_insert_rowid()`,
  );
  if (!rows[0])
    throw Error(
      "The saved item could not be read. Reload before trying again.",
    );
  return rows[0];
}
export const fieldsOf = (row, keys) =>
  Object.fromEntries(keys.map((key) => [key, row[key] ?? ""]));
export const tagsOf = (text) => [
  ...new Set(
    String(text || "")
      .split(",")
      .map((tag) => tag.trim())
      .filter(Boolean),
  ),
];
export function localDate(date = new Date()) {
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
export function displayTime(value) {
  if (!value) return "";
  const utc = /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(value)
    ? value.replace(" ", "T") + "Z"
    : value;
  const date = new Date(utc);
  return Number.isNaN(date.getTime())
    ? value
    : date.toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
}
export function httpUrl(value) {
  try {
    const url = new URL(value);
    return ["https:", "http:"].includes(url.protocol) ? url.href : null;
  } catch {
    return null;
  }
}

export function useModel(model) {
  const [data, setData] = useState(null),
    [error, setError] = useState(""),
    [busy, setBusy] = useState(false);
  useEffect(() => {
    let alive = true;
    const refresh = () =>
      model
        .load()
        .then((v) => {
          if (alive) setData(v);
        })
        .catch((e) => {
          if (alive) setError(e.message);
        });
    const off = model.subscribe(refresh);
    refresh();
    return () => {
      alive = false;
      off();
    };
  }, [model]);
  const act = async (fn) => {
    setBusy(true);
    setError("");
    try {
      await fn();
      return true;
    } catch (e) {
      setError(e.message);
      return false;
    } finally {
      setBusy(false);
    }
  };
  return { data, error, busy, act, clearError: () => setError("") };
}

export function useOperations(refresh) {
  const [busy, setBusy] = useState(false),
    [error, setError] = useState("");
  const guard = useRef(false);
  const act = async (task) => {
    if (guard.current) return false;
    guard.current = true;
    setBusy(true);
    setError("");
    try {
      await task();
      await refresh();
      return true;
    } catch (e) {
      setError(e.message || String(e));
      return false;
    } finally {
      guard.current = false;
      setBusy(false);
    }
  };
  return { busy, error, setError, act };
}