Code Inbox

index.jsx 533 lines

Files · 8
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533import "./style.css";
import React, { useState, useEffect } from "react";
import { FormActions, ConfirmDelete } from "../../shared/forms.jsx";
import { useOperations } from "../../shared/data.js";
import { createRoot } from "react-dom/client";
import {
  Page,
  Header,
  Button,
  Modal,
  Field,
  EmptyState,
  Stats,
  Search,
  ErrorNotice,
} from "../../shared/ui.jsx";
import { nullable } from "../../shared/format.js";
import { createCodeInbox, samples } from "./model.js";
const L = window.lattice,
  store = createCodeInbox(L),
  reasons = [
    "mention",
    "review_requested",
    "assign",
    "subscribed",
    "author",
    "comment",
    "ci_activity",
    "invitation",
    "manual",
    "security_alert",
    "state_change",
    "team_mention",
  ],
  types = [
    "Issue",
    "PullRequest",
    "Commit",
    "Release",
    "Discussion",
    "RepositoryInvitation",
    "CheckSuite",
  ];
const pretty = (s) => s.replaceAll("_", " "),
  date = (s) =>
    new Date(s).toLocaleString(undefined, {
      month: "short",
      day: "numeric",
      hour: "2-digit",
      minute: "2-digit",
    });
function Editor({ thread, onClose, onSave, busy, error }) {
  const [f, set] = useState(
    thread || {
      unread: 1,
      subject_type: "Issue",
      reason: "mention",
      updated_at: new Date().toISOString(),
    },
  );
  const input = (key, label, type = "text", hint) => (
    <Field label={label} key={key} hint={hint}>
      <input
        required={[
          "id",
          "repository_full_name",
          "subject_title",
          "updated_at",
          "url",
        ].includes(key)}
        readOnly={key === "id" && !!thread}
        type={type}
        value={f[key] ?? ""}
        onChange={(e) => set({ ...f, [key]: e.target.value })}
      />
    </Field>
  );
  return (
    <Modal
      title={thread ? "Edit notification" : "New notification"}
      onClose={onClose}
    >
      <ErrorNotice error={error} />
      <form
        onSubmit={(e) => {
          e.preventDefault();
          const data = Object.fromEntries(
            [
              "id",
              "repository_full_name",
              "repository_html_url",
              "subject_title",
              "subject_type",
              "subject_url",
              "subject_latest_comment_url",
              "reason",
              "updated_at",
              "last_read_at",
              "url",
              "subscription_url",
            ].map((k) => [k, nullable(f[k])]),
          );
          data.unread = Number(f.unread);
          if (!data.unread && !data.last_read_at)
            data.last_read_at = new Date().toISOString();
          onSave(data);
        }}
      >
        <div className="ui-form-grid">
          {!thread &&
            input(
              "id",
              "Source thread ID",
              "text",
              "Kept as text, including leading zeros",
            )}
          {input(
            "repository_full_name",
            "Repository",
            "text",
            "owner/repository",
          )}
          <Field label="Subject title" wide>
            <input
              required
              autoFocus
              value={f.subject_title || ""}
              onChange={(e) => set({ ...f, subject_title: e.target.value })}
            />
          </Field>
          <Field label="Subject type">
            <input
              required
              list="subject-types"
              value={f.subject_type || ""}
              onChange={(e) => set({ ...f, subject_type: e.target.value })}
            />
          </Field>
          <datalist id="subject-types">
            {types.map((t) => (
              <option key={t}>{t}</option>
            ))}
          </datalist>
          <Field label="Reason">
            <input
              required
              list="thread-reasons"
              value={f.reason || ""}
              onChange={(e) => set({ ...f, reason: e.target.value })}
            />
          </Field>
          <datalist id="thread-reasons">
            {reasons.map((t) => (
              <option key={t}>{t}</option>
            ))}
          </datalist>
          {input(
            "updated_at",
            "Updated at",
            "text",
            "ISO timestamp, e.g. 2026-10-15T10:00:00Z",
          )}
          {input("url", "Thread API URL", "url")}
          <Field label="Read status">
            <select
              value={f.unread}
              onChange={(e) => set({ ...f, unread: Number(e.target.value) })}
            >
              <option value="1">Unread</option>
              <option value="0">Read</option>
            </select>
          </Field>
          {input(
            "last_read_at",
            "Last read at",
            "text",
            "ISO timestamp, or blank",
          )}
        </div>
        <details>
          <summary>Source details</summary>
          <div className="ui-form-grid">
            {input("repository_html_url", "Repository browser URL", "url")}
            {input("subject_url", "Subject API URL", "url")}
            {input(
              "subject_latest_comment_url",
              "Latest comment API URL",
              "url",
            )}
            {input("subscription_url", "Subscription API URL", "url")}
          </div>
        </details>
        <FormActions busy={busy} onCancel={onClose} label="Save notification" />
      </form>
    </Modal>
  );
}
function App() {
  const [threads, setThreads] = useState([]),
    [loading, setLoading] = useState(true),
    [search, setSearch] = useState(""),
    [filter, setFilter] = useState("All"),
    [reason, setReason] = useState("All reasons"),
    [editing, setEditing] = useState(null),
    [viewing, setViewing] = useState(null),
    [deleting, setDeleting] = useState(null),
    [importing, setImporting] = useState(false),
    [json, setJson] = useState(""),
    [status, setStatus] = useState("");
  const load = async () => {
    setThreads(await store.list());
    setLoading(false);
  };
  const { busy, error, setError, act } = useOperations(load);
  useEffect(() => {
    load().catch((e) => {
      setError(e.message);
      setLoading(false);
    });
    L.events.listen("receive-thread", async (d) => {
      try {
        await store.receive(d);
        await load();
      } catch (e) {
        setError(e.message);
        throw e;
      }
    });
  }, []);
  const unread = threads.filter((t) => t.unread),
    filtered = threads.filter(
      (t) =>
        (filter === "All" ||
          (filter === "Unread" && t.unread) ||
          (filter === "Read" && !t.unread)) &&
        (reason === "All reasons" || t.reason === reason) &&
        `${t.repository_full_name} ${t.subject_title} ${t.reason} ${t.subject_type}`
          .toLowerCase()
          .includes(search.toLowerCase()),
    );
  const save = (d) =>
    act(async () => {
      const old = editing === "new" ? null : editing;
      await store.save(d, old?.id);
      setEditing(null);
    });
  return (
    <Page className="record-page">
      <Header
        title="Code Inbox"
        subtitle="Code reviews, mentions, and repository updates."
        actions={
          <>
            {threads.length === 0 && (
              <Button
                disabled={busy}
                onClick={() => act(() => store.import(samples))}
              >
                Add sample data
              </Button>
            )}
            <Button
              onClick={() => {
                setJson("");
                setImporting(true);
                setStatus("");
              }}
            >
              Import JSON
            </Button>
            <Button variant="primary" onClick={() => setEditing("new")}>
              Add thread
            </Button>
          </>
        }
      />
      <ErrorNotice error={error} clear={() => setError("")} />
      {status && (
        <p
          role="status"
          className="ui-badge ui-badge-success"
          style={{ marginBottom: 16 }}
        >
          {status}
        </p>
      )}
      <Stats
        items={[
          [unread.length, "Unread threads"],
          [
            threads.filter((t) => !!t.unread && t.reason === "review_requested")
              .length,
            "Review requests",
          ],
          [
            new Set(threads.map((t) => t.repository_full_name)).size,
            "Repositories",
          ],
        ]}
      />
      <div className="ui-toolbar">
        <Search
          value={search}
          onChange={setSearch}
          placeholder="Search repository, title, or reason"
        />
        <div className="ui-actions">
          <div className="ui-tabs">
            {["All", "Unread", "Read"].map((f) => (
              <Button
                key={f}
                aria-pressed={filter === f}
                onClick={() => setFilter(f)}
              >
                {f}
              </Button>
            ))}
          </div>
          <select
            aria-label="Filter by reason"
            value={reason}
            onChange={(e) => setReason(e.target.value)}
          >
            <option>All reasons</option>
            {[...new Set(threads.map((t) => t.reason))].sort().map((r) => (
              <option value={r} key={r}>
                {pretty(r)}
              </option>
            ))}
          </select>
        </div>
      </div>
      <div className="ui-list-header">
        <h2>Notifications</h2>
        <small>
          {filtered.length} {filtered.length === 1 ? "thread" : "threads"}
        </small>
      </div>
      {loading ? (
        <p className="ui-loading">Loading notifications…</p>
      ) : filtered.length ? (
        <div className="ui-panel">
          {filtered.map((t) => (
            <article
              className={`thread ${!t.unread ? "thread-read" : ""}`}
              key={t.id}
            >
              <div className="thread-icon" aria-hidden="true">
                {t.subject_type === "PullRequest"
                  ? "⑂"
                  : t.subject_type === "Release"
                    ? "◇"
                    : "◉"}
              </div>
              <div>
                <div className="repo">
                  {!!t.unread && <span className="dot" aria-label="Unread" />}
                  {t.repository_full_name}
                </div>
                <h3>
                  <Button onClick={() => setViewing(t)}>
                    {t.subject_title}
                  </Button>
                </h3>
                <div className="card-meta">
                  <span
                    className={`ui-badge ${t.reason === "review_requested" || t.reason === "mention" ? "ui-badge-accent" : ""}`}
                  >
                    {pretty(t.reason)}
                  </span>
                  <span className="ui-badge">{t.subject_type}</span>
                  {!t.unread && (
                    <span className="ui-badge ui-badge-success">Read</span>
                  )}
                </div>
              </div>
              <div className="thread-aside">
                <time dateTime={t.updated_at}>{date(t.updated_at)}</time>
                <div className="ui-actions">
                  {!!t.unread && (
                    <Button
                      disabled={busy}
                      aria-label={`Mark ${t.subject_title} as read`}
                      onClick={() => act(() => store.read(t.id))}
                    >
                      Mark as read
                    </Button>
                  )}
                  <Button
                    aria-label={`Edit ${t.subject_title}`}
                    onClick={() => setEditing(t)}
                  >
                    Edit
                  </Button>
                  <Button
                    aria-label={`Delete ${t.subject_title}`}
                    onClick={() => setDeleting(t)}
                  >
                    Delete
                  </Button>
                </div>
              </div>
            </article>
          ))}
        </div>
      ) : (
        <EmptyState
          title={
            threads.length ? "No matching notifications" : "Your inbox is empty"
          }
        >
          {threads.length
            ? "Try a different search or filter."
            : "Import GitHub notifications or add a thread."}
        </EmptyState>
      )}
      {importing && (
        <Modal title="Import notifications" onClose={() => setImporting(false)}>
          <ErrorNotice error={error} clear={() => setError("")} />
          <p className="ui-muted">
            Paste GitHub notifications JSON. Existing threads will be updated.
          </p>
          <form
            onSubmit={(e) => {
              e.preventDefault();
              act(async () => {
                const count = await store.import(JSON.parse(json));
                setImporting(false);
                setStatus(
                  `Imported ${count} ${count === 1 ? "notification" : "notifications"}.`,
                );
              });
            }}
          >
            <Field label="Notifications JSON" wide>
              <textarea
                autoFocus
                required
                className="ui-mono"
                rows="13"
                value={json}
                onChange={(e) => setJson(e.target.value)}
                placeholder='[{"id": "123", "repository": {"full_name": "owner/repo"}, …}]'
              />
            </Field>
            <FormActions
              busy={busy}
              onCancel={() => setImporting(false)}
              label="Import"
            />
          </form>
        </Modal>
      )}
      {editing && (
        <Editor
          thread={editing === "new" ? null : editing}
          onSave={save}
          onClose={() => setEditing(null)}
          busy={busy}
          error={error}
        />
      )}
      {viewing && (
        <Modal title={viewing.subject_title} onClose={() => setViewing(null)}>
          <div className="ui-actions">
            <span className="ui-badge ui-badge-accent">
              {pretty(viewing.reason)}
            </span>
            <span className="ui-badge">{viewing.subject_type}</span>
          </div>
          <dl className="ui-detail-list">
            {[
              ["Repository", viewing.repository_full_name],
              ["Updated", viewing.updated_at],
              ["Last read", viewing.last_read_at || "Never"],
              ["Repository browser URL", viewing.repository_html_url],
              ["Subject API URL", viewing.subject_url],
              ["Latest comment API URL", viewing.subject_latest_comment_url],
              ["Thread API URL", viewing.url],
              ["Subscription API URL", viewing.subscription_url],
            ]
              .filter(([, v]) => v)
              .map(([k, v]) => (
                <div key={k}>
                  <dt>{k}</dt>
                  <dd className={k.includes("URL") ? "ui-mono" : ""}>{v}</dd>
                </div>
              ))}
          </dl>
          <div className="ui-form-actions">
            {!!viewing.unread && (
              <Button
                disabled={busy}
                onClick={() =>
                  act(async () => {
                    await store.read(viewing.id);
                    setViewing(null);
                  })
                }
              >
                Mark as read
              </Button>
            )}
            <Button
              variant="primary"
              onClick={() => {
                setEditing(viewing);
                setViewing(null);
              }}
            >
              Edit
            </Button>
          </div>
        </Modal>
      )}
      {deleting && (
        <ConfirmDelete
          title="Delete notification?"
          children={`Remove “${deleting.subject_title}”?`}
          busy={busy}
          onClose={() => setDeleting(null)}
          onConfirm={() =>
            act(async () => {
              await store.remove(deleting.id);
              setDeleting(null);
            })
          }
        />
      )}
    </Page>
  );
}
createRoot(document.getElementById("root")).render(<App />);