Notifications

index.jsx 254 lines

Files · 4
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254import React, { useState } from "react";
import { createRoot } from "react-dom/client";
import {
  Page,
  Header,
  Button,
  Input,
  Row,
  EmptyState,
  Toast,
  useToast,
} from "../../shared/ui.jsx";
import { ConfirmDelete } from "../../shared/forms.jsx";
import { useStore, inserted, displayTime } from "../../shared/data.js";
const L = window.lattice;
const appName = (value) =>
  value
    ? value
        .split(".")
        .at(-1)
        .replace(/(^|[-_])\w/g, (part) =>
          part.replace(/[-_]/, " ").toUpperCase(),
        )
    : "Other";
function App() {
  const [search, setSearch] = useState(""),
    [source, setSource] = useState(""),
    [sort, setSort] = useState("newest"),
    [deleting, setDeleting] = useState(null);
  const [deliveryError, setDeliveryError] = useState("");
  const { toast, notify, dismissToast } = useToast();
  const store = useStore({
    setup: () =>
      L.db.exec(
        `CREATE TABLE IF NOT EXISTS notifications (id INTEGER PRIMARY KEY AUTOINCREMENT,title TEXT NOT NULL,body TEXT DEFAULT '',source_app_id TEXT DEFAULT '',logged_at TEXT DEFAULT (datetime('now')))`,
      ),
    load: () =>
      L.db.query("SELECT * FROM notifications ORDER BY logged_at DESC,id DESC"),
    listeners: {
      "receive-notification": async (data, envelope) => {
        const title = data?.title || "Notification",
          body = data?.body || "",
          sourceApp = envelope?.source_app_id || "";
        const row = await inserted(
          "notifications",
          "INSERT INTO notifications (title,body,source_app_id) VALUES (?,?,?)",
          [title, body, sourceApp],
        );
        let desktopDelivered = true;
        try {
          await L.notify.send(title, body);
          setDeliveryError("");
        } catch {
          desktopDelivered = false;
          setDeliveryError(
            "Notification saved. Desktop delivery is unavailable.",
          );
        }
        await L.events.emit("notification-logged", {
          id: row.id,
          title,
          body,
          app_id: sourceApp,
          logged_at: row.logged_at,
          desktop_delivered: desktopDelivered,
        });
      },
    },
  });
  const query = search.trim().toLowerCase();
  const filtered = store.rows
    .filter(
      (row) =>
        (!source || row.source_app_id === source) &&
        [row.title, row.body, row.source_app_id].some((value) =>
          String(value || "")
            .toLowerCase()
            .includes(query),
        ),
    )
    .sort((a, b) =>
      sort === "oldest"
        ? a.logged_at.localeCompare(b.logged_at) || a.id - b.id
        : b.logged_at.localeCompare(a.logged_at) || b.id - a.id,
    );
  const isClear = deleting === "all";
  return (
    <Page>
      <Header
        title="Notifications"
        subtitle={`${store.rows.length} saved notifications`}
        actions={
          <Button
            disabled={!store.rows.length || store.busy}
            onClick={() => setDeleting("all")}
          >
            Clear all
          </Button>
        }
      />
      <div className="ui-toolbar">
        <Input
          type="search"
          aria-label="Search notifications"
          placeholder="Search notifications"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
        />
        <select
          aria-label="Filter by source"
          value={source}
          onChange={(e) => setSource(e.target.value)}
        >
          <option value="">All sources</option>
          {[
            ...new Set(
              store.rows.map((row) => row.source_app_id).filter(Boolean),
            ),
          ]
            .sort()
            .map((value) => (
              <option key={value} value={value}>
                {appName(value)}
              </option>
            ))}
        </select>
        <select
          aria-label="Sort notifications"
          value={sort}
          onChange={(e) => setSort(e.target.value)}
        >
          <option value="newest">Newest first</option>
          <option value="oldest">Oldest first</option>
        </select>
      </div>
      {deliveryError && (
        <p className="ui-muted" role="status" style={{ marginBottom: 12 }}>
          {deliveryError}
        </p>
      )}
      {store.error && (
        <p className="ui-error-text" role="alert">
          {store.error}{" "}
          <Button onClick={() => store.act(async () => {})}>Retry</Button>
        </p>
      )}
      <div className="ui-panel">
        {!store.ready ? (
          <EmptyState
            title={
              store.error
                ? "Notifications could not be loaded"
                : "Loading notifications…"
            }
          />
        ) : !filtered.length ? (
          <EmptyState
            title={
              store.rows.length
                ? "No matching notifications"
                : "No notifications yet"
            }
            action={
              store.rows.length ? (
                <Button
                  onClick={() => {
                    setSearch("");
                    setSource("");
                  }}
                >
                  Clear filters
                </Button>
              ) : null
            }
          >
            {!store.rows.length
              ? "Notifications from connected apps will appear here."
              : null}
          </EmptyState>
        ) : (
          filtered.map((row) => (
            <Row
              key={row.id}
              leading={
                <span className="ui-avatar">
                  {appName(row.source_app_id)[0]}
                </span>
              }
              title={row.title}
              actions={
                <Button
                  variant="ghost"
                  aria-label={`Delete ${row.title}`}
                  onClick={() => setDeleting(row)}
                >
                  Delete
                </Button>
              }
            >
              {row.body && (
                <p
                  className="ui-muted"
                  style={{ margin: "5px 0", whiteSpace: "pre-wrap" }}
                >
                  {row.body}
                </p>
              )}
              <div className="ui-actions">
                <span className="ui-badge">{appName(row.source_app_id)}</span>
                <time
                  className="ui-muted"
                  dateTime={row.logged_at?.replace(" ", "T") + "Z"}
                >
                  {displayTime(row.logged_at)}
                </time>
              </div>
            </Row>
          ))
        )}
      </div>
      {deleting && (
        <ConfirmDelete
          title={isClear ? "Clear all notifications?" : "Delete notification?"}
          busy={store.busy}
          error={store.error}
          onClose={() => setDeleting(null)}
          onConfirm={async () => {
            if (
              await store.act(() =>
                L.db.exec(
                  isClear
                    ? "DELETE FROM notifications"
                    : "DELETE FROM notifications WHERE id=?",
                  isClear ? [] : [deleting.id],
                ),
              )
            ) {
              setDeleting(null);
              notify(
                isClear ? "Notifications cleared" : "Notification deleted",
              );
            }
          }}
        >
          {isClear
            ? `All ${store.rows.length} notifications will be permanently deleted, including any hidden by filters.`
            : deleting.title}
        </ConfirmDelete>
      )}
      <Toast message={toast} onDismiss={dismissToast} />
    </Page>
  );
}
createRoot(document.getElementById("root")).render(<App />);