From 0f0074cbab14ddb77bc9135b02b4dd60e3581a58 Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Fri, 2 Jun 2023 14:45:05 +0200 Subject: [PATCH] Implement push subscription expiry --- cmd/serve.go | 8 +++ docs/config.md | 10 +++ docs/subscribe/web.md | 15 ++--- server/config.go | 10 +++ server/log.go | 1 + server/server.yml | 2 + server/server_manager.go | 3 + server/server_web_push.go | 92 ++++++++++++++++----------- server/server_web_push_test.go | 38 ++++++++++- server/types.go | 19 ++++++ server/web_push.go | 55 +++++++++++++++- web/public/sw.js | 112 +++++++++++++++++++-------------- web/src/app/Prefs.js | 3 +- web/src/components/Account.jsx | 1 - web/src/components/App.jsx | 4 +- web/src/components/hooks.js | 1 - 16 files changed, 272 insertions(+), 102 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 4ec94945..a77227e6 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -99,6 +99,8 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-private-key", Aliases: []string{"web_push_private_key"}, EnvVars: []string{"NTFY_WEB_PUSH_PRIVATE_KEY"}, Usage: "private key used for web push notifications"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-subscriptions-file", Aliases: []string{"web_push_subscriptions_file"}, EnvVars: []string{"NTFY_WEB_PUSH_SUBSCRIPTIONS_FILE"}, Usage: "file used to store web push subscriptions"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-email-address", Aliases: []string{"web_push_email_address"}, EnvVars: []string{"NTFY_WEB_PUSH_EMAIL_ADDRESS"}, Usage: "e-mail address of sender, required to use browser push services"}), + altsrc.NewDurationFlag(&cli.DurationFlag{Name: "web-push-expiry-warning-duration", Aliases: []string{"web_push_expiry_warning_duration"}, EnvVars: []string{"NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION"}, Value: server.DefaultWebPushExpiryWarningDuration, Usage: "duration after last update to send a warning notification"}), + altsrc.NewDurationFlag(&cli.DurationFlag{Name: "web-push-expiry-duration", Aliases: []string{"web_push_expiry_duration"}, EnvVars: []string{"NTFY_WEB_PUSH_EXPIRY_DURATION"}, Value: server.DefaultWebPushExpiryDuration, Usage: "duration after last update to expire subscription"}), ) var cmdServe = &cli.Command{ @@ -138,6 +140,8 @@ func execServe(c *cli.Context) error { webPushPrivateKey := c.String("web-push-private-key") webPushPublicKey := c.String("web-push-public-key") webPushSubscriptionsFile := c.String("web-push-subscriptions-file") + webPushExpiryWarningDuration := c.Duration("web-push-expiry-warning-duration") + webPushExpiryDuration := c.Duration("web-push-expiry-duration") webPushEmailAddress := c.String("web-push-email-address") cacheFile := c.String("cache-file") cacheDuration := c.Duration("cache-duration") @@ -197,6 +201,8 @@ func execServe(c *cli.Context) error { return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-subscriptions-file, web-push-email-address, and base-url should be set. run 'ntfy web-push generate-keys' to generate keys") } else if keepaliveInterval < 5*time.Second { return errors.New("keepalive interval cannot be lower than five seconds") + } else if webPushEnabled && (webPushExpiryWarningDuration < 24*time.Hour || (webPushExpiryDuration-webPushExpiryWarningDuration < 24*time.Hour)) { + return errors.New("web push expiry warning duration must be at least 1 day, expire duration must be at least 1 day later") } else if managerInterval < 5*time.Second { return errors.New("manager interval cannot be lower than five seconds") } else if cacheDuration > 0 && cacheDuration < managerInterval { @@ -364,6 +370,8 @@ func execServe(c *cli.Context) error { conf.WebPushPublicKey = webPushPublicKey conf.WebPushSubscriptionsFile = webPushSubscriptionsFile conf.WebPushEmailAddress = webPushEmailAddress + conf.WebPushExpiryDuration = webPushExpiryDuration + conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration // Set up hot-reloading of config go sigHandlerConfigReload(config) diff --git a/docs/config.md b/docs/config.md index 7e1ef6c6..cfd14034 100644 --- a/docs/config.md +++ b/docs/config.md @@ -827,6 +827,11 @@ web-push-email-address: sysadmin@example.com # don't forget to set the required base-url for web push notifications base-url: https://ntfy.example.com + +# you can also set custom expiry and warning durations +# the minimum is 1 day for warning, and 1 day after warning for expiry +web-push-expiry-warning-duration: 168h +web-push-expiry-duration: 192h ``` The `web-push-subscriptions-file` is used to store the push subscriptions. Subscriptions do not ever expire automatically, unless the push @@ -1339,6 +1344,8 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `web-push-private-key` | `NTFY_WEB_PUSH_PRIVATE_KEY` | *string* | - | Web Push: Private Key. Run `ntfy web-push generate-keys` to generate | | `web-push-subscriptions-file` | `NTFY_WEB_PUSH_SUBSCRIPTIONS_FILE` | *string* | - | Web Push: Subscriptions file | | `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address | +| `web-push-expiry-warning-duration` | `NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION` | *duration* | 1 week | Web Push: Time before expiry warning is sent (min 1 day) | +| `web-push-expiry-duration` | `NTFY_WEB_PUSH_EXPIRY_DURATION` | *duration* | 1 week + 1 day | Web Push: Time before subscription is expired (min 1 day after warning) | The format for a *duration* is: `(smh)`, e.g. 30s, 20m or 1h. The format for a *size* is: `(GMK)`, e.g. 1G, 200M or 4000k. @@ -1436,5 +1443,8 @@ OPTIONS: --web-push-private-key value, --web_push_private_key value private key used for web push notifications [$NTFY_WEB_PUSH_PRIVATE_KEY] --web-push-subscriptions-file value, --web_push_subscriptions_file value file used to store web push subscriptions [$NTFY_WEB_PUSH_SUBSCRIPTIONS_FILE] --web-push-email-address value, --web_push_email_address value e-mail address of sender, required to use browser push services [$NTFY_WEB_PUSH_EMAIL_ADDRESS] + --web-push-expiry-warning-duration value, --web_push_expiry_warning_duration value duration after last update to send a warning notification (default: 168h0m0s) [$NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION] + --web-push-expiry-duration value, --web_push_expiry_duration value duration after last update to expire subscription (default: 192h0m0s) [$NTFY_WEB_PUSH_EXPIRY_DURATION] --help, -h show help + ``` diff --git a/docs/subscribe/web.md b/docs/subscribe/web.md index e073bec9..465a53ee 100644 --- a/docs/subscribe/web.md +++ b/docs/subscribe/web.md @@ -2,15 +2,9 @@ You can use the Web UI to subscribe to topics as well. Simply type in the topic name and click the *Subscribe* button. -While subscribing, you have the option to enable desktop notifications, as well as background notifications. When you -enable them for the first time, you will be prompted to allow notifications on your browser. +While subscribing, you have the option to enable background notifications on supported browsers. -- **Sound only** - - If you don't enable browser notifications, a sound will play when a new notification comes in, and the tab title - will show the number of new notifications. - -- **Browser Notifications** +- If background notifications are off: This requires an active ntfy tab to be open to receive notifications. These are typically instantaneous, and will appear as a system notification. If you don't see these, check that your browser is allowed to show notifications @@ -19,7 +13,7 @@ enable them for the first time, you will be prompted to allow notifications on y If you don't want to enable background notifications, pinning the ntfy tab on your browser is a good solution to leave it running. -- **Background Notifications** +- If background notifications are on: This uses the [Web Push API](https://caniuse.com/push-api). You don't need an active ntfy tab open, but in some cases you may need to keep your browser open. @@ -27,6 +21,9 @@ enable them for the first time, you will be prompted to allow notifications on y Background notifications are only supported on the same server hosting the web app. You cannot use another server, but can instead subscribe on the other server itself. + If the ntfy app is not opened for more than a week, background notifications will be paused. You can resume them + by opening the app again, and will get a warning notification before they are paused. + | Browser | Platform | Browser Running | Browser Not Running | Restrictions | | ------- | -------- | --------------- | ------------------- | ------------------------------------------------------- | | Chrome | Desktop | ✅ | ❌ | | diff --git a/server/config.go b/server/config.go index d5c87672..e26ee0dd 100644 --- a/server/config.go +++ b/server/config.go @@ -23,6 +23,12 @@ const ( DefaultStripePriceCacheDuration = 3 * time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed ) +// Defines default web push settings +const ( + DefaultWebPushExpiryWarningDuration = 7 * 24 * time.Hour + DefaultWebPushExpiryDuration = DefaultWebPushExpiryWarningDuration + 24*time.Hour +) + // Defines all global and per-visitor limits // - message size limit: the max number of bytes for a message // - total topic limit: max number of topics overall @@ -152,6 +158,8 @@ type Config struct { WebPushPublicKey string WebPushSubscriptionsFile string WebPushEmailAddress string + WebPushExpiryDuration time.Duration + WebPushExpiryWarningDuration time.Duration } // NewConfig instantiates a default new server config @@ -238,5 +246,7 @@ func NewConfig() *Config { WebPushPublicKey: "", WebPushSubscriptionsFile: "", WebPushEmailAddress: "", + WebPushExpiryDuration: DefaultWebPushExpiryDuration, + WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, } } diff --git a/server/log.go b/server/log.go index c638ed97..9f922b6a 100644 --- a/server/log.go +++ b/server/log.go @@ -29,6 +29,7 @@ const ( tagResetter = "resetter" tagWebsocket = "websocket" tagMatrix = "matrix" + tagWebPush = "web_push" ) var ( diff --git a/server/server.yml b/server/server.yml index 6b921bbd..e59c8336 100644 --- a/server/server.yml +++ b/server/server.yml @@ -47,6 +47,8 @@ # web-push-private-key: # web-push-subscriptions-file: # web-push-email-address: +# web-push-expiry-warning-duration: 168h +# web-push-expiry-duration: 192h # If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory. # This allows for service restarts without losing messages in support of the since= parameter. diff --git a/server/server_manager.go b/server/server_manager.go index 52e3621e..a8626bd5 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -15,6 +15,9 @@ func (s *Server) execManager() { s.pruneTokens() s.pruneAttachments() s.pruneMessages() + if s.config.WebPushEnabled { + s.expireOrNotifyOldSubscriptions() + } // Message count per topic var messagesCached int diff --git a/server/server_web_push.go b/server/server_web_push.go index fcf0dad6..d7c28955 100644 --- a/server/server_web_push.go +++ b/server/server_web_push.go @@ -43,6 +43,7 @@ func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) { subscriptions, err := s.webPush.SubscriptionsForTopic(m.Topic) if err != nil { logvm(v, m).Err(err).Warn("Unable to publish web push messages") + return } @@ -50,42 +51,61 @@ func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) { go func(i int, sub webPushSubscription) { ctx := log.Context{"endpoint": sub.BrowserSubscription.Endpoint, "username": sub.UserID, "topic": m.Topic, "message_id": m.ID} - payload := &webPushPayload{ - SubscriptionID: fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), - Message: *m, - } - jsonPayload, err := json.Marshal(payload) - - if err != nil { - logvm(v, m).Err(err).Fields(ctx).Debug("Unable to publish web push message") - return - } - - resp, err := webpush.SendNotification(jsonPayload, &sub.BrowserSubscription, &webpush.Options{ - Subscriber: s.config.WebPushEmailAddress, - VAPIDPublicKey: s.config.WebPushPublicKey, - VAPIDPrivateKey: s.config.WebPushPrivateKey, - // Deliverability on iOS isn't great with lower urgency values, - // and thus we can't really map lower ntfy priorities to lower urgency values - Urgency: webpush.UrgencyHigh, - }) - - if err != nil { - logvm(v, m).Err(err).Fields(ctx).Debug("Unable to publish web push message") - if err := s.webPush.RemoveByEndpoint(sub.BrowserSubscription.Endpoint); err != nil { - logvm(v, m).Err(err).Fields(ctx).Warn("Unable to expire subscription") - } - return - } - - // May want to handle at least 429 differently, but for now treat all errors the same - if !(200 <= resp.StatusCode && resp.StatusCode <= 299) { - logvm(v, m).Fields(ctx).Field("response", resp).Debug("Unable to publish web push message") - if err := s.webPush.RemoveByEndpoint(sub.BrowserSubscription.Endpoint); err != nil { - logvm(v, m).Err(err).Fields(ctx).Warn("Unable to expire subscription") - } - return - } + s.sendWebPushNotification(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), *m), &sub, &ctx) }(i, xi) } } + +func (s *Server) expireOrNotifyOldSubscriptions() { + subscriptions, err := s.webPush.ExpireAndGetExpiringSubscriptions(s.config.WebPushExpiryWarningDuration, s.config.WebPushExpiryDuration) + if err != nil { + log.Tag(tagWebPush).Err(err).Warn("Unable to publish expiry imminent warning") + + return + } + + for i, xi := range subscriptions { + go func(i int, sub webPushSubscription) { + ctx := log.Context{"endpoint": sub.BrowserSubscription.Endpoint} + + s.sendWebPushNotification(newWebPushSubscriptionExpiringPayload(), &sub, &ctx) + }(i, xi) + } + + log.Tag(tagWebPush).Debug("Expired old subscriptions and published %d expiry imminent warnings", len(subscriptions)) +} + +func (s *Server) sendWebPushNotification(payload any, sub *webPushSubscription, ctx *log.Context) { + jsonPayload, err := json.Marshal(payload) + + if err != nil { + log.Tag(tagWebPush).Err(err).Fields(*ctx).Debug("Unable to publish web push message") + return + } + + resp, err := webpush.SendNotification(jsonPayload, &sub.BrowserSubscription, &webpush.Options{ + Subscriber: s.config.WebPushEmailAddress, + VAPIDPublicKey: s.config.WebPushPublicKey, + VAPIDPrivateKey: s.config.WebPushPrivateKey, + // Deliverability on iOS isn't great with lower urgency values, + // and thus we can't really map lower ntfy priorities to lower urgency values + Urgency: webpush.UrgencyHigh, + }) + + if err != nil { + log.Tag(tagWebPush).Err(err).Fields(*ctx).Debug("Unable to publish web push message") + if err := s.webPush.RemoveByEndpoint(sub.BrowserSubscription.Endpoint); err != nil { + log.Tag(tagWebPush).Err(err).Fields(*ctx).Warn("Unable to expire subscription") + } + return + } + + // May want to handle at least 429 differently, but for now treat all errors the same + if !(200 <= resp.StatusCode && resp.StatusCode <= 299) { + log.Tag(tagWebPush).Fields(*ctx).Field("response", resp).Debug("Unable to publish web push message") + if err := s.webPush.RemoveByEndpoint(sub.BrowserSubscription.Endpoint); err != nil { + log.Tag(tagWebPush).Err(err).Fields(*ctx).Warn("Unable to expire subscription") + } + return + } +} diff --git a/server/server_web_push_test.go b/server/server_web_push_test.go index 75746918..0086b794 100644 --- a/server/server_web_push_test.go +++ b/server/server_web_push_test.go @@ -157,8 +157,42 @@ func TestServer_WebPush_PublishExpire(t *testing.T) { requireSubscriptionCount(t, s, "test-topic-abc", 0) } +func TestServer_WebPush_Expiry(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) + + var received atomic.Bool + + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := io.ReadAll(r.Body) + require.Nil(t, err) + w.WriteHeader(200) + w.Write([]byte(``)) + received.Store(true) + })) + defer upstreamServer.Close() + + addSubscription(t, s, "test-topic", upstreamServer.URL+"/push-receive") + requireSubscriptionCount(t, s, "test-topic", 1) + + _, err := s.webPush.db.Exec("UPDATE subscriptions SET updated_at = datetime('now', '-7 days')") + require.Nil(t, err) + + s.expireOrNotifyOldSubscriptions() + requireSubscriptionCount(t, s, "test-topic", 1) + + waitFor(t, func() bool { + return received.Load() + }) + + _, err = s.webPush.db.Exec("UPDATE subscriptions SET updated_at = datetime('now', '-8 days')") + require.Nil(t, err) + + s.expireOrNotifyOldSubscriptions() + requireSubscriptionCount(t, s, "test-topic", 0) +} + func payloadForTopics(t *testing.T, topics []string) string { - topicsJson, err := json.Marshal(topics) + topicsJSON, err := json.Marshal(topics) require.Nil(t, err) return fmt.Sprintf(`{ @@ -170,7 +204,7 @@ func payloadForTopics(t *testing.T, topics []string) string { "auth": "auth-key" } } - }`, topicsJson) + }`, topicsJSON) } func addSubscription(t *testing.T, s *Server, topic string, url string) { diff --git a/server/types.go b/server/types.go index f1f15735..3ddfcbba 100644 --- a/server/types.go +++ b/server/types.go @@ -468,10 +468,29 @@ type apiStripeSubscriptionDeletedEvent struct { } type webPushPayload struct { + Event string `json:"event"` SubscriptionID string `json:"subscription_id"` Message message `json:"message"` } +func newWebPushPayload(subscriptionID string, message message) webPushPayload { + return webPushPayload{ + Event: "message", + SubscriptionID: subscriptionID, + Message: message, + } +} + +type webPushControlMessagePayload struct { + Event string `json:"event"` +} + +func newWebPushSubscriptionExpiringPayload() webPushControlMessagePayload { + return webPushControlMessagePayload{ + Event: "subscription_expiring", + } +} + type webPushSubscription struct { BrowserSubscription webpush.Subscription UserID string diff --git a/server/web_push.go b/server/web_push.go index c48920d8..a98d6ad8 100644 --- a/server/web_push.go +++ b/server/web_push.go @@ -3,6 +3,7 @@ package server import ( "database/sql" "fmt" + "time" "github.com/SherClockHolmes/webpush-go" _ "github.com/mattn/go-sqlite3" // SQLite driver @@ -18,7 +19,8 @@ const ( endpoint TEXT NOT NULL, key_auth TEXT NOT NULL, key_p256dh TEXT NOT NULL, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + warning_sent BOOLEAN DEFAULT FALSE ); CREATE INDEX IF NOT EXISTS idx_topic ON subscriptions (topic); CREATE INDEX IF NOT EXISTS idx_endpoint ON subscriptions (endpoint); @@ -32,8 +34,12 @@ const ( deleteWebPushSubscriptionByEndpointQuery = `DELETE FROM subscriptions WHERE endpoint = ?` deleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscriptions WHERE user_id = ?` deleteWebPushSubscriptionByTopicAndEndpointQuery = `DELETE FROM subscriptions WHERE topic = ? AND endpoint = ?` + deleteWebPushSubscriptionsByAgeQuery = `DELETE FROM subscriptions WHERE warning_sent = 1 AND updated_at <= datetime('now', ?)` - selectWebPushSubscriptionsForTopicQuery = `SELECT endpoint, key_auth, key_p256dh, user_id FROM subscriptions WHERE topic = ?` + selectWebPushSubscriptionsForTopicQuery = `SELECT endpoint, key_auth, key_p256dh, user_id FROM subscriptions WHERE topic = ?` + selectWebPushSubscriptionsExpiringSoonQuery = `SELECT DISTINCT endpoint, key_auth, key_p256dh FROM subscriptions WHERE warning_sent = 0 AND updated_at <= datetime('now', ?)` + + updateWarningSentQuery = `UPDATE subscriptions SET warning_sent = true WHERE warning_sent = 0 AND updated_at <= datetime('now', ?)` selectWebPushSubscriptionsCountQuery = `SELECT COUNT(*) FROM subscriptions` ) @@ -72,7 +78,6 @@ func setupNewSubscriptionsDB(db *sql.DB) error { } 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 @@ -121,6 +126,49 @@ func (c *webPushStore) SubscriptionsForTopic(topic string) (subscriptions []webP return data, nil } +func (c *webPushStore) ExpireAndGetExpiringSubscriptions(warningDuration time.Duration, expiryDuration time.Duration) (subscriptions []webPushSubscription, err error) { + tx, err := c.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + + _, err = tx.Exec(deleteWebPushSubscriptionsByAgeQuery, fmt.Sprintf("-%.2f seconds", expiryDuration.Seconds())) + if err != nil { + return nil, err + } + + rows, err := tx.Query(selectWebPushSubscriptionsExpiringSoonQuery, fmt.Sprintf("-%.2f seconds", warningDuration.Seconds())) + if err != nil { + return nil, err + } + defer rows.Close() + + var data []webPushSubscription + for rows.Next() { + i := webPushSubscription{} + err = rows.Scan(&i.BrowserSubscription.Endpoint, &i.BrowserSubscription.Keys.Auth, &i.BrowserSubscription.Keys.P256dh) + fmt.Printf("%v+", i) + if err != nil { + return nil, err + } + data = append(data, i) + } + + // also set warning as sent + _, err = tx.Exec(updateWarningSentQuery, fmt.Sprintf("-%.2f seconds", warningDuration.Seconds())) + if err != nil { + return nil, err + } + + err = tx.Commit() + if err != nil { + return nil, err + } + + return data, nil +} + func (c *webPushStore) RemoveByEndpoint(endpoint string) error { _, err := c.db.Exec( deleteWebPushSubscriptionByEndpointQuery, @@ -136,6 +184,7 @@ func (c *webPushStore) RemoveByUserID(userID string) error { ) return err } + func (c *webPushStore) Close() error { return c.db.Close() } diff --git a/web/public/sw.js b/web/public/sw.js index 70ad9a7d..dba350fa 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -32,35 +32,50 @@ self.addEventListener("push", (event) => { const data = event.data.json(); console.log("[ServiceWorker] Received Web Push Event", { event, data }); - const { subscription_id: subscriptionId, message } = data; - broadcastChannel.postMessage(message); - event.waitUntil( (async () => { - const db = await getDbAsync(); - - await Promise.all([ - (async () => { - await db.notifications.add({ - ...message, - subscriptionId, - // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation - new: 1, - }); - const badgeCount = await db.notifications.where({ new: 1 }).count(); - console.log("[ServiceWorker] Setting new app badge count", { badgeCount }); - self.navigator.setAppBadge?.(badgeCount); - })(), - db.subscriptions.update(subscriptionId, { - last: message.id, - }), - self.registration.showNotification(formatTitleWithDefault(message, message.topic), { - tag: subscriptionId, - body: formatMessage(message), + if (data.event === "subscription_expiring") { + await self.registration.showNotification("Notifications will be paused", { + body: "Open ntfy to continue receiving notifications", icon: "/static/images/ntfy.png", data, - }), - ]); + }); + } else if (data.event === "message") { + const { subscription_id: subscriptionId, message } = data; + broadcastChannel.postMessage(message); + + const db = await getDbAsync(); + + await Promise.all([ + (async () => { + await db.notifications.add({ + ...message, + subscriptionId, + // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + new: 1, + }); + const badgeCount = await db.notifications.where({ new: 1 }).count(); + console.log("[ServiceWorker] Setting new app badge count", { badgeCount }); + self.navigator.setAppBadge?.(badgeCount); + })(), + db.subscriptions.update(subscriptionId, { + last: message.id, + }), + self.registration.showNotification(formatTitleWithDefault(message, message.topic), { + tag: subscriptionId, + body: formatMessage(message), + icon: "/static/images/ntfy.png", + data, + }), + ]); + } else { + // We can't ignore the push, since permission can be revoked by the browser + await self.registration.showNotification("Unknown notification received from server", { + body: "You may need to update ntfy by opening the web app", + icon: "/static/images/ntfy.png", + data, + }); + } })() ); }); @@ -68,33 +83,38 @@ self.addEventListener("push", (event) => { self.addEventListener("notificationclick", (event) => { event.notification.close(); - const { message } = event.notification.data; - - if (message.click) { - self.clients.openWindow(message.click); - return; - } - - const rootUrl = new URL(self.location.origin); - const topicUrl = new URL(message.topic, self.location.origin); - event.waitUntil( (async () => { const clients = await self.clients.matchAll({ type: "window" }); - const topicClient = clients.find((client) => client.url === topicUrl.toString()); - if (topicClient) { - topicClient.focus(); - return; - } - + const rootUrl = new URL(self.location.origin); const rootClient = clients.find((client) => client.url === rootUrl.toString()); - if (rootClient) { - rootClient.focus(); - return; - } - self.clients.openWindow(topicUrl); + if (event.notification.data.event !== "message") { + if (rootClient) { + rootClient.focus(); + } else { + self.clients.openWindow(rootUrl); + } + } else { + const { message } = event.notification.data; + + if (message.click) { + self.clients.openWindow(message.click); + return; + } + + const topicUrl = new URL(message.topic, self.location.origin); + const topicClient = clients.find((client) => client.url === topicUrl.toString()); + + if (topicClient) { + topicClient.focus(); + } else if (rootClient) { + rootClient.focus(); + } else { + self.clients.openWindow(topicUrl); + } + } })() ); }); diff --git a/web/src/app/Prefs.js b/web/src/app/Prefs.js index 45078352..22f767af 100644 --- a/web/src/app/Prefs.js +++ b/web/src/app/Prefs.js @@ -42,4 +42,5 @@ class Prefs { } } -export default new Prefs(getDb()); +const prefs = new Prefs(getDb()); +export default prefs; diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 1fb66cb5..400ca08c 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -57,7 +57,6 @@ import { IncorrectPasswordError, UnauthorizedError } from "../app/errors"; import { ProChip } from "./SubscriptionPopup"; import theme from "./theme"; import session from "../app/Session"; -import subscriptionManager from "../app/SubscriptionManager"; const Account = () => { if (!session.exists()) { diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 148c3ac2..f19710d8 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -58,9 +58,7 @@ const App = () => { const updateTitle = (newNotificationsCount) => { document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy"; - if ("setAppBadge" in window.navigator) { - window.navigator.setAppBadge(newNotificationsCount); - } + window.navigator.setAppBadge?.(newNotificationsCount); }; const Layout = () => { diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index eb40e443..815f0596 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -10,7 +10,6 @@ import session from "../app/Session"; import accountApi from "../app/AccountApi"; import { UnauthorizedError } from "../app/errors"; import { webPushRefreshWorker, useWebPushUpdateWorker } from "../app/WebPushWorker"; -import notifier from "../app/Notifier"; /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection