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