Make the app an installable PWA with phone layout and Web Push

Add a generated manifest, icon set, offline page and service worker that never caches authenticated HTML, plus install and update UI. Turn the rail into a drawer below md with container-query internals, dvh and safe-area handling, and reconnect the socket after visibility/network changes. Add Web Push subscriptions, presence-aware delivery, notification settings and tests.
This commit is contained in:
2026-10-19 17:12:16 +01:00
parent 61098a114c
commit dbfe48796b
100 changed files with 3573 additions and 117 deletions
@@ -28,14 +28,22 @@ type Props = {
* settings page from the title. Every list uses it, so the row behaves the
* same, and the open button shows it in the viewer over the page.
*/
export default function ComputerListRow({ computer, badge, subtitle, children, linked = true }: Props) {
export default function ComputerListRow({
computer,
badge,
subtitle,
children,
linked = true,
}: Props) {
const name = computer.name || `Computer ${computer.id}`;
const ready = computer.status == "running";
return (
<SettingsListRow
avatar={<ComputerIconTile />}
href={linked ? `/admin/settings/computers/${computer.id}` : undefined}
href={
linked ? `/admin/settings/computers/${computer.id}` : undefined
}
title={name}
badge={badge}
subtitle={
@@ -3,6 +3,7 @@ import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1";
import Span from "@moduletrace/twui/layout/Span";
import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button";
type Props = {
title: string;
@@ -15,7 +16,8 @@ type Props = {
/**
* Same 64px header as a conversation, so moving between bots and settings
* doesn't shift the page.
* doesn't shift the page. Below `md` it also holds the rail's drawer
* button, since the rail is out of the layout there.
*/
export default function SettingsPaneHeader({
title,
@@ -23,7 +25,8 @@ export default function SettingsPaneHeader({
action,
}: Props) {
return (
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
<Row className="h-16 shrink-0 gap-2! md:gap-3! flex-nowrap items-center px-3 md:px-6 border-0 border-b border-solid border-hairline">
<RailDrawerButton />
<Stack className="min-w-0 gap-0!">
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate">
{title}
@@ -44,7 +44,7 @@ export default function SettingsShell({
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch">
<Stack
className={twMerge(
"w-full max-w-container mx-auto gap-0! items-stretch px-6 lg:px-10 pb-16",
"w-full max-w-container mx-auto gap-0! items-stretch px-4 md:px-6 lg:px-10 pb-16",
wide && "max-w-[1200px]",
)}
>
@@ -0,0 +1,46 @@
"use client";
import { Smartphone } from "lucide-react";
import type { PushDeviceOverview } from "@/src/types";
import IconTile from "@/src/components/general/icon-tile";
import relativeTime from "@/src/utils/relative-time";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
type Props = {
subscription: PushDeviceOverview;
removing: boolean;
onRemove: () => void;
};
/**
* One device in the Notifications list.
*/
export default function NotificationDeviceRow({
subscription,
removing,
onRemove,
}: Props) {
const last_used = subscription.last_used_at
? `Last used ${relativeTime({ at: subscription.last_used_at })} ago`
: "Not used yet";
return (
<SettingsListRow
avatar={<IconTile Icon={Smartphone} />}
title={subscription.device}
subtitle={
subscription.last_error
? `Last send failed: ${subscription.last_error}`
: `Added ${relativeTime({ at: subscription.created_at })} ago · ${last_used}`
}
>
<SettingsRowAction
title={`Remove ${subscription.device}`}
label={removing ? "Removing" : "Remove"}
disabled={removing}
onClick={onRemove}
/>
</SettingsListRow>
);
}
@@ -0,0 +1,99 @@
"use client";
import Stack from "@moduletrace/twui/layout/Stack";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Button from "@moduletrace/twui/layout/Button";
import Checkbox from "@moduletrace/twui/form/Checkbox";
import { useState } from "react";
import { BellRing, Send } from "lucide-react";
import type usePushNotifications from "@/src/hooks/use-push-notifications";
type Props = ReturnType<typeof usePushNotifications> & {
onChanged?: () => void;
};
const StateCopy: Record<string, string> = {
loading: "Checking this device.",
dev: "Notifications need the installed app. They work in a production build over https, or on localhost.",
unsupported:
"This browser can't receive notifications. On iPhone and iPad, add the app to the Home Screen first, then open it from there.",
"needs-install":
"Add the app to the Home Screen first. iPhone and iPad only allow notifications for installed apps.",
denied:
"Notifications are blocked for this site. Allow them in the browser's site settings, then reload this page.",
};
/**
* The switch for the device the settings are open on, and a test send.
*/
export default function NotificationsDeviceGroup({
state,
enabled,
busy,
error,
enable,
disable,
send_test,
onChanged,
}: Props) {
const [test_status, setTestStatus] = useState<string>();
const can_switch = state == "on" || state == "off";
const copy = error || StateCopy[state];
return (
<Stack className="w-full gap-4! items-stretch">
<Row className="w-full gap-3! flex-nowrap items-start">
<Checkbox
checked={enabled}
readOnly={!can_switch || busy}
label="Notifications on this device"
clickDispatchHandler={async (on) => {
if (!can_switch || busy) return;
const ok = on ? await enable() : await disable();
if (ok) onChanged?.();
}}
/>
</Row>
{copy ? (
<Span
role={error ? "alert" : "status"}
className="text-[13px] leading-[18px] text-ink-secondary"
>
{copy}
</Span>
) : null}
<Row className="gap-2! flex-nowrap">
<Button
title="Send a test notification to this user's devices"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={!enabled || busy}
loading={busy}
beforeIcon={
enabled ? <Send size={15} /> : <BellRing size={15} />
}
onClick={async () => {
setTestStatus(undefined);
const ok = await send_test();
if (ok) setTestStatus("Test sent. It may take a few seconds to arrive.");
}}
>
Send a test
</Button>
</Row>
{test_status ? (
<Span
role="status"
className="text-[13px] leading-[18px] text-ink-secondary"
>
{test_status}
</Span>
) : null}
</Stack>
);
}
@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import Span from "@moduletrace/twui/layout/Span";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { PushDeviceOverview } from "@/src/types";
import SettingsList from "../../(partials)/settings-list";
import NotificationDeviceRow from "./notification-device-row";
type Props = {
subscriptions: PushDeviceOverview[];
status?: string;
onRemoved?: (msg?: string) => void;
};
/**
* Every device this user gets notifications on.
*/
export default function NotificationsDevicesGroup({
subscriptions,
status,
onRemoved,
}: Props) {
const [removing_id, setRemovingId] = useState<number>();
const remove = async (id: number) => {
setRemovingId(id);
const res = await fetchApi<{}, APIResponseObject>(
"/api/admin/push/remove-device",
{ method: "POST", body: { id } },
);
setRemovingId(undefined);
if (res.success) onRemoved?.("Device removed.");
};
return (
<>
<SettingsList
is_empty={!subscriptions.length}
empty="No device yet. Turn notifications on above."
>
{subscriptions.map((subscription) => (
<NotificationDeviceRow
key={subscription.id}
subscription={subscription}
removing={removing_id == subscription.id}
onRemove={() => remove(subscription.id)}
/>
))}
</SettingsList>
{status ? (
<Span role="status" className="text-[13px] leading-[18px] text-ink-secondary">
{status}
</Span>
) : null}
</>
);
}
@@ -0,0 +1,61 @@
"use client";
import { useContext, useState } from "react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import SettingsGroup from "../../(partials)/settings-group";
import NotificationsDeviceGroup from "../(partials)/notifications-device-group";
import NotificationsDevicesGroup from "../(partials)/notifications-devices-group";
import { AppContext } from "@/src/pages/__root";
import usePushNotifications from "@/src/hooks/use-push-notifications";
import type { PushOverview } from "@/src/types";
/**
* Notifications settings: switch this device on or off, send a test, and
* see the other devices that receive notifications.
*/
export default function NotificationsSection() {
const { pageProps } = useContext(AppContext);
const [overview, setOverview] = useState<PushOverview>({
public_key: pageProps?.push_overview?.public_key || null,
subscriptions: pageProps?.push_overview?.subscriptions || [],
});
const [devices_status, setDevicesStatus] = useState<string>();
const push = usePushNotifications({ public_key: overview.public_key });
const refresh = async () => {
const res = await fetchApi<{}, APIResponseObject<PushOverview>>(
"/api/admin/push/overview",
{ method: "GET" },
);
if (res.success && res.singleRes) {
setOverview(res.singleRes);
}
};
return (
<>
<SettingsGroup
title="This device"
description="A notification arrives when a bot replies or needs you and the app is closed. While the app is open, nothing is sent."
>
<NotificationsDeviceGroup {...push} onChanged={refresh} />
</SettingsGroup>
<SettingsGroup
title="Devices"
description="Every device that receives notifications. Removing one stops it here."
>
<NotificationsDevicesGroup
subscriptions={overview.subscriptions}
status={devices_status}
onRemoved={(msg) => {
setDevicesStatus(msg);
refresh();
}}
/>
</SettingsGroup>
</>
);
}
@@ -0,0 +1,27 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabPushOverview from "@/src/functions/backend/db/push/grab-push-overview";
import { ensureVapidKeys } from "@/src/push/vapid";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
/**
* The Notifications page: this user's devices and the public key a new
* device subscribes with.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
const keys = await ensureVapidKeys().catch(() => null);
return {
props: {
...settings.props,
push_overview: await grabPushOverview({
user_id: settings.props.person.id,
public_key: keys?.public_key || null,
}),
},
};
};
@@ -0,0 +1,16 @@
import NotificationsSection from "./(sections)/notifications-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsNotificationsPage() {
return (
<SettingsShell
title="Notifications"
description="When a bot needs you, and which devices hear about it."
>
<NotificationsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Notifications" });