Move the notification settings onto the general settings page

This commit is contained in:
2026-10-23 04:11:21 +01:00
parent a5eb2b6a95
commit 5475241371
10 changed files with 24 additions and 51 deletions
@@ -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>
</>
);
}
@@ -1,7 +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 General page: app appearance and behavior, plus this user's
* notification devices and the public key a new device subscribes with.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
return await grabSettingsServerProps(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,
}),
},
};
};
@@ -1,4 +1,5 @@
import AppearanceSection from "./(sections)/appearance-section";
import NotificationsSection from "./(sections)/notifications-section";
import PromptSection from "./(sections)/prompt-section";
import StorageSection from "./(sections)/storage-section";
import VersionSection from "./(sections)/version-section";
@@ -12,6 +13,7 @@ export default function AdminSettingsGeneralPage() {
description="How the app looks and behaves."
>
<AppearanceSection />
<NotificationsSection />
<PromptSection />
<StorageSection />
<VersionSection />