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 />);