Просмотр исходного кода

Simplify web push UX and updates

- Use a single endpoint
- Use a declarative web push sync hook. This thus handles all edge cases
  that had to be manually handled before: logout, login, account sync,
  etc.
- Simplify UX: browser notifications are always enabled (unless denied),
  web push toggle only shows up if permissions are already granted.
nimbleghost 2 лет назад
Родитель
Сommit
47ad024ec7

+ 12 - 15
server/server.go

@@ -67,17 +67,15 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error
 
 var (
 	// If changed, don't forget to update Android App and auth_sqlite.go
-	topicRegex                  = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)               // No /!
-	topicPathRegex              = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`)              // Regex must match JS & Android app!
-	externalTopicPathRegex      = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
-	jsonPathRegex               = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
-	ssePathRegex                = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
-	rawPathRegex                = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
-	wsPathRegex                 = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
-	authPathRegex               = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
-	webPushSubscribePathRegex   = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/web-push/subscribe$`)
-	webPushUnsubscribePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/web-push/unsubscribe$`)
-	publishPathRegex            = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
+	topicRegex             = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)               // No /!
+	topicPathRegex         = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`)              // Regex must match JS & Android app!
+	externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
+	jsonPathRegex          = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
+	ssePathRegex           = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
+	rawPathRegex           = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
+	wsPathRegex            = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
+	authPathRegex          = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
+	publishPathRegex       = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
 
 	webConfigPath                                        = "/config.js"
 	webManifestPath                                      = "/manifest.webmanifest"
@@ -96,6 +94,7 @@ var (
 	apiAccountSettingsPath                               = "/v1/account/settings"
 	apiAccountSubscriptionPath                           = "/v1/account/subscription"
 	apiAccountReservationPath                            = "/v1/account/reservation"
+	apiAccountWebPushPath                                = "/v1/account/web-push"
 	apiAccountPhonePath                                  = "/v1/account/phone"
 	apiAccountPhoneVerifyPath                            = "/v1/account/phone/verify"
 	apiAccountBillingPortalPath                          = "/v1/account/billing/portal"
@@ -525,10 +524,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
 		return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v)
 	} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
 		return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v)
-	} else if r.Method == http.MethodPost && webPushSubscribePathRegex.MatchString(r.URL.Path) {
-		return s.ensureWebPushEnabled(s.limitRequestsWithTopic(s.authorizeTopicRead(s.handleTopicWebPushSubscribe)))(w, r, v)
-	} else if r.Method == http.MethodPost && webPushUnsubscribePathRegex.MatchString(r.URL.Path) {
-		return s.ensureWebPushEnabled(s.limitRequestsWithTopic(s.authorizeTopicRead(s.handleTopicWebPushUnsubscribe)))(w, r, v)
+	} else if r.Method == http.MethodPut && apiAccountWebPushPath == r.URL.Path {
+		return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v)
 	} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
 		return s.ensureWebEnabled(s.handleTopic)(w, r, v)
 	}

+ 17 - 21
server/server_web_push.go

@@ -3,40 +3,36 @@ package server
 import (
 	"encoding/json"
 	"fmt"
+	"net/http"
+
 	"github.com/SherClockHolmes/webpush-go"
 	"heckel.io/ntfy/log"
-	"net/http"
+	"heckel.io/ntfy/user"
 )
 
-func (s *Server) handleTopicWebPushSubscribe(w http.ResponseWriter, r *http.Request, v *visitor) error {
-	sub, err := readJSONWithLimit[webPushSubscribePayload](r.Body, jsonBodyBytesLimit, false)
-	if err != nil || sub.BrowserSubscription.Endpoint == "" || sub.BrowserSubscription.Keys.P256dh == "" || sub.BrowserSubscription.Keys.Auth == "" {
+func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
+	payload, err := readJSONWithLimit[webPushSubscriptionPayload](r.Body, jsonBodyBytesLimit, false)
+	if err != nil || payload.BrowserSubscription.Endpoint == "" || payload.BrowserSubscription.Keys.P256dh == "" || payload.BrowserSubscription.Keys.Auth == "" {
 		return errHTTPBadRequestWebPushSubscriptionInvalid
 	}
 
-	topic, err := fromContext[*topic](r, contextTopic)
-	if err != nil {
-		return err
-	}
-	if err = s.webPush.AddSubscription(topic.ID, v.MaybeUserID(), *sub); err != nil {
-		return err
-	}
-	return s.writeJSON(w, newSuccessResponse())
-}
+	u := v.User()
 
-func (s *Server) handleTopicWebPushUnsubscribe(w http.ResponseWriter, r *http.Request, _ *visitor) error {
-	payload, err := readJSONWithLimit[webPushUnsubscribePayload](r.Body, jsonBodyBytesLimit, false)
+	topics, err := s.topicsFromIDs(payload.Topics...)
 	if err != nil {
-		return errHTTPBadRequestWebPushSubscriptionInvalid
+		return err
 	}
 
-	topic, err := fromContext[*topic](r, contextTopic)
-	if err != nil {
-		return err
+	if s.userManager != nil {
+		for _, t := range topics {
+			if err := s.userManager.Authorize(u, t.ID, user.PermissionRead); err != nil {
+				logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID)
+				return errHTTPForbidden.With(t)
+			}
+		}
 	}
 
-	err = s.webPush.RemoveSubscription(topic.ID, payload.Endpoint)
-	if err != nil {
+	if err := s.webPush.UpdateSubscriptions(payload.Topics, v.MaybeUserID(), payload.BrowserSubscription); err != nil {
 		return err
 	}
 

+ 42 - 43
server/server_web_push_test.go

@@ -1,6 +1,8 @@
 package server
 
 import (
+	"encoding/json"
+	"fmt"
 	"io"
 	"net/http"
 	"net/http/httptest"
@@ -14,22 +16,10 @@ import (
 	"heckel.io/ntfy/util"
 )
 
-var (
-	webPushSubscribePayloadExample = `{
-		"browser_subscription":{
-			"endpoint": "https://example.com/webpush",
-			"keys": {
-				"p256dh": "p256dh-key",
-				"auth": "auth-key"
-			}
-		}
-	}`
-)
-
-func TestServer_WebPush_TopicSubscribe(t *testing.T) {
+func TestServer_WebPush_TopicAdd(t *testing.T) {
 	s := newTestServer(t, newTestConfigWithWebPush(t))
 
-	response := request(t, s, "POST", "/test-topic/web-push/subscribe", webPushSubscribePayloadExample, nil)
+	response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}), nil)
 	require.Equal(t, 200, response.Code)
 	require.Equal(t, `{"success":true}`+"\n", response.Body.String())
 
@@ -43,6 +33,19 @@ func TestServer_WebPush_TopicSubscribe(t *testing.T) {
 	require.Equal(t, subs[0].UserID, "")
 }
 
+func TestServer_WebPush_TopicUnsubscribe(t *testing.T) {
+	s := newTestServer(t, newTestConfigWithWebPush(t))
+
+	addSubscription(t, s, "test-topic", "https://example.com/webpush")
+	requireSubscriptionCount(t, s, "test-topic", 1)
+
+	response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{}), nil)
+	require.Equal(t, 200, response.Code)
+	require.Equal(t, `{"success":true}`+"\n", response.Body.String())
+
+	requireSubscriptionCount(t, s, "test-topic", 0)
+}
+
 func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) {
 	config := configureAuth(t, newTestConfigWithWebPush(t))
 	config.AuthDefault = user.PermissionDenyAll
@@ -51,7 +54,7 @@ func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) {
 	require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
 	require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
 
-	response := request(t, s, "POST", "/test-topic/web-push/subscribe", webPushSubscribePayloadExample, map[string]string{
+	response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}), map[string]string{
 		"Authorization": util.BasicAuth("ben", "ben"),
 	})
 	require.Equal(t, 200, response.Code)
@@ -68,38 +71,20 @@ func TestServer_WebPush_TopicSubscribeProtected_Denied(t *testing.T) {
 	config.AuthDefault = user.PermissionDenyAll
 	s := newTestServer(t, config)
 
-	response := request(t, s, "POST", "/test-topic/web-push/subscribe", webPushSubscribePayloadExample, nil)
+	response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}), nil)
 	require.Equal(t, 403, response.Code)
 
 	requireSubscriptionCount(t, s, "test-topic", 0)
 }
 
-func TestServer_WebPush_TopicUnsubscribe(t *testing.T) {
-	s := newTestServer(t, newTestConfigWithWebPush(t))
-
-	response := request(t, s, "POST", "/test-topic/web-push/subscribe", webPushSubscribePayloadExample, nil)
-	require.Equal(t, 200, response.Code)
-	require.Equal(t, `{"success":true}`+"\n", response.Body.String())
-
-	requireSubscriptionCount(t, s, "test-topic", 1)
-
-	unsubscribe := `{"endpoint":"https://example.com/webpush"}`
-	response = request(t, s, "POST", "/test-topic/web-push/unsubscribe", unsubscribe, nil)
-	require.Equal(t, 200, response.Code)
-	require.Equal(t, `{"success":true}`+"\n", response.Body.String())
-
-	requireSubscriptionCount(t, s, "test-topic", 0)
-}
-
 func TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) {
 	config := configureAuth(t, newTestConfigWithWebPush(t))
-	config.AuthDefault = user.PermissionDenyAll
 	s := newTestServer(t, config)
 
 	require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
 	require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
 
-	response := request(t, s, "POST", "/test-topic/web-push/subscribe", webPushSubscribePayloadExample, map[string]string{
+	response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}), map[string]string{
 		"Authorization": util.BasicAuth("ben", "ben"),
 	})
 
@@ -172,15 +157,29 @@ func TestServer_WebPush_PublishExpire(t *testing.T) {
 	requireSubscriptionCount(t, s, "test-topic-abc", 0)
 }
 
+func payloadForTopics(t *testing.T, topics []string) string {
+	topicsJson, err := json.Marshal(topics)
+	require.Nil(t, err)
+
+	return fmt.Sprintf(`{
+		"topics": %s,
+		"browser_subscription":{
+			"endpoint": "https://example.com/webpush",
+			"keys": {
+				"p256dh": "p256dh-key",
+				"auth": "auth-key"
+			}
+		}
+	}`, topicsJson)
+}
+
 func addSubscription(t *testing.T, s *Server, topic string, url string) {
-	err := s.webPush.AddSubscription("test-topic", "", webPushSubscribePayload{
-		BrowserSubscription: webpush.Subscription{
-			Endpoint: url,
-			Keys: webpush.Keys{
-				// connected to a local test VAPID key, not a leak!
-				Auth:   "kSC3T8aN1JCQxxPdrFLrZg",
-				P256dh: "BMKKbxdUU_xLS7G1Wh5AN8PvWOjCzkCuKZYb8apcqYrDxjOF_2piggBnoJLQYx9IeSD70fNuwawI3e9Y8m3S3PE",
-			},
+	err := s.webPush.AddSubscription(topic, "", webpush.Subscription{
+		Endpoint: url,
+		Keys: webpush.Keys{
+			// connected to a local test VAPID key, not a leak!
+			Auth:   "kSC3T8aN1JCQxxPdrFLrZg",
+			P256dh: "BMKKbxdUU_xLS7G1Wh5AN8PvWOjCzkCuKZYb8apcqYrDxjOF_2piggBnoJLQYx9IeSD70fNuwawI3e9Y8m3S3PE",
 		},
 	})
 	require.Nil(t, err)

+ 5 - 7
server/types.go

@@ -1,12 +1,13 @@
 package server
 
 import (
-	"heckel.io/ntfy/log"
-	"heckel.io/ntfy/user"
 	"net/http"
 	"net/netip"
 	"time"
 
+	"heckel.io/ntfy/log"
+	"heckel.io/ntfy/user"
+
 	"github.com/SherClockHolmes/webpush-go"
 	"heckel.io/ntfy/util"
 )
@@ -476,10 +477,7 @@ type webPushSubscription struct {
 	UserID              string
 }
 
-type webPushSubscribePayload struct {
+type webPushSubscriptionPayload struct {
 	BrowserSubscription webpush.Subscription `json:"browser_subscription"`
-}
-
-type webPushUnsubscribePayload struct {
-	Endpoint string `json:"endpoint"`
+	Topics              []string             `json:"topics"`
 }

+ 25 - 13
server/web_push.go

@@ -2,7 +2,9 @@ package server
 
 import (
 	"database/sql"
+	"fmt"
 
+	"github.com/SherClockHolmes/webpush-go"
 	_ "github.com/mattn/go-sqlite3" // SQLite driver
 )
 
@@ -69,23 +71,33 @@ func setupNewSubscriptionsDB(db *sql.DB) error {
 	return nil
 }
 
-func (c *webPushStore) AddSubscription(topic string, userID string, subscription webPushSubscribePayload) error {
-	_, err := c.db.Exec(
-		insertWebPushSubscriptionQuery,
-		topic,
-		userID,
-		subscription.BrowserSubscription.Endpoint,
-		subscription.BrowserSubscription.Keys.Auth,
-		subscription.BrowserSubscription.Keys.P256dh,
-	)
-	return err
+func (c *webPushStore) UpdateSubscriptions(topics []string, userID string, subscription webpush.Subscription) error {
+	fmt.Printf("AAA")
+	tx, err := c.db.Begin()
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback()
+
+	if err = c.RemoveByEndpoint(subscription.Endpoint); err != nil {
+		return err
+	}
+	for _, topic := range topics {
+		if err := c.AddSubscription(topic, userID, subscription); err != nil {
+			return err
+		}
+	}
+	return tx.Commit()
 }
 
-func (c *webPushStore) RemoveSubscription(topic string, endpoint string) error {
+func (c *webPushStore) AddSubscription(topic string, userID string, subscription webpush.Subscription) error {
 	_, err := c.db.Exec(
-		deleteWebPushSubscriptionByTopicAndEndpointQuery,
+		insertWebPushSubscriptionQuery,
 		topic,
-		endpoint,
+		userID,
+		subscription.Endpoint,
+		subscription.Keys.Auth,
+		subscription.Keys.P256dh,
 	)
 	return err
 }

+ 4 - 5
web/public/static/langs/en.json

@@ -52,6 +52,8 @@
   "nav_button_connecting": "connecting",
   "nav_upgrade_banner_label": "Upgrade to ntfy Pro",
   "nav_upgrade_banner_description": "Reserve topics, more messages & emails, and larger attachments",
+  "alert_notification_permission_required_title": "Permission required",
+  "alert_notification_permission_required_description": "Please click here to enable notifications",
   "alert_notification_permission_denied_title": "Notifications are blocked",
   "alert_notification_permission_denied_description": "Please re-enable them in your browser and refresh the page to receive notifications",
   "alert_notification_ios_install_required_title": "iOS Install Required",
@@ -94,9 +96,7 @@
   "notifications_example": "Example",
   "notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
   "notification_toggle_unmute": "Unmute",
-  "notification_toggle_sound": "Sound only",
-  "notification_toggle_browser": "Browser notifications",
-  "notification_toggle_background": "Browser and background notifications",
+  "notification_toggle_background": "Background notifications",
   "display_name_dialog_title": "Change display name",
   "display_name_dialog_description": "Set an alternative name for a topic that is displayed in the subscription list. This helps identify topics with complicated names more easily.",
   "display_name_dialog_placeholder": "Display name",
@@ -169,8 +169,7 @@
   "subscribe_dialog_subscribe_description": "Topics may not be password-protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications.",
   "subscribe_dialog_subscribe_topic_placeholder": "Topic name, e.g. phil_alerts",
   "subscribe_dialog_subscribe_use_another_label": "Use another server",
-  "subscribe_dialog_subscribe_enable_browser_notifications_label": "Notify me via browser notifications",
-  "subscribe_dialog_subscribe_enable_background_notifications_label": "Also notify me when ntfy is not open (web push)",
+  "subscribe_dialog_subscribe_enable_background_notifications_label": "Enable background notifications (web push)",
   "subscribe_dialog_subscribe_base_url_label": "Service URL",
   "subscribe_dialog_subscribe_button_generate_topic_name": "Generate name",
   "subscribe_dialog_subscribe_button_cancel": "Cancel",

+ 7 - 29
web/src/app/Api.js

@@ -6,8 +6,7 @@ import {
   topicUrlAuth,
   topicUrlJsonPoll,
   topicUrlJsonPollWithSince,
-  topicUrlWebPushSubscribe,
-  topicUrlWebPushUnsubscribe,
+  webPushSubscriptionsUrl,
 } from "./utils";
 import userManager from "./UserManager";
 import { fetchOrThrow } from "./errors";
@@ -116,36 +115,15 @@ class Api {
     throw new Error(`Unexpected server response ${response.status}`);
   }
 
-  async subscribeWebPush(baseUrl, topic, browserSubscription) {
-    const user = await userManager.get(baseUrl);
-    const url = topicUrlWebPushSubscribe(baseUrl, topic);
-    console.log(`[Api] Sending Web Push Subscription ${url}`);
+  async updateWebPushSubscriptions(topics, browserSubscription) {
+    const user = await userManager.get(config.base_url);
+    const url = webPushSubscriptionsUrl(config.base_url);
+    console.log(`[Api] Sending Web Push Subscriptions`, { url, topics, endpoint: browserSubscription.endpoint });
 
     const response = await fetch(url, {
-      method: "POST",
-      headers: maybeWithAuth({}, user),
-      body: JSON.stringify({ browser_subscription: browserSubscription }),
-    });
-
-    if (response.ok) {
-      return true;
-    }
-
-    throw new Error(`Unexpected server response ${response.status}`);
-  }
-
-  async unsubscribeWebPush(subscription, browserSubscription) {
-    const user = await userManager.get(subscription.baseUrl);
-
-    const url = topicUrlWebPushUnsubscribe(subscription.baseUrl, subscription.topic);
-    console.log(`[Api] Unsubscribing Web Push Subscription ${url}`);
-
-    const response = await fetch(url, {
-      method: "POST",
+      method: "PUT",
       headers: maybeWithAuth({}, user),
-      body: JSON.stringify({
-        endpoint: browserSubscription.endpoint,
-      }),
+      body: JSON.stringify({ topics, browser_subscription: browserSubscription }),
     });
 
     if (response.ok) {

+ 3 - 6
web/src/app/ConnectionManager.js

@@ -1,5 +1,4 @@
 import Connection from "./Connection";
-import { NotificationType } from "./SubscriptionManager";
 import { hashCode } from "./utils";
 
 const makeConnectionId = (subscription, user) =>
@@ -52,11 +51,9 @@ class ConnectionManager {
         const connectionId = makeConnectionId(s, user);
         return { ...s, user, connectionId };
       })
-      // we want to create a ws for both sound-only and active browser notifications,
-      // only background notifications don't need this as they come over web push.
-      // however, if background notifications are muted, we again need the ws while
-      // the page is active
-      .filter((s) => s.notificationType !== NotificationType.BACKGROUND && s.mutedUntil !== 1);
+      // 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();
     const targetIds = subscriptionsWithUsersAndConnectionId.map((s) => s.connectionId);

+ 18 - 35
web/src/app/Notifier.js

@@ -2,7 +2,6 @@ import { openUrl, playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array
 import { formatMessage, formatTitleWithDefault } from "./notificationUtils";
 import prefs from "./Prefs";
 import logo from "../img/ntfy.png";
-import api from "./Api";
 
 /**
  * The notifier is responsible for displaying desktop notifications. Note that not all modern browsers
@@ -45,44 +44,20 @@ class Notifier {
     }
   }
 
-  async unsubscribeWebPush(subscription) {
-    try {
-      const pushManager = await this.pushManager();
-      const browserSubscription = await pushManager.getSubscription();
-      if (!browserSubscription) {
-        throw new Error("No browser subscription found");
-      }
-      await api.unsubscribeWebPush(subscription, browserSubscription);
-    } catch (e) {
-      console.error("[Notifier] Error unsubscribing from web push", e);
+  async getBrowserSubscription() {
+    if (!this.pushPossible()) {
+      throw new Error("Unsupported or denied");
     }
-  }
 
-  async subscribeWebPush(baseUrl, topic) {
-    if (!this.supported() || !this.pushSupported() || !config.enable_web_push) {
-      return {};
-    }
+    const pushManager = await this.pushManager();
 
-    // only subscribe to web push for the current server. this is a limitation of the web push API,
-    // which only allows a single server per service worker origin.
-    if (baseUrl !== config.base_url) {
-      return {};
-    }
-
-    try {
-      const pushManager = await this.pushManager();
-      const browserSubscription = await pushManager.subscribe({
+    return (
+      (await pushManager.getSubscription()) ??
+      pushManager.subscribe({
         userVisibleOnly: true,
         applicationServerKey: urlB64ToUint8Array(config.web_push_public_key),
-      });
-
-      await api.subscribeWebPush(baseUrl, topic, browserSubscription);
-      console.log("[Notifier.subscribeWebPush] Successfully subscribed to web push");
-    } catch (e) {
-      console.error("[Notifier.subscribeWebPush] Error subscribing to web push", e);
-    }
-
-    return {};
+      })
+    );
   }
 
   async pushManager() {
@@ -95,6 +70,10 @@ class Notifier {
     return registration.pushManager;
   }
 
+  notRequested() {
+    return this.supported() && Notification.permission === "default";
+  }
+
   granted() {
     return this.supported() && Notification.permission === "granted";
   }
@@ -127,6 +106,10 @@ class Notifier {
     return config.enable_web_push && "serviceWorker" in navigator && "PushManager" in window;
   }
 
+  pushPossible() {
+    return this.pushSupported() && this.contextSupported() && this.granted() && !this.iosSupportedButInstallRequired();
+  }
+
   /**
    * 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
@@ -136,7 +119,7 @@ class Notifier {
   }
 
   iosSupportedButInstallRequired() {
-    return "standalone" in window.navigator && window.navigator.standalone === false;
+    return this.pushSupported() && "standalone" in window.navigator && window.navigator.standalone === false;
   }
 }
 

+ 22 - 82
web/src/app/SubscriptionManager.js

@@ -1,20 +1,9 @@
+import api from "./Api";
 import notifier from "./Notifier";
 import prefs from "./Prefs";
 import getDb from "./getDb";
 import { topicUrl } from "./utils";
 
-/** @typedef {string} NotificationTypeEnum */
-
-/** @enum {NotificationTypeEnum} */
-export const NotificationType = {
-  /** sound-only */
-  SOUND: "sound",
-  /** browser notifications when there is an active tab, via websockets */
-  BROWSER: "browser",
-  /** web push notifications, regardless of whether the window is open */
-  BACKGROUND: "background",
-};
-
 class SubscriptionManager {
   constructor(db) {
     this.db = db;
@@ -31,6 +20,11 @@ class SubscriptionManager {
     );
   }
 
+  async webPushTopics() {
+    const subscriptions = await this.db.subscriptions.where({ webPushEnabled: 1, mutedUntil: 0 }).toArray();
+    return subscriptions.map(({ topic }) => topic);
+  }
+
   async get(subscriptionId) {
     return this.db.subscriptions.get(subscriptionId);
   }
@@ -47,14 +41,7 @@ class SubscriptionManager {
       return;
     }
 
-    await notifier.playSound();
-
-    // sound only
-    if (subscription.notificationType === "sound") {
-      return;
-    }
-
-    await notifier.notify(subscription, notification, defaultClickAction);
+    await Promise.all([notifier.playSound(), notifier.notify(subscription, notification, defaultClickAction)]);
   }
 
   /**
@@ -62,28 +49,25 @@ class SubscriptionManager {
    * @param {string} topic
    * @param {object} opts
    * @param {boolean} opts.internal
-   * @param {NotificationTypeEnum} opts.notificationType
+   * @param {boolean} opts.webPushEnabled
    * @returns
    */
   async add(baseUrl, topic, opts = {}) {
     const id = topicUrl(baseUrl, topic);
 
-    if (opts.notificationType === "background") {
-      await notifier.subscribeWebPush(baseUrl, topic);
-    }
-
     const existingSubscription = await this.get(id);
     if (existingSubscription) {
       return existingSubscription;
     }
 
     const subscription = {
+      ...opts,
       id: topicUrl(baseUrl, topic),
       baseUrl,
       topic,
       mutedUntil: 0,
       last: null,
-      ...opts,
+      webPushEnabled: opts.webPushEnabled ? 1 : 0,
     };
 
     await this.db.subscriptions.put(subscription);
@@ -94,17 +78,16 @@ class SubscriptionManager {
   async syncFromRemote(remoteSubscriptions, remoteReservations) {
     console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
 
-    const notificationType = (await prefs.webPushDefaultEnabled()) === "enabled" ? "background" : "browser";
+    const webPushEnabled = (await prefs.webPushDefaultEnabled()) === "enabled";
 
     // Add remote subscriptions
     const remoteIds = await Promise.all(
       remoteSubscriptions.map(async (remote) => {
-        const local = await this.add(remote.base_url, remote.topic, {
-          notificationType,
-        });
         const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
 
-        await this.update(local.id, {
+        const local = await this.add(remote.base_url, remote.topic, {
+          // only if same-origin subscription
+          webPushEnabled: webPushEnabled && remote.base_url === config.base_url,
           displayName: remote.display_name, // May be undefined
           reservation, // May be null!
         });
@@ -126,6 +109,12 @@ class SubscriptionManager {
     );
   }
 
+  async refreshWebPushSubscriptions(presetTopics) {
+    const topics = presetTopics ?? (await this.webPushTopics());
+
+    await api.updateWebPushSubscriptions(topics, await notifier.getBrowserSubscription());
+  }
+
   async updateState(subscriptionId, state) {
     this.db.subscriptions.update(subscriptionId, { state });
   }
@@ -133,10 +122,6 @@ class SubscriptionManager {
   async remove(subscription) {
     await this.db.subscriptions.delete(subscription.id);
     await this.db.notifications.where({ subscriptionId: subscription.id }).delete();
-
-    if (subscription.notificationType === NotificationType.BACKGROUND) {
-      await notifier.unsubscribeWebPush(subscription);
-    }
   }
 
   async first() {
@@ -228,59 +213,14 @@ class SubscriptionManager {
     await this.db.subscriptions.update(subscriptionId, {
       mutedUntil,
     });
-
-    const subscription = await this.get(subscriptionId);
-
-    if (subscription.notificationType === "background") {
-      if (mutedUntil === 1) {
-        await notifier.unsubscribeWebPush(subscription);
-      } else {
-        await notifier.subscribeWebPush(subscription.baseUrl, subscription.topic);
-      }
-    }
   }
 
-  /**
-   *
-   * @param {object} subscription
-   * @param {NotificationTypeEnum} newNotificationType
-   * @returns
-   */
-  async setNotificationType(subscription, newNotificationType) {
-    const oldNotificationType = subscription.notificationType ?? "browser";
-
-    if (oldNotificationType === newNotificationType) {
-      return;
-    }
-
-    if (oldNotificationType === "background") {
-      await notifier.unsubscribeWebPush(subscription);
-    } else if (newNotificationType === "background") {
-      await notifier.subscribeWebPush(subscription.baseUrl, subscription.topic);
-    }
-
+  async toggleBackgroundNotifications(subscription) {
     await this.db.subscriptions.update(subscription.id, {
-      notificationType: newNotificationType,
+      webPushEnabled: subscription.webPushEnabled === 1 ? 0 : 1,
     });
   }
 
-  // for logout/delete, unsubscribe first to prevent receiving dangling notifications
-  async unsubscribeAllWebPush() {
-    const subscriptions = await this.db.subscriptions.where({ notificationType: "background" }).toArray();
-    await Promise.all(subscriptions.map((subscription) => notifier.unsubscribeWebPush(subscription)));
-  }
-
-  async refreshWebPushSubscriptions() {
-    const subscriptions = await this.db.subscriptions.where({ notificationType: "background" }).toArray();
-    const browserSubscription = await (await navigator.serviceWorker.getRegistration())?.pushManager?.getSubscription();
-
-    if (browserSubscription) {
-      await Promise.all(subscriptions.map((subscription) => notifier.subscribeWebPush(subscription.baseUrl, subscription.topic)));
-    } else {
-      await Promise.all(subscriptions.map((subscription) => this.setNotificationType(subscription, "sound")));
-    }
-  }
-
   async setDisplayName(subscriptionId, displayName) {
     await this.db.subscriptions.update(subscriptionId, {
       displayName,

+ 51 - 13
web/src/app/WebPushWorker.js

@@ -1,16 +1,40 @@
+import { useState, useEffect } from "react";
+import { useLiveQuery } from "dexie-react-hooks";
 import notifier from "./Notifier";
 import subscriptionManager from "./SubscriptionManager";
 
-const onMessage = () => {
-  notifier.playSound();
+export const useWebPushUpdateWorker = () => {
+  const topics = useLiveQuery(() => subscriptionManager.webPushTopics());
+  const [lastTopics, setLastTopics] = useState();
+
+  useEffect(() => {
+    if (!notifier.pushPossible() || JSON.stringify(topics) === JSON.stringify(lastTopics)) {
+      return;
+    }
+
+    (async () => {
+      try {
+        console.log("[useWebPushUpdateWorker] Refreshing web push subscriptions");
+
+        await subscriptionManager.refreshWebPushSubscriptions(topics);
+
+        setLastTopics(topics);
+      } catch (e) {
+        console.error("[useWebPushUpdateWorker] Error refreshing web push subscriptions", e);
+      }
+    })();
+  }, [topics, lastTopics]);
 };
 
-const delayMillis = 2000; // 2 seconds
-const intervalMillis = 300000; // 5 minutes
+const intervalMillis = 5 * 60 * 1_000; // 5 minutes
+const updateIntervalMillis = 60 * 60 * 1_000; // 1 hour
 
-class WebPushWorker {
+class WebPushRefreshWorker {
   constructor() {
     this.timer = null;
+    this.lastUpdate = null;
+    this.messageHandler = this.onMessage.bind(this);
+    this.visibilityHandler = this.onVisibilityChange.bind(this);
   }
 
   startWorker() {
@@ -19,28 +43,42 @@ class WebPushWorker {
     }
 
     this.timer = setInterval(() => this.updateSubscriptions(), intervalMillis);
-    setTimeout(() => this.updateSubscriptions(), delayMillis);
 
     this.broadcastChannel = new BroadcastChannel("web-push-broadcast");
-    this.broadcastChannel.addEventListener("message", onMessage);
+    this.broadcastChannel.addEventListener("message", this.messageHandler);
+
+    document.addEventListener("visibilitychange", this.visibilityHandler);
   }
 
   stopWorker() {
     clearTimeout(this.timer);
 
-    this.broadcastChannel.removeEventListener("message", onMessage);
+    this.broadcastChannel.removeEventListener("message", this.messageHandler);
     this.broadcastChannel.close();
+
+    document.removeEventListener("visibilitychange", this.visibilityHandler);
+  }
+
+  onMessage() {
+    notifier.playSound();
+  }
+
+  onVisibilityChange() {
+    if (document.visibilityState === "visible") {
+      this.updateSubscriptions();
+    }
   }
 
   async updateSubscriptions() {
-    try {
-      console.log("[WebPushBroadcastListener] Refreshing web push subscriptions");
+    if (!notifier.pushPossible()) {
+      return;
+    }
 
+    if (!this.lastUpdate || Date.now() - this.lastUpdate > updateIntervalMillis) {
       await subscriptionManager.refreshWebPushSubscriptions();
-    } catch (e) {
-      console.error("[WebPushBroadcastListener] Error refreshing web push subscriptions", e);
+      this.lastUpdate = Date.now();
     }
   }
 }
 
-export default new WebPushWorker();
+export const webPushRefreshWorker = new WebPushRefreshWorker();

+ 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,notificationType",
+    subscriptions: "&id,baseUrl,[webPushEnabled+mutedUntil]",
     notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
     users: "&baseUrl,username",
     prefs: "&key",

+ 1 - 2
web/src/app/utils.js

@@ -20,9 +20,8 @@ export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/jso
 export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;
 export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
 export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
-export const topicUrlWebPushSubscribe = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/web-push/subscribe`;
-export const topicUrlWebPushUnsubscribe = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/web-push/unsubscribe`;
 export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
+export const webPushSubscriptionsUrl = (baseUrl) => `${baseUrl}/v1/account/web-push`;
 export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
 export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
 export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;

+ 0 - 2
web/src/components/Account.jsx

@@ -1078,8 +1078,6 @@ const DeleteAccountDialog = (props) => {
 
   const handleSubmit = async () => {
     try {
-      await subscriptionManager.unsubscribeAllWebPush();
-
       await accountApi.delete(password);
       await getDb().delete();
       console.debug(`[Account] Account deleted`);

+ 0 - 2
web/src/components/ActionBar.jsx

@@ -120,8 +120,6 @@ const ProfileIcon = () => {
 
   const handleLogout = async () => {
     try {
-      await subscriptionManager.unsubscribeAllWebPush();
-
       await accountApi.logout();
       await getDb().delete();
     } finally {

+ 56 - 35
web/src/components/Navigation.jsx

@@ -108,27 +108,34 @@ const NavList = (props) => {
   const isPaid = account?.billing?.subscription;
   const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
   const showSubscriptionsList = props.subscriptions?.length > 0;
-  const showNotificationPermissionDenied = notifier.denied();
+  const [showNotificationPermissionRequired, setShowNotificationPermissionRequired] = useState(notifier.notRequested());
+  const [showNotificationPermissionDenied, setShowNotificationPermissionDenied] = useState(notifier.denied());
   const showNotificationIOSInstallRequired = notifier.iosSupportedButInstallRequired();
   const showNotificationBrowserNotSupportedBox = !showNotificationIOSInstallRequired && !notifier.browserSupported();
   const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
 
-  const navListPadding =
+  const refreshPermissions = () => {
+    setShowNotificationPermissionRequired(notifier.notRequested());
+    setShowNotificationPermissionDenied(notifier.denied());
+  };
+
+  const alertVisible =
+    showNotificationPermissionRequired ||
     showNotificationPermissionDenied ||
     showNotificationIOSInstallRequired ||
     showNotificationBrowserNotSupportedBox ||
-    showNotificationContextNotSupportedBox
-      ? "0"
-      : "";
+    showNotificationContextNotSupportedBox;
 
   return (
     <>
       <Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
-      <List component="nav" sx={{ paddingTop: navListPadding }}>
+      <List component="nav" sx={{ paddingTop: alertVisible ? "0" : "" }}>
+        {showNotificationPermissionRequired && <NotificationPermissionRequired refreshPermissions={refreshPermissions} />}
         {showNotificationPermissionDenied && <NotificationPermissionDeniedAlert />}
         {showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />}
         {showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert />}
         {showNotificationIOSInstallRequired && <NotificationIOSInstallRequiredAlert />}
+        {alertVisible && <Divider />}
         {!showSubscriptionsList && (
           <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
             <ListItemIcon>
@@ -346,16 +353,36 @@ const SubscriptionItem = (props) => {
   );
 };
 
+const NotificationPermissionRequired = ({ refreshPermissions }) => {
+  const { t } = useTranslation();
+  return (
+    <Alert severity="info" sx={{ paddingTop: 2 }}>
+      <AlertTitle>{t("alert_notification_permission_required_title")}</AlertTitle>
+      <Typography gutterBottom align="left">
+        {/* component=Button is not an anchor, false positive */}
+        {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
+        <Link
+          component="button"
+          style={{ textAlign: "left" }}
+          onClick={async () => {
+            await notifier.maybeRequestPermission();
+            refreshPermissions();
+          }}
+        >
+          {t("alert_notification_permission_required_description")}
+        </Link>
+      </Typography>
+    </Alert>
+  );
+};
+
 const NotificationPermissionDeniedAlert = () => {
   const { t } = useTranslation();
   return (
-    <>
-      <Alert severity="warning" sx={{ paddingTop: 2 }}>
-        <AlertTitle>{t("alert_notification_permission_denied_title")}</AlertTitle>
-        <Typography gutterBottom>{t("alert_notification_permission_denied_description")}</Typography>
-      </Alert>
-      <Divider />
-    </>
+    <Alert severity="warning" sx={{ paddingTop: 2 }}>
+      <AlertTitle>{t("alert_notification_permission_denied_title")}</AlertTitle>
+      <Typography gutterBottom>{t("alert_notification_permission_denied_description")}</Typography>
+    </Alert>
   );
 };
 
@@ -363,7 +390,7 @@ const NotificationIOSInstallRequiredAlert = () => {
   const { t } = useTranslation();
   return (
     <>
-      <Alert severity="warning" sx={{ paddingTop: 2 }}>
+      <Alert severity="info" sx={{ paddingTop: 2 }}>
         <AlertTitle>{t("alert_notification_ios_install_required_title")}</AlertTitle>
         <Typography gutterBottom>{t("alert_notification_ios_install_required_description")}</Typography>
       </Alert>
@@ -375,33 +402,27 @@ const NotificationIOSInstallRequiredAlert = () => {
 const NotificationBrowserNotSupportedAlert = () => {
   const { t } = useTranslation();
   return (
-    <>
-      <Alert severity="warning" sx={{ paddingTop: 2 }}>
-        <AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
-        <Typography gutterBottom>{t("alert_not_supported_description")}</Typography>
-      </Alert>
-      <Divider />
-    </>
+    <Alert severity="warning" sx={{ paddingTop: 2 }}>
+      <AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
+      <Typography gutterBottom>{t("alert_not_supported_description")}</Typography>
+    </Alert>
   );
 };
 
 const NotificationContextNotSupportedAlert = () => {
   const { t } = useTranslation();
   return (
-    <>
-      <Alert severity="warning" sx={{ paddingTop: 2 }}>
-        <AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
-        <Typography gutterBottom>
-          <Trans
-            i18nKey="alert_not_supported_context_description"
-            components={{
-              mdnLink: <Link href="https://developer.mozilla.org/en-US/docs/Web/API/notification" target="_blank" rel="noopener" />,
-            }}
-          />
-        </Typography>
-      </Alert>
-      <Divider />
-    </>
+    <Alert severity="warning" sx={{ paddingTop: 2 }}>
+      <AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
+      <Typography gutterBottom>
+        <Trans
+          i18nKey="alert_not_supported_context_description"
+          components={{
+            mdnLink: <Link href="https://developer.mozilla.org/en-US/docs/Web/API/notification" target="_blank" rel="noopener" />,
+          }}
+        />
+      </Typography>
+    </Alert>
   );
 };
 

+ 1 - 1
web/src/components/Preferences.jsx

@@ -86,7 +86,7 @@ const Notifications = () => {
         <Sound />
         <MinPriority />
         <DeleteAfter />
-        {notifier.pushSupported() && <WebPushDefaultEnabled />}
+        {notifier.pushPossible() && <WebPushDefaultEnabled />}
       </PrefGroup>
     </Card>
   );

+ 11 - 71
web/src/components/SubscribeDialog.jsx

@@ -12,16 +12,14 @@ import {
   FormGroup,
   useMediaQuery,
   Switch,
-  Stack,
 } from "@mui/material";
 import { useTranslation } from "react-i18next";
-import { Warning } from "@mui/icons-material";
 import { useLiveQuery } from "dexie-react-hooks";
 import theme from "./theme";
 import api from "../app/Api";
 import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils";
 import userManager from "../app/UserManager";
-import subscriptionManager, { NotificationType } from "../app/SubscriptionManager";
+import subscriptionManager from "../app/SubscriptionManager";
 import poller from "../app/Poller";
 import DialogFooter from "./DialogFooter";
 import session from "../app/Session";
@@ -59,16 +57,16 @@ const SubscribeDialog = (props) => {
 
   const webPushDefaultEnabled = useLiveQuery(async () => prefs.webPushDefaultEnabled());
 
-  const handleSuccess = async (notificationType) => {
+  const handleSuccess = async (webPushEnabled) => {
     console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
     const actualBaseUrl = baseUrl || config.base_url;
     const subscription = await subscribeTopic(actualBaseUrl, topic, {
-      notificationType,
+      webPushEnabled,
     });
     poller.pollInBackground(subscription); // Dangle!
 
     // if the user hasn't changed the default web push setting yet, set it to enabled
-    if (notificationType === "background" && webPushDefaultEnabled === "initial") {
+    if (webPushEnabled && webPushDefaultEnabled === "initial") {
       await prefs.setWebPushDefaultEnabled(true);
     }
 
@@ -100,23 +98,6 @@ const SubscribeDialog = (props) => {
   );
 };
 
-const browserNotificationsSupported = notifier.supported();
-const pushNotificationsSupported = notifier.pushSupported();
-const iosInstallRequired = notifier.iosSupportedButInstallRequired();
-const pushPossible = pushNotificationsSupported && iosInstallRequired;
-
-const getNotificationTypeFromToggles = (browserNotificationsEnabled, backgroundNotificationsEnabled) => {
-  if (backgroundNotificationsEnabled) {
-    return NotificationType.BACKGROUND;
-  }
-
-  if (browserNotificationsEnabled) {
-    return NotificationType.BROWSER;
-  }
-
-  return NotificationType.SOUND;
-};
-
 const SubscribePage = (props) => {
   const { t } = useTranslation();
   const { account } = useContext(AccountContext);
@@ -134,27 +115,7 @@ const SubscribePage = (props) => {
   const reserveTopicEnabled =
     session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0));
 
-  // load initial value, but update it in `handleBrowserNotificationsChanged`
-  // if we interact with the API and therefore possibly change it (from default -> denied)
-  const [notificationsExplicitlyDenied, setNotificationsExplicitlyDenied] = useState(notifier.denied());
-  // default to on if notifications are already granted
-  const [browserNotificationsEnabled, setBrowserNotificationsEnabled] = useState(notifier.granted());
-  const [backgroundNotificationsEnabled, setBackgroundNotificationsEnabled] = useState(
-    pushPossible && props.webPushDefaultEnabled === "enabled"
-  );
-
-  const handleBrowserNotificationsChanged = async (e) => {
-    if (e.target.checked && (await notifier.maybeRequestPermission())) {
-      setBrowserNotificationsEnabled(true);
-      if (pushPossible && props.webPushDefaultEnabled === "enabled") {
-        setBackgroundNotificationsEnabled(true);
-      }
-    } else {
-      setNotificationsExplicitlyDenied(notifier.denied());
-      setBrowserNotificationsEnabled(false);
-      setBackgroundNotificationsEnabled(false);
-    }
-  };
+  const [backgroundNotificationsEnabled, setBackgroundNotificationsEnabled] = useState(props.webPushDefaultEnabled === "enabled");
 
   const handleBackgroundNotificationsChanged = (e) => {
     setBackgroundNotificationsEnabled(e.target.checked);
@@ -197,7 +158,7 @@ const SubscribePage = (props) => {
     }
 
     console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
-    props.onSuccess(getNotificationTypeFromToggles(browserNotificationsEnabled, backgroundNotificationsEnabled));
+    props.onSuccess(backgroundNotificationsEnabled);
   };
 
   const handleUseAnotherChanged = (e) => {
@@ -311,41 +272,20 @@ const SubscribePage = (props) => {
             )}
           </FormGroup>
         )}
-        {browserNotificationsSupported && (
+        {notifier.pushPossible() && !anotherServerVisible && (
           <FormGroup>
             <FormControlLabel
               control={
                 <Switch
-                  onChange={handleBrowserNotificationsChanged}
-                  checked={browserNotificationsEnabled}
-                  disabled={notificationsExplicitlyDenied}
+                  onChange={handleBackgroundNotificationsChanged}
+                  checked={backgroundNotificationsEnabled}
                   inputProps={{
-                    "aria-label": t("subscribe_dialog_subscribe_enable_browser_notifications_label"),
+                    "aria-label": t("subscribe_dialog_subscribe_enable_background_notifications_label"),
                   }}
                 />
               }
-              label={
-                <Stack direction="row" gap={1} alignItems="center">
-                  {t("subscribe_dialog_subscribe_enable_browser_notifications_label")}
-                  {notificationsExplicitlyDenied && <Warning />}
-                </Stack>
-              }
+              label={t("subscribe_dialog_subscribe_enable_background_notifications_label")}
             />
-            {pushNotificationsSupported && !anotherServerVisible && browserNotificationsEnabled && (
-              <FormControlLabel
-                control={
-                  <Switch
-                    onChange={handleBackgroundNotificationsChanged}
-                    checked={backgroundNotificationsEnabled}
-                    disabled={iosInstallRequired}
-                    inputProps={{
-                      "aria-label": t("subscribe_dialog_subscribe_enable_background_notifications_label"),
-                    }}
-                  />
-                }
-                label={t("subscribe_dialog_subscribe_enable_background_notifications_label")}
-              />
-            )}
           </FormGroup>
         )}
       </DialogContent>

+ 11 - 40
web/src/components/SubscriptionPopup.jsx

@@ -33,7 +33,7 @@ import {
   Send,
 } from "@mui/icons-material";
 import theme from "./theme";
-import subscriptionManager, { NotificationType } from "../app/SubscriptionManager";
+import subscriptionManager from "../app/SubscriptionManager";
 import DialogFooter from "./DialogFooter";
 import accountApi, { Role } from "../app/AccountApi";
 import session from "../app/Session";
@@ -334,14 +334,6 @@ const DisplayNameDialog = (props) => {
   );
 };
 
-const getNotificationType = (subscription) => {
-  if (subscription.mutedUntil === 1) {
-    return "muted";
-  }
-
-  return subscription.notificationType ?? NotificationType.BROWSER;
-};
-
 const checkedItem = (
   <ListItemIcon>
     <Check />
@@ -350,15 +342,10 @@ const checkedItem = (
 
 const NotificationToggle = ({ subscription }) => {
   const { t } = useTranslation();
-  const type = getNotificationType(subscription);
 
-  const handleChange = async (newType) => {
+  const handleToggleBackground = async () => {
     try {
-      if (newType !== NotificationType.SOUND && !(await notifier.maybeRequestPermission())) {
-        return;
-      }
-
-      await subscriptionManager.setNotificationType(subscription, newType);
+      await subscriptionManager.toggleBackgroundNotifications(subscription);
     } catch (e) {
       console.error("[NotificationToggle] Error setting notification type", e);
     }
@@ -368,7 +355,7 @@ const NotificationToggle = ({ subscription }) => {
     await subscriptionManager.setMutedUntil(subscription.id, 0);
   };
 
-  if (type === "muted") {
+  if (subscription.mutedUntil === 1) {
     return (
       <MenuItem onClick={unmute}>
         <ListItemIcon>
@@ -381,30 +368,14 @@ const NotificationToggle = ({ subscription }) => {
 
   return (
     <>
-      <MenuItem>
-        {type === NotificationType.SOUND && checkedItem}
-        <ListItemText inset={type !== NotificationType.SOUND} onClick={() => handleChange(NotificationType.SOUND)}>
-          {t("notification_toggle_sound")}
-        </ListItemText>
-      </MenuItem>
-      {!notifier.denied() && !notifier.iosSupportedButInstallRequired() && (
+      {notifier.pushPossible() && (
         <>
-          {notifier.supported() && (
-            <MenuItem>
-              {type === NotificationType.BROWSER && checkedItem}
-              <ListItemText inset={type !== NotificationType.BROWSER} onClick={() => handleChange(NotificationType.BROWSER)}>
-                {t("notification_toggle_browser")}
-              </ListItemText>
-            </MenuItem>
-          )}
-          {notifier.pushSupported() && (
-            <MenuItem>
-              {type === NotificationType.BACKGROUND && checkedItem}
-              <ListItemText inset={type !== NotificationType.BACKGROUND} onClick={() => handleChange(NotificationType.BACKGROUND)}>
-                {t("notification_toggle_background")}
-              </ListItemText>
-            </MenuItem>
-          )}
+          <MenuItem>
+            {subscription.webPushEnabled === 1 && checkedItem}
+            <ListItemText inset={subscription.webPushEnabled !== 1} onClick={handleToggleBackground}>
+              {t("notification_toggle_background")}
+            </ListItemText>
+          </MenuItem>
         </>
       )}
     </>

+ 6 - 3
web/src/components/hooks.js

@@ -9,7 +9,8 @@ import pruner from "../app/Pruner";
 import session from "../app/Session";
 import accountApi from "../app/AccountApi";
 import { UnauthorizedError } from "../app/errors";
-import webPushWorker from "../app/WebPushWorker";
+import { webPushRefreshWorker, useWebPushUpdateWorker } from "../app/WebPushWorker";
+import notifier from "../app/Notifier";
 
 /**
  * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
@@ -134,24 +135,26 @@ const stopWorkers = () => {
   poller.stopWorker();
   pruner.stopWorker();
   accountApi.stopWorker();
+  webPushRefreshWorker.stopWorker();
 };
 
 const startWorkers = () => {
   poller.startWorker();
   pruner.startWorker();
   accountApi.startWorker();
+  webPushRefreshWorker.startWorker();
 };
 
 export const useBackgroundProcesses = () => {
+  useWebPushUpdateWorker();
+
   useEffect(() => {
     console.log("[useBackgroundProcesses] mounting");
     startWorkers();
-    webPushWorker.startWorker();
 
     return () => {
       console.log("[useBackgroundProcesses] unloading");
       stopWorkers();
-      webPushWorker.stopWorker();
     };
   }, []);
 };