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() && }
-
+
+