Make web push toggle global

This commit is contained in:
nimbleghost 2023-06-08 09:22:56 +02:00
parent a8db08c7d4
commit 46798ac322
10 changed files with 99 additions and 91 deletions

View file

@ -95,6 +95,7 @@
"notifications_no_subscriptions_description": "Click the \"{{linktext}}\" link to create or subscribe to a topic. After that, you can send messages via PUT or POST and you'll receive notifications here.", "notifications_no_subscriptions_description": "Click the \"{{linktext}}\" link to create or subscribe to a topic. After that, you can send messages via PUT or POST and you'll receive notifications here.",
"notifications_example": "Example", "notifications_example": "Example",
"notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.", "notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
"notification_toggle_mute": "Mute",
"notification_toggle_unmute": "Unmute", "notification_toggle_unmute": "Unmute",
"notification_toggle_background": "Background notifications", "notification_toggle_background": "Background notifications",
"display_name_dialog_title": "Change display name", "display_name_dialog_title": "Change display name",
@ -369,6 +370,10 @@
"prefs_reservations_dialog_description": "Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.", "prefs_reservations_dialog_description": "Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.",
"prefs_reservations_dialog_topic_label": "Topic", "prefs_reservations_dialog_topic_label": "Topic",
"prefs_reservations_dialog_access_label": "Access", "prefs_reservations_dialog_access_label": "Access",
"prefs_notifications_web_push_title": "Enable web push notifications",
"prefs_notifications_web_push_description": "Enable this to receive notifications in the background even when ntfy isn't running",
"prefs_notifications_web_push_enabled": "Enabled",
"prefs_notifications_web_push_disabled": "Disabled",
"reservation_delete_dialog_description": "Removing a reservation gives up ownership over the topic, and allows others to reserve it. You can keep, or delete existing messages and attachments.", "reservation_delete_dialog_description": "Removing a reservation gives up ownership over the topic, and allows others to reserve it. You can keep, or delete existing messages and attachments.",
"reservation_delete_dialog_action_keep_title": "Keep cached messages and attachments", "reservation_delete_dialog_action_keep_title": "Keep cached messages and attachments",
"reservation_delete_dialog_action_keep_description": "Messages and attachments that are cached on the server will become publicly visible for people with knowledge of the topic name.", "reservation_delete_dialog_action_keep_description": "Messages and attachments that are cached on the server will become publicly visible for people with knowledge of the topic name.",

View file

@ -45,15 +45,11 @@ class ConnectionManager {
return; return;
} }
console.log(`[ConnectionManager] Refreshing connections`); console.log(`[ConnectionManager] Refreshing connections`);
const subscriptionsWithUsersAndConnectionId = subscriptions const subscriptionsWithUsersAndConnectionId = subscriptions.map((s) => {
.map((s) => { const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
const [user] = users.filter((u) => u.baseUrl === s.baseUrl); const connectionId = makeConnectionId(s, user);
const connectionId = makeConnectionId(s, user); return { ...s, user, connectionId };
return { ...s, user, connectionId }; });
})
// background notifications don't need this as they come over web push.
// however, if they are muted, we again need the ws while the page is active
.filter((s) => !s.webPushEnabled && s.mutedUntil !== 1);
console.log(); console.log();
const targetIds = subscriptionsWithUsersAndConnectionId.map((s) => s.connectionId); const targetIds = subscriptionsWithUsersAndConnectionId.map((s) => s.connectionId);

View file

@ -114,6 +114,11 @@ class Notifier {
return this.pushSupported() && this.contextSupported() && this.granted() && !this.iosSupportedButInstallRequired(); return this.pushSupported() && this.contextSupported() && this.granted() && !this.iosSupportedButInstallRequired();
} }
async pushEnabled() {
const enabled = await prefs.webPushEnabled();
return this.pushPossible() && enabled;
}
/** /**
* Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API * Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification * is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification

View file

@ -31,6 +31,15 @@ class Prefs {
const deleteAfter = await this.db.prefs.get("deleteAfter"); const deleteAfter = await this.db.prefs.get("deleteAfter");
return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week
} }
async webPushEnabled() {
const obj = await this.db.prefs.get("webPushEnabled");
return obj?.value ?? false;
}
async setWebPushEnabled(enabled) {
await this.db.prefs.put({ key: "webPushEnabled", value: enabled });
}
} }
const prefs = new Prefs(getDb()); const prefs = new Prefs(getDb());

View file

@ -21,8 +21,16 @@ class SubscriptionManager {
} }
async webPushTopics() { async webPushTopics() {
const subscriptions = await this.db.subscriptions.where({ webPushEnabled: 1, mutedUntil: 0 }).toArray(); // the Promise.resolve wrapper is not superfluous, without it the live query breaks:
return subscriptions.map(({ topic }) => topic); // https://dexie.org/docs/dexie-react-hooks/useLiveQuery()#calling-non-dexie-apis-from-querier
if (!(await Promise.resolve(notifier.pushEnabled()))) {
return [];
}
const subscriptions = await this.db.subscriptions.where({ mutedUntil: 0, baseUrl: config.base_url }).toArray();
// internal is currently a bool, it could be a 0/1 to be indexable, but for now just filter them out here
return subscriptions.filter(({ internal }) => !internal).map(({ topic }) => topic);
} }
async get(subscriptionId) { async get(subscriptionId) {
@ -49,7 +57,6 @@ class SubscriptionManager {
* @param {string} topic * @param {string} topic
* @param {object} opts * @param {object} opts
* @param {boolean} opts.internal * @param {boolean} opts.internal
* @param {boolean} opts.webPushEnabled
* @returns * @returns
*/ */
async add(baseUrl, topic, opts = {}) { async add(baseUrl, topic, opts = {}) {
@ -67,7 +74,6 @@ class SubscriptionManager {
topic, topic,
mutedUntil: 0, mutedUntil: 0,
last: null, last: null,
webPushEnabled: opts.webPushEnabled ? 1 : 0,
}; };
await this.db.subscriptions.put(subscription); await this.db.subscriptions.put(subscription);
@ -211,12 +217,6 @@ class SubscriptionManager {
}); });
} }
async toggleBackgroundNotifications(subscription) {
await this.db.subscriptions.update(subscription.id, {
webPushEnabled: subscription.webPushEnabled === 1 ? 0 : 1,
});
}
async setDisplayName(subscriptionId, displayName) { async setDisplayName(subscriptionId, displayName) {
await this.db.subscriptions.update(subscriptionId, { await this.db.subscriptions.update(subscriptionId, {
displayName, displayName,

View file

@ -14,7 +14,7 @@ const getDbBase = (username) => {
const db = new Dexie(dbName); const db = new Dexie(dbName);
db.version(2).stores({ db.version(2).stores({
subscriptions: "&id,baseUrl,[webPushEnabled+mutedUntil]", subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]",
notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
users: "&baseUrl,username", users: "&baseUrl,username",
prefs: "&key", prefs: "&key",

View file

@ -69,6 +69,16 @@ const Layout = () => {
const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
const users = useLiveQuery(() => userManager.all()); const users = useLiveQuery(() => userManager.all());
const subscriptions = useLiveQuery(() => subscriptionManager.all()); const subscriptions = useLiveQuery(() => subscriptionManager.all());
const webPushTopics = useLiveQuery(() => subscriptionManager.webPushTopics());
const websocketSubscriptions = useMemo(
() => (subscriptions && webPushTopics ? subscriptions.filter((s) => !webPushTopics.includes(s.topic)) : []),
// websocketSubscriptions should stay stable unless the list of subscription ids changes.
// without the memoization, the connection listener calls a refresh for no reason.
// this isn't a problem due to the makeConnectionId, but it triggers an
// unnecessary recomputation for every received message.
[JSON.stringify({ subscriptions: subscriptions?.map(({ id }) => id), webPushTopics })]
);
const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal); const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
const [selected] = (subscriptionsWithoutInternal || []).filter( const [selected] = (subscriptionsWithoutInternal || []).filter(
@ -77,7 +87,7 @@ const Layout = () => {
(config.base_url === s.baseUrl && params.topic === s.topic) (config.base_url === s.baseUrl && params.topic === s.topic)
); );
useConnectionListeners(account, subscriptions, users); useConnectionListeners(account, websocketSubscriptions, users);
useAccountListener(setAccount); useAccountListener(setAccount);
useBackgroundProcesses(); useBackgroundProcesses();
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);

View file

@ -48,6 +48,7 @@ import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
import { UnauthorizedError } from "../app/errors"; import { UnauthorizedError } from "../app/errors";
import { subscribeTopic } from "./SubscribeDialog"; import { subscribeTopic } from "./SubscribeDialog";
import notifier from "../app/Notifier";
const maybeUpdateAccountSettings = async (payload) => { const maybeUpdateAccountSettings = async (payload) => {
if (!session.exists()) { if (!session.exists()) {
@ -85,6 +86,7 @@ const Notifications = () => {
<Sound /> <Sound />
<MinPriority /> <MinPriority />
<DeleteAfter /> <DeleteAfter />
<WebPushEnabled />
</PrefGroup> </PrefGroup>
</Card> </Card>
); );
@ -232,6 +234,35 @@ const DeleteAfter = () => {
); );
}; };
const WebPushEnabled = () => {
const { t } = useTranslation();
const labelId = "prefWebPushEnabled";
const defaultEnabled = useLiveQuery(async () => prefs.webPushEnabled());
const handleChange = async (ev) => {
await prefs.setWebPushEnabled(ev.target.value);
};
// while loading
if (defaultEnabled == null) {
return null;
}
if (!notifier.pushPossible()) {
return null;
}
return (
<Pref labelId={labelId} title={t("prefs_notifications_web_push_title")} description={t("prefs_notifications_web_push_description")}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={defaultEnabled} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value>{t("prefs_notifications_web_push_enabled")}</MenuItem>
<MenuItem value={false}>{t("prefs_notifications_web_push_disabled")}</MenuItem>
</Select>
</FormControl>
</Pref>
);
};
const Users = () => { const Users = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0); const [dialogKey, setDialogKey] = useState(0);

View file

@ -28,7 +28,6 @@ import ReserveTopicSelect from "./ReserveTopicSelect";
import { AccountContext } from "./App"; import { AccountContext } from "./App";
import { TopicReservedError, UnauthorizedError } from "../app/errors"; import { TopicReservedError, UnauthorizedError } from "../app/errors";
import { ReserveLimitChip } from "./SubscriptionPopup"; import { ReserveLimitChip } from "./SubscriptionPopup";
import notifier from "../app/Notifier";
const publicBaseUrl = "https://ntfy.sh"; const publicBaseUrl = "https://ntfy.sh";
@ -53,12 +52,10 @@ const SubscribeDialog = (props) => {
const [showLoginPage, setShowLoginPage] = useState(false); const [showLoginPage, setShowLoginPage] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const handleSuccess = async (webPushEnabled) => { const handleSuccess = async () => {
console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
const actualBaseUrl = baseUrl || config.base_url; const actualBaseUrl = baseUrl || config.base_url;
const subscription = await subscribeTopic(actualBaseUrl, topic, { const subscription = await subscribeTopic(actualBaseUrl, topic, {});
webPushEnabled,
});
poller.pollInBackground(subscription); // Dangle! poller.pollInBackground(subscription); // Dangle!
props.onSuccess(subscription); props.onSuccess(subscription);
}; };
@ -99,12 +96,6 @@ const SubscribePage = (props) => {
const reserveTopicEnabled = const reserveTopicEnabled =
session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0)); session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0));
const [backgroundNotificationsEnabled, setBackgroundNotificationsEnabled] = useState(false);
const handleBackgroundNotificationsChanged = (e) => {
setBackgroundNotificationsEnabled(e.target.checked);
};
const handleSubscribe = async () => { const handleSubscribe = async () => {
const user = await userManager.get(baseUrl); // May be undefined const user = await userManager.get(baseUrl); // May be undefined
const username = user ? user.username : t("subscribe_dialog_error_user_anonymous"); const username = user ? user.username : t("subscribe_dialog_error_user_anonymous");
@ -142,15 +133,12 @@ const SubscribePage = (props) => {
} }
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
props.onSuccess(backgroundNotificationsEnabled); props.onSuccess();
}; };
const handleUseAnotherChanged = (e) => { const handleUseAnotherChanged = (e) => {
props.setBaseUrl(""); props.setBaseUrl("");
setAnotherServerVisible(e.target.checked); setAnotherServerVisible(e.target.checked);
if (e.target.checked) {
setBackgroundNotificationsEnabled(false);
}
}; };
const subscribeButtonEnabled = (() => { const subscribeButtonEnabled = (() => {
@ -256,22 +244,6 @@ const SubscribePage = (props) => {
)} )}
</FormGroup> </FormGroup>
)} )}
{notifier.pushPossible() && !anotherServerVisible && (
<FormGroup>
<FormControlLabel
control={
<Switch
onChange={handleBackgroundNotificationsChanged}
checked={backgroundNotificationsEnabled}
inputProps={{
"aria-label": t("subscribe_dialog_subscribe_enable_background_notifications_label"),
}}
/>
}
label={t("subscribe_dialog_subscribe_enable_background_notifications_label")}
/>
</FormGroup>
)}
</DialogContent> </DialogContent>
<DialogFooter status={error}> <DialogFooter status={error}>
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button> <Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>

View file

@ -15,19 +15,17 @@ import {
MenuItem, MenuItem,
IconButton, IconButton,
ListItemIcon, ListItemIcon,
ListItemText,
Divider,
} from "@mui/material"; } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
Check,
Clear, Clear,
ClearAll, ClearAll,
Edit, Edit,
EnhancedEncryption, EnhancedEncryption,
Lock, Lock,
LockOpen, LockOpen,
Notifications,
NotificationsOff, NotificationsOff,
RemoveCircle, RemoveCircle,
Send, Send,
@ -44,7 +42,6 @@ import api from "../app/Api";
import { AccountContext } from "./App"; import { AccountContext } from "./App";
import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs";
import { UnauthorizedError } from "../app/errors"; import { UnauthorizedError } from "../app/errors";
import notifier from "../app/Notifier";
export const SubscriptionPopup = (props) => { export const SubscriptionPopup = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -169,8 +166,8 @@ export const SubscriptionPopup = (props) => {
return ( return (
<> <>
<PopupMenu horizontal={placement} anchorEl={props.anchor} open={!!props.anchor} onClose={props.onClose}> <PopupMenu horizontal={placement} anchorEl={props.anchor} open={!!props.anchor} onClose={props.onClose}>
{notifier.pushPossible() && <NotificationToggle subscription={subscription} />} <NotificationToggle subscription={subscription} />
<Divider />
<MenuItem onClick={handleChangeDisplayName}> <MenuItem onClick={handleChangeDisplayName}>
<ListItemIcon> <ListItemIcon>
<Edit fontSize="small" /> <Edit fontSize="small" />
@ -334,44 +331,27 @@ const DisplayNameDialog = (props) => {
); );
}; };
const checkedItem = (
<ListItemIcon>
<Check />
</ListItemIcon>
);
const NotificationToggle = ({ subscription }) => { const NotificationToggle = ({ subscription }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const handleToggleBackground = async () => { const handleToggleMute = async () => {
try { const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future
await subscriptionManager.toggleBackgroundNotifications(subscription); await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);
} catch (e) {
console.error("[NotificationToggle] Error setting notification type", e);
}
}; };
const unmute = async () => { return subscription.mutedUntil ? (
await subscriptionManager.setMutedUntil(subscription.id, 0); <MenuItem onClick={handleToggleMute}>
}; <ListItemIcon>
<Notifications />
if (subscription.mutedUntil === 1) { </ListItemIcon>
return ( {t("notification_toggle_unmute")}
<MenuItem onClick={unmute}> </MenuItem>
<ListItemIcon> ) : (
<NotificationsOff /> <MenuItem onClick={handleToggleMute}>
</ListItemIcon> <ListItemIcon>
{t("notification_toggle_unmute")} <NotificationsOff />
</MenuItem> </ListItemIcon>
); {t("notification_toggle_mute")}
}
return (
<MenuItem>
{subscription.webPushEnabled === 1 && checkedItem}
<ListItemText inset={subscription.webPushEnabled !== 1} onClick={handleToggleBackground}>
{t("notification_toggle_background")}
</ListItemText>
</MenuItem> </MenuItem>
); );
}; };