Files
work-app/src/components/Settings.tsx
T
Vincent 8f989fb8f6 Keep background apps alive, and notify from their unread count
The report was that a backgrounded site dies slowly and stops fetching.
It did, for three separate reasons, and the first two fixes each traded
one failure for another:

Hiding inactive views lets WebKit suspend them, so nothing is hidden any
more - every view keeps its size and stays in the window, and the active
one is ordered on top.

Telling a background page it was hidden then made Gmail throttle its own
syncing; its unread count sat unchanged for two and a half minutes. That
spoof is gone.

Neither was enough on its own: a background page keeps its timers but
loses the connection its updates arrive on, and WKWebView has no
equivalent of Electron's backgroundThrottling. Background apps are now
poked every 45s with the events a page uses to catch up after you return
to a tab - it fetches without losing a half-written reply.

Notifications now come from the unread count in the title rather than
the site's notification code, which Gmail will not run while it believes
you are looking at it. Measured: Gmail behind Odoo went 155 -> 157
untouched and raised "Gmail / 1 new".

Also adds a background-app probe to Settings, which is what turned this
from guesswork into measurement, and drops the nav's blocked-element
count and the hide-element button.
2026-09-01 14:10:31 +02:00

384 lines
15 KiB
TypeScript

import { useEffect, useState } from "react";
import * as api from "../api";
import type { Theme } from "../hooks/useAppearance";
import type { Config, Group, WorkApp } from "../types";
import { Trash } from "./icons";
import {
BTN,
BTN_PRIMARY,
Badge,
Dialog,
Favicon,
HELP,
ICON_CHROME,
INPUT,
LABEL,
SUBPANEL,
SectionHeading,
Segmented,
} from "./ui";
interface Props {
config: Config;
/** The app a test notification is fired from. */
activeId: string | null;
theme: Theme;
/** Opens straight onto the hidden-elements section when set. */
focusHiddenFor?: string | null;
onConfig: (c: Config) => void;
onTheme: (t: Theme) => void;
onClose: () => void;
}
export default function Settings({
config, theme, activeId, focusHiddenFor, onConfig, onTheme, onClose,
}: Props) {
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [groupId, setGroupId] = useState("");
const [groupName, setGroupName] = useState("");
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<WorkApp | null>(null);
const [notifyStatus, setNotifyStatus] = useState<string | null>(null);
const [reports, setReports] = useState<[string, string][]>([]);
const groups = [...config.groups].sort((a, b) => a.order - b.order);
// Same order the nav shows, so the two lists never disagree about position.
const groupRank = (id: string | null) =>
id === null ? -1 : (groups.find((g) => g.id === id)?.order ?? Number.MAX_SAFE_INTEGER);
const orderedApps = [...config.apps].sort(
(a, b) => groupRank(a.groupId) - groupRank(b.groupId) || a.order - b.order,
);
useEffect(() => {
if (!focusHiddenFor) return;
document.getElementById("hidden-elements")?.scrollIntoView({ behavior: "smooth" });
}, [focusHiddenFor]);
const run = async (fn: () => Promise<Config>) => {
try {
onConfig(await fn());
setError(null);
} catch (e) {
setError(String(e));
}
};
const addApp = async () => {
if (!name.trim() || !url.trim()) return;
await run(() => api.addApp(name, url, groupId || null));
setName("");
setUrl("");
};
const addGroup = async () => {
if (!groupName.trim()) return;
await run(() => api.addGroup(groupName));
setGroupName("");
};
const patch = (app: WorkApp, fields: Partial<WorkApp>) =>
run(() => api.updateApp({ ...app, ...fields }));
const withHidden = orderedApps.filter((a) => a.hidden.length > 0);
return (
<>
<Dialog title="Settings" onCancel={onClose} wide footer={<div />}>
<div className="space-y-6">
{error && (
<p className="rounded-lg bg-red-500/10 px-3 py-2 text-[12px] text-red-600 dark:text-red-400">
{error}
</p>
)}
{/* ----------------------------------------------------- apps */}
<section className="space-y-2">
<SectionHeading>Apps</SectionHeading>
<div className="space-y-1.5">
{config.apps.length === 0 && <p className={HELP}>Nothing yet. Add the first one below.</p>}
{orderedApps.map((app) => (
<div
key={app.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
>
<Favicon url={app.url} name={app.name} />
<input
value={app.name}
onChange={(e) => patch(app, { name: e.target.value })}
className={`${INPUT} h-[26px] w-full flex-1`}
/>
<input
value={app.url}
onChange={(e) => patch(app, { url: e.target.value })}
title="Changing this rebuilds the app's view"
className={`${INPUT} h-[26px] w-full flex-[1.4] font-mono text-[11px]`}
/>
<select
value={app.groupId ?? ""}
onChange={(e) => patch(app, { groupId: e.target.value || null })}
className={`${INPUT} h-[26px] w-[110px] shrink-0 cursor-pointer`}
>
<option value="">No group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
<button
onClick={() => setConfirmDelete(app)}
title={`Remove ${app.name}`}
className={`${ICON_CHROME} hover:text-red-500!`}
>
<Trash />
</button>
</div>
))}
</div>
<div className={`${SUBPANEL} flex items-end gap-2`}>
<label className="flex-1 space-y-1">
<span className={LABEL}>Name</span>
<input value={name} onChange={(e) => setName(e.target.value)}
placeholder="Odoo" className={`${INPUT} w-full`} />
</label>
<label className="flex-[1.4] space-y-1">
<span className={LABEL}>URL</span>
<input value={url} onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addApp()}
placeholder="example.com" className={`${INPUT} w-full font-mono text-[11px]`} />
</label>
<label className="w-[110px] space-y-1">
<span className={LABEL}>Group</span>
<select value={groupId} onChange={(e) => setGroupId(e.target.value)}
className={`${INPUT} w-full cursor-pointer`}>
<option value="">No group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</label>
<button onClick={addApp} className={BTN_PRIMARY}>Add</button>
</div>
<p className={HELP}>
An app owns its URL's host. Links to any other app on this list switch to it;
everything else opens in your browser.
</p>
</section>
{/* ------------------------------------------- hidden elements */}
<section id="hidden-elements" className="space-y-2">
<SectionHeading>Hidden elements</SectionHeading>
{withHidden.length === 0 ? (
<p className={HELP}>
Nothing hidden. Right-click anything in an app and choose <em>Hide this
element</em> or use the eye button in the nav to pick one.
</p>
) : (
<div className="space-y-3">
{withHidden.map((app) => (
<div key={app.id} className={SUBPANEL}>
<div className="mb-2 flex items-center gap-2">
<Favicon url={app.url} name={app.name} />
<span className="text-[12px] font-medium">{app.name}</span>
<Badge tone="accent">{app.hidden.length}</Badge>
</div>
<div className="space-y-1">
{app.hidden.map((sel, i) => (
<div key={i} className="flex items-center gap-2">
<input
value={sel}
onChange={(e) => {
const next = [...app.hidden];
next[i] = e.target.value;
run(() => api.setHidden(app.id, next));
}}
title="Edit the selector to widen or narrow what it hides"
className={`${INPUT} h-[26px] w-full flex-1 font-mono text-[11px]`}
/>
<button
onClick={() =>
run(() => api.setHidden(app.id, app.hidden.filter((_, j) => j !== i)))
}
title="Show this again"
className={`${ICON_CHROME} hover:text-red-500!`}
>
<Trash />
</button>
</div>
))}
</div>
</div>
))}
</div>
)}
<p className={HELP}>
Changes apply immediately, without a reload. A selector that stops matching
after the site changes simply hides nothing.
</p>
</section>
{/* ------------------------------------------------------ zoom */}
<section className="space-y-2">
<SectionHeading>Zoom</SectionHeading>
<div className="space-y-1.5">
{orderedApps.map((app) => (
<div
key={app.id}
className="flex items-center gap-3 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
>
<Favicon url={app.url} name={app.name} />
<span className="w-28 shrink-0 truncate text-[12px]">{app.name}</span>
<input
type="range"
min={50}
max={200}
step={5}
value={Math.round((app.zoom ?? 1) * 100)}
onChange={(e) =>
run(() => api.setZoom(app.id, Number(e.target.value) / 100))
}
className="min-w-0 flex-1 cursor-pointer accent-sky-500"
/>
<span className="w-12 shrink-0 text-right font-mono text-[11px] tabular-nums text-slate-500 dark:text-slate-400">
{Math.round((app.zoom ?? 1) * 100)}%
</span>
</div>
))}
</div>
<p className={HELP}>
+ and ⌘− zoom the app you are in; 0 puts it back to 100%. Each app keeps
its own size.
</p>
</section>
{/* --------------------------------------------- notifications */}
<section className="space-y-2">
<SectionHeading>Notifications</SectionHeading>
<div className="flex items-center gap-2">
<button
onClick={() => activeId && api.testNotification(activeId)}
disabled={!activeId}
className={BTN}
>
Send a test notification
</button>
<button
onClick={async () => setNotifyStatus(await api.notificationStatus())}
className={BTN}
>
Check permission
</button>
<button
onClick={async () => {
await api.probeApps();
await new Promise((r) => setTimeout(r, 500));
setReports(await api.appReports());
}}
className={BTN}
>
Check background apps
</button>
</div>
{reports.length > 0 && (
<div className="space-y-0.5">
{reports.map(([name, report]) => (
<p key={name} className={`${HELP} font-mono`}>
<span className="font-semibold">{name}</span> · {report}
</p>
))}
</div>
)}
<div className="hidden">
</div>
{notifyStatus && (
<p className={`${HELP} font-mono`}>{notifyStatus}</p>
)}
<p className={HELP}>
WKWebView defines a notification API that silently does nothing, so it is
replaced with one that forwards to macOS. Notifications raised by a service
worker in the background are not covered only those a page raises while open.
</p>
</section>
{/* ---------------------------------------------------- groups */}
<section className="space-y-2">
<SectionHeading>Groups</SectionHeading>
<div className="space-y-1.5">
{groups.length === 0 && <p className={HELP}>No groups yet.</p>}
{groups.map((g: Group) => (
<div key={g.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800">
<input
value={g.name}
onChange={(e) => run(() => api.updateGroup({ ...g, name: e.target.value }))}
className={`${INPUT} h-[26px] w-full flex-1`}
/>
<span className="shrink-0 font-mono text-[10px] text-slate-400">
{config.apps.filter((a) => a.groupId === g.id).length} apps
</span>
<button
onClick={() => run(() => api.deleteGroup(g.id))}
title={`Delete ${g.name} — its apps stay, ungrouped`}
className={`${ICON_CHROME} hover:text-red-500!`}
>
<Trash />
</button>
</div>
))}
</div>
<div className={`${SUBPANEL} flex items-end gap-2`}>
<label className="flex-1 space-y-1">
<span className={LABEL}>New group</span>
<input value={groupName} onChange={(e) => setGroupName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addGroup()}
placeholder="Finance" className={`${INPUT} w-full`} />
</label>
<button onClick={addGroup} className={BTN}>Add group</button>
</div>
</section>
{/* ----------------------------------------------- appearance */}
<section className="space-y-2">
<SectionHeading>Appearance</SectionHeading>
<Segmented<Theme>
value={theme}
onChange={onTheme}
options={[
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
]}
/>
</section>
<div className="flex justify-end border-t border-slate-200 pt-4 dark:border-slate-800">
<button onClick={onClose} className={BTN}>Done</button>
</div>
</div>
</Dialog>
{confirmDelete && (
<Dialog
title={`Remove ${confirmDelete.name}?`}
onCancel={() => setConfirmDelete(null)}
onConfirm={async () => {
await run(() => api.deleteApp(confirmDelete.id));
setConfirmDelete(null);
}}
confirmLabel="Remove"
destructive
>
It disappears from the nav and its view is closed. The session it holds for{" "}
<span className="font-mono text-[12px]">{confirmDelete.url}</span> is kept, so
adding it back does not mean signing in again.
</Dialog>
)}
</>
);
}