Преглед на файлове

Make web push toggle global

nimbleghost преди 2 години
родител
ревизия
46798ac322

+ 5 - 0
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 <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
+  "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.",

+ 5 - 9
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);

+ 5 - 0
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

+ 9 - 0
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());

+ 10 - 10
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,

+ 1 - 1
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",

+ 11 - 1
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]);

+ 31 - 0
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 = () => {
         <Sound />
         <MinPriority />
         <DeleteAfter />
+        <WebPushEnabled />
       </PrefGroup>
     </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 { t } = useTranslation();
   const [dialogKey, setDialogKey] = useState(0);

+ 3 - 31
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) => {
             )}
           </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>
       <DialogFooter status={error}>
         <Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>

+ 19 - 39
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 (
     <>
       <PopupMenu horizontal={placement} anchorEl={props.anchor} open={!!props.anchor} onClose={props.onClose}>
-        {notifier.pushPossible() && <NotificationToggle subscription={subscription} />}
-        <Divider />
+        <NotificationToggle subscription={subscription} />
+
         <MenuItem onClick={handleChangeDisplayName}>
           <ListItemIcon>
             <Edit fontSize="small" />
@@ -334,44 +331,27 @@ const DisplayNameDialog = (props) => {
   );
 };
 
-const checkedItem = (
-  <ListItemIcon>
-    <Check />
-  </ListItemIcon>
-);
-
 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 unmute = async () => {
-    await subscriptionManager.setMutedUntil(subscription.id, 0);
+  const handleToggleMute = async () => {
+    const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future
+    await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);
   };
 
-  if (subscription.mutedUntil === 1) {
-    return (
-      <MenuItem onClick={unmute}>
-        <ListItemIcon>
-          <NotificationsOff />
-        </ListItemIcon>
-        {t("notification_toggle_unmute")}
-      </MenuItem>
-    );
-  }
-
-  return (
-    <MenuItem>
-      {subscription.webPushEnabled === 1 && checkedItem}
-      <ListItemText inset={subscription.webPushEnabled !== 1} onClick={handleToggleBackground}>
-        {t("notification_toggle_background")}
-      </ListItemText>
+  return subscription.mutedUntil ? (
+    <MenuItem onClick={handleToggleMute}>
+      <ListItemIcon>
+        <Notifications />
+      </ListItemIcon>
+      {t("notification_toggle_unmute")}
+    </MenuItem>
+  ) : (
+    <MenuItem onClick={handleToggleMute}>
+      <ListItemIcon>
+        <NotificationsOff />
+      </ListItemIcon>
+      {t("notification_toggle_mute")}
     </MenuItem>
   );
 };