Files
ihasmail/web/src/views/settings/NotificationsSettings.tsx
T
jcoffey-dev 457ea53ca3 Let an installation seed and lock user settings
The first two thirds of #207. A school wanting "warn about outside senders"
on for three thousand pupils cannot ask three thousand pupils, and the
reporter is right that this is a company policy rather than a preference.

Two powers, and the difference between them is the whole request. `defaults`
seed an account that has never had settings of its own and can be changed
afterwards like anything else -- a starting point, not a rule. `enforced` are
reapplied on every load and cannot be changed at all.

Enforced controls stay visible and go dead, with a line saying why. The issue
asked for that by name: a control that is simply missing reads as a bug to
somebody who has used ihasmail without a policy.

The lock is in the settings store rather than only on the controls. There is
one door -- `update` -- and putting it there means an imported settings file,
a settings file synced from a device that predates the policy, and a control
somebody adds later and forgets to check are all covered by construction.
Reset goes back to the installation's answer rather than to ihasmail's, so it
cannot be a way around a policy either.

Configured by environment variable or by a file, because ihasmail's own
production runs read-only with no volume: an installation that cannot mount a
file can still set a variable. Keys this build does not have are dropped, the
same rule an imported settings file already gets -- a policy written against a
newer ihasmail must not put a setting nothing reads into everybody's synced
settings file. Malformed JSON stops the server rather than quietly doing
nothing, since a policy that silently did not apply is indistinguishable from
the feature not working.

Tier three -- enforcing a setting once while still letting readers change it
afterwards -- is not here. It needs a decision the reporter and I have not
made yet, and it is the only part that stores anything new.

Refs #207.
2026-09-02 10:49:55 -07:00

88 lines
4.2 KiB
TypeScript

import { useEffect, useState } from "react";
import { useSettings } from "@/store/settings";
import { Switch } from "@/ui/misc";
import { requestNotificationPermission, showNotification, playNewMailSound } from "@/lib/notify";
import { useSession } from "@/store/session";
import { disableWebPush, enableWebPush, webPushActive } from "@/lib/webpushEnable";
import { supportsEmailPush, webPushAvailable } from "@/lib/webpush";
import { toast } from "@/ui/toast";
import { t } from "@/lib/i18n";
import { isEnforced } from "@/lib/settingsPolicy";
export function NotificationsSettings() {
const s = useSettings((st) => st.settings);
const update = useSettings((st) => st.update);
const pushConnected = useSession((st) => st.pushConnected);
const [perm, setPerm] = useState<NotificationPermission | "unsupported">("Notification" in window ? Notification.permission : "unsupported");
const [background, setBackground] = useState(false);
const [busy, setBusy] = useState(false);
const canBackground = webPushAvailable();
useEffect(() => {
void webPushActive().then(setBackground);
}, []);
useEffect(() => {
if ("Notification" in window) setPerm(Notification.permission);
}, [s.desktopNotifications]);
return (
<div>
<h1>{t("Notifications")}</h1>
<p className="lead">{t("Live updates are delivered via JMAP push ({state}).", { state: pushConnected ? t("connected") : t("reconnecting…") })}</p>
<Switch
checked={s.desktopNotifications}
onChange={async (v) => {
if (v) {
const p = await requestNotificationPermission();
setPerm(p);
if (p !== "granted") return;
}
update({ desktopNotifications: v });
}}
label={t("Desktop notifications while ihasmail is open")}
hint={perm === "denied" ? t("Notifications are blocked in your browser settings.") : perm === "unsupported" ? t("Not supported in this browser.") : t("Shows a system notification when new mail arrives in your Inbox while the tab is in the background.")}
disabled={perm === "denied" || perm === "unsupported"}
/>
{/*
The distinction worth drawing for the user: the switch above needs a tab
open, this one does not. Everything before this shipped only the first
kind, while calling it "desktop notifications".
*/}
<Switch
checked={background}
disabled={!canBackground || busy || perm === "denied"}
onChange={async (v) => {
setBusy(true);
try {
if (v) {
const p = await requestNotificationPermission();
setPerm(p);
if (p !== "granted") return;
const res = await enableWebPush();
if (!res.ok) { toast.error(res.reason); return; }
setBackground(true);
toast.success(t("Background notifications are on"));
} else {
await disableWebPush();
setBackground(false);
}
} finally {
setBusy(false);
}
}}
label={t("Notify me even when ihasmail is closed")}
hint={
!canBackground
? t("Needs a browser with the Push API and a mail server that publishes a push key.")
: supportsEmailPush()
? t("Your mail server delivers these straight to your browser, so they arrive with no ihasmail tab open, naming the sender and subject. Your browser still has to be running — if you quit it completely, notifications wait and arrive when you open it again.")
: t("Your mail server can wake this browser, but will not include the sender or subject. Your browser still has to be running.")
}
/>
<Switch locked={isEnforced("notificationSound")} checked={s.notificationSound} onChange={(v) => update({ notificationSound: v })} label={t("Play a sound for new mail")} />
<div className="row mt-16">
<button className="btn" onClick={() => { showNotification(t("ihasmail test"), { body: t("This is what a new-mail notification looks like.") }); playNewMailSound(); }}>{t("Test notification")}</button>
</div>
<p className="hint mt-8">{t("The tab title and favicon always show your unread Inbox count.")}</p>
</div>
);
}