diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 80994504..d776ac05 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -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_example": "Example", "notifications_more_details": "For more information, check out the website or documentation.", + "notification_toggle_mute": "Mute", "notification_toggle_unmute": "Unmute", "notification_toggle_background": "Background notifications", "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_topic_label": "Topic", "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_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.", diff --git a/web/src/app/ConnectionManager.js b/web/src/app/ConnectionManager.js index 7cb12e90..ea7ed15e 100644 --- a/web/src/app/ConnectionManager.js +++ b/web/src/app/ConnectionManager.js @@ -45,15 +45,11 @@ class ConnectionManager { return; } console.log(`[ConnectionManager] Refreshing connections`); - const subscriptionsWithUsersAndConnectionId = subscriptions - .map((s) => { - const [user] = users.filter((u) => u.baseUrl === s.baseUrl); - const connectionId = makeConnectionId(s, user); - 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); + const subscriptionsWithUsersAndConnectionId = subscriptions.map((s) => { + const [user] = users.filter((u) => u.baseUrl === s.baseUrl); + const connectionId = makeConnectionId(s, user); + return { ...s, user, connectionId }; + }); console.log(); const targetIds = subscriptionsWithUsersAndConnectionId.map((s) => s.connectionId); diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js index 4b8b832c..ddf68f5b 100644 --- a/web/src/app/Notifier.js +++ b/web/src/app/Notifier.js @@ -114,6 +114,11 @@ class Notifier { 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 * is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification diff --git a/web/src/app/Prefs.js b/web/src/app/Prefs.js index 75ac3ab5..1f1a6d80 100644 --- a/web/src/app/Prefs.js +++ b/web/src/app/Prefs.js @@ -31,6 +31,15 @@ class Prefs { const deleteAfter = await this.db.prefs.get("deleteAfter"); 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()); diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 6b82531d..1521aedf 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -21,8 +21,16 @@ class SubscriptionManager { } async webPushTopics() { - const subscriptions = await this.db.subscriptions.where({ webPushEnabled: 1, mutedUntil: 0 }).toArray(); - return subscriptions.map(({ topic }) => topic); + // the Promise.resolve wrapper is not superfluous, without it the live query breaks: + // 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) { @@ -49,7 +57,6 @@ class SubscriptionManager { * @param {string} topic * @param {object} opts * @param {boolean} opts.internal - * @param {boolean} opts.webPushEnabled * @returns */ async add(baseUrl, topic, opts = {}) { @@ -67,7 +74,6 @@ class SubscriptionManager { topic, mutedUntil: 0, last: null, - webPushEnabled: opts.webPushEnabled ? 1 : 0, }; 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) { await this.db.subscriptions.update(subscriptionId, { displayName, diff --git a/web/src/app/getDb.js b/web/src/app/getDb.js index 92b62c43..e52932c7 100644 --- a/web/src/app/getDb.js +++ b/web/src/app/getDb.js @@ -14,7 +14,7 @@ const getDbBase = (username) => { const db = new Dexie(dbName); 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 users: "&baseUrl,username", prefs: "&key", diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index f19710d8..56d5a765 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -69,6 +69,16 @@ const Layout = () => { const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); const users = useLiveQuery(() => userManager.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 newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; const [selected] = (subscriptionsWithoutInternal || []).filter( @@ -77,7 +87,7 @@ const Layout = () => { (config.base_url === s.baseUrl && params.topic === s.topic) ); - useConnectionListeners(account, subscriptions, users); + useConnectionListeners(account, websocketSubscriptions, users); useAccountListener(setAccount); useBackgroundProcesses(); useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index 4afc0f80..7ef5a01e 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -48,6 +48,7 @@ import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; import { UnauthorizedError } from "../app/errors"; import { subscribeTopic } from "./SubscribeDialog"; +import notifier from "../app/Notifier"; const maybeUpdateAccountSettings = async (payload) => { if (!session.exists()) { @@ -85,6 +86,7 @@ const Notifications = () => { + ); @@ -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 ( + + + + + + ); +}; + const Users = () => { const { t } = useTranslation(); const [dialogKey, setDialogKey] = useState(0); diff --git a/web/src/components/SubscribeDialog.jsx b/web/src/components/SubscribeDialog.jsx index 8c5d7e45..fedccc39 100644 --- a/web/src/components/SubscribeDialog.jsx +++ b/web/src/components/SubscribeDialog.jsx @@ -28,7 +28,6 @@ import ReserveTopicSelect from "./ReserveTopicSelect"; import { AccountContext } from "./App"; import { TopicReservedError, UnauthorizedError } from "../app/errors"; import { ReserveLimitChip } from "./SubscriptionPopup"; -import notifier from "../app/Notifier"; const publicBaseUrl = "https://ntfy.sh"; @@ -53,12 +52,10 @@ const SubscribeDialog = (props) => { const [showLoginPage, setShowLoginPage] = useState(false); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - const handleSuccess = async (webPushEnabled) => { + const handleSuccess = async () => { console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); const actualBaseUrl = baseUrl || config.base_url; - const subscription = await subscribeTopic(actualBaseUrl, topic, { - webPushEnabled, - }); + const subscription = await subscribeTopic(actualBaseUrl, topic, {}); poller.pollInBackground(subscription); // Dangle! props.onSuccess(subscription); }; @@ -99,12 +96,6 @@ const SubscribePage = (props) => { const reserveTopicEnabled = 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 user = await userManager.get(baseUrl); // May be undefined 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}`); - props.onSuccess(backgroundNotificationsEnabled); + props.onSuccess(); }; const handleUseAnotherChanged = (e) => { props.setBaseUrl(""); setAnotherServerVisible(e.target.checked); - if (e.target.checked) { - setBackgroundNotificationsEnabled(false); - } }; const subscribeButtonEnabled = (() => { @@ -256,22 +244,6 @@ const SubscribePage = (props) => { )} )} - {notifier.pushPossible() && !anotherServerVisible && ( - - - } - label={t("subscribe_dialog_subscribe_enable_background_notifications_label")} - /> - - )} diff --git a/web/src/components/SubscriptionPopup.jsx b/web/src/components/SubscriptionPopup.jsx index 67a96da7..ee162972 100644 --- a/web/src/components/SubscriptionPopup.jsx +++ b/web/src/components/SubscriptionPopup.jsx @@ -15,19 +15,17 @@ import { MenuItem, IconButton, ListItemIcon, - ListItemText, - Divider, } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { - Check, Clear, ClearAll, Edit, EnhancedEncryption, Lock, LockOpen, + Notifications, NotificationsOff, RemoveCircle, Send, @@ -44,7 +42,6 @@ import api from "../app/Api"; import { AccountContext } from "./App"; import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; import { UnauthorizedError } from "../app/errors"; -import notifier from "../app/Notifier"; export const SubscriptionPopup = (props) => { const { t } = useTranslation(); @@ -169,8 +166,8 @@ export const SubscriptionPopup = (props) => { return ( <> - {notifier.pushPossible() && } - + + @@ -334,44 +331,27 @@ const DisplayNameDialog = (props) => { ); }; -const checkedItem = ( - - - -); - const NotificationToggle = ({ subscription }) => { const { t } = useTranslation(); - const handleToggleBackground = async () => { - try { - await subscriptionManager.toggleBackgroundNotifications(subscription); - } catch (e) { - console.error("[NotificationToggle] Error setting notification type", e); - } + const handleToggleMute = async () => { + const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future + await subscriptionManager.setMutedUntil(subscription.id, mutedUntil); }; - const unmute = async () => { - await subscriptionManager.setMutedUntil(subscription.id, 0); - }; - - if (subscription.mutedUntil === 1) { - return ( - - - - - {t("notification_toggle_unmute")} - - ); - } - - return ( - - {subscription.webPushEnabled === 1 && checkedItem} - - {t("notification_toggle_background")} - + return subscription.mutedUntil ? ( + + + + + {t("notification_toggle_unmute")} + + ) : ( + + + + + {t("notification_toggle_mute")} ); };