diff --git a/web/public/sw.js b/web/public/sw.js index a29a2039..bf6e8dbe 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -4,7 +4,8 @@ import { NavigationRoute, registerRoute } from "workbox-routing"; import { NetworkFirst } from "workbox-strategies"; import { dbAsync } from "../src/app/db"; -import { formatMessage, formatTitleWithDefault } from "../src/app/notificationUtils"; + +import { getNotificationParams, icon, badge } from "../src/app/notificationUtils"; import i18n from "../src/app/i18n"; @@ -20,15 +21,9 @@ import i18n from "../src/app/i18n"; const broadcastChannel = new BroadcastChannel("web-push-broadcast"); -const isImage = (filenameOrUrl) => filenameOrUrl?.match(/\.(png|jpe?g|gif|webp)$/i) ?? false; - -const icon = "/static/images/ntfy.png"; - -const addNotification = async (data) => { +const addNotification = async ({ subscriptionId, message }) => { const db = await dbAsync(); - const { subscription_id: subscriptionId, message } = data; - await db.notifications.add({ ...message, subscriptionId, @@ -45,27 +40,6 @@ const addNotification = async (data) => { self.navigator.setAppBadge?.(badgeCount); }; -const showNotification = async (data) => { - const { subscription_id: subscriptionId, message } = data; - - // Please update the desktop notification in Notifier.js to match any changes here - const image = isImage(message.attachment?.name) ? message.attachment.url : undefined; - await self.registration.showNotification(formatTitleWithDefault(message, message.topic), { - tag: subscriptionId, - body: formatMessage(message), - icon: image ?? icon, - image, - data, - timestamp: message.time * 1_000, - actions: message.actions - ?.filter(({ action }) => action === "view" || action === "http") - .map(({ label }) => ({ - action: label, - title: label, - })), - }); -}; - /** * Handle a received web push notification * @param {object} data see server/types.go, type webPushPayload @@ -76,21 +50,33 @@ const handlePush = async (data) => { body: i18n.t("web_push_subscription_expiring_body"), icon, data, + badge, }); } else if (data.event === "message") { + const { subscription_id: subscriptionId, message } = data; + // see: web/src/app/WebPush.js // the service worker cannot play a sound, so if the web app // is running, it receives the broadcast and plays it. - broadcastChannel.postMessage(data.message); + broadcastChannel.postMessage(message); - await addNotification(data); - await showNotification(data); + await addNotification({ subscriptionId, message }); + + await self.registration.showNotification( + ...getNotificationParams({ + subscriptionId, + message, + defaultTitle: message.topic, + topicRoute: new URL(message.topic, self.location.origin).toString(), + }) + ); } else { // We can't ignore the push, since permission can be revoked by the browser await self.registration.showNotification(i18n.t("web_push_unknown_notification_title"), { body: i18n.t("web_push_unknown_notification_body"), icon, data, + badge, }); } }; @@ -104,17 +90,23 @@ const handleClick = async (event) => { const rootUrl = new URL(self.location.origin); const rootClient = clients.find((client) => client.url === rootUrl.toString()); + // perhaps open on another topic + const fallbackClient = clients[0]; - if (event.notification.data?.event !== "message") { - // e.g. subscription_expiring event, simply open the web app on the root route (/) + if (!event.notification.data?.message) { + // e.g. something other than a message, e.g. a subscription_expiring event + // simply open the web app on the root route (/) if (rootClient) { rootClient.focus(); + } else if (fallbackClient) { + fallbackClient.focus(); + fallbackClient.navigate(rootUrl.toString()); } else { self.clients.openWindow(rootUrl); } event.notification.close(); } else { - const { message } = event.notification.data; + const { message, topicRoute } = event.notification.data; if (event.action) { const action = event.notification.data.message.actions.find(({ label }) => event.action === label); @@ -134,9 +126,10 @@ const handleClick = async (event) => { } } catch (e) { console.error("[ServiceWorker] Error performing http action", e); - self.registration.showNotification(`${i18n.t('notifications_actions_failed_notification')}: ${action.label} (${action.action})`, { + self.registration.showNotification(`${i18n.t("notifications_actions_failed_notification")}: ${action.label} (${action.action})`, { body: e.message, icon, + badge, }); } } @@ -151,18 +144,22 @@ const handleClick = async (event) => { } else { // If no action was clicked, and the message doesn't have a click url: // - first try focus an open tab on the `/:topic` route - // - if not, an open tab on the root route (`/`) - // - if no ntfy window is open, open a new tab on the `/:topic` route + // - if not, use an open tab on the root route (`/`) and navigate to the topic + // - if not, use whichever tab we have open and navigate to the topic + // - finally, open a new tab focused on the topic - const topicUrl = new URL(message.topic, self.location.origin); - const topicClient = clients.find((client) => client.url === topicUrl.toString()); + const topicClient = clients.find((client) => client.url === topicRoute); if (topicClient) { topicClient.focus(); } else if (rootClient) { rootClient.focus(); + rootClient.navigate(topicRoute); + } else if (fallbackClient) { + fallbackClient.focus(); + fallbackClient.navigate(topicRoute); } else { - self.clients.openWindow(topicUrl); + self.clients.openWindow(topicRoute); } event.notification.close(); diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js index 7d71c326..b0311f40 100644 --- a/web/src/app/Notifier.js +++ b/web/src/app/Notifier.js @@ -1,39 +1,34 @@ -import { openUrl, playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils"; -import { formatMessage, formatTitleWithDefault } from "./notificationUtils"; +import { playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils"; +import { getNotificationParams } from "./notificationUtils"; import prefs from "./Prefs"; -import logo from "../img/ntfy.png"; +import routes from "../components/routes"; /** * The notifier is responsible for displaying desktop notifications. Note that not all modern browsers * support this; most importantly, all iOS browsers do not support window.Notification. */ class Notifier { - async notify(subscription, notification, onClickFallback) { + async notify(subscription, notification) { if (!this.supported()) { return; } - const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); - const displayName = topicDisplayName(subscription); - const message = formatMessage(notification); - const title = formatTitleWithDefault(notification, displayName); - const image = notification.attachment?.name.match(/\.(png|jpe?g|gif|webp)$/i) ? notification.attachment.url : undefined; + await this.playSound(); - // Show notification - console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`); - // Please update sw.js if formatting changes - const n = new Notification(title, { - body: message, - tag: subscription.id, - icon: image ?? logo, - image, - timestamp: message.time * 1_000, - }); - if (notification.click) { - n.onclick = () => openUrl(notification.click); - } else { - n.onclick = () => onClickFallback(subscription); - } + const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); + const defaultTitle = topicDisplayName(subscription); + + console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}`); + + const registration = await this.serviceWorkerRegistration(); + await registration.showNotification( + ...getNotificationParams({ + subscriptionId: subscription.id, + message: notification, + defaultTitle, + topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(), + }) + ); } async playSound() { @@ -73,11 +68,15 @@ class Notifier { } async pushManager() { + return (await this.serviceWorkerRegistration()).pushManager; + } + + async serviceWorkerRegistration() { const registration = await navigator.serviceWorker.getRegistration(); if (!registration) { throw new Error("No service worker registration found"); } - return registration.pushManager; + return registration; } notRequested() { diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 8399c4fb..4e2f400a 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -42,7 +42,7 @@ class SubscriptionManager { return this.db.subscriptions.get(subscriptionId); } - async notify(subscriptionId, notification, defaultClickAction) { + async notify(subscriptionId, notification) { const subscription = await this.get(subscriptionId); if (subscription.mutedUntil > 0) { return; @@ -53,7 +53,7 @@ class SubscriptionManager { return; } - await Promise.all([notifier.playSound(), notifier.notify(subscription, notification, defaultClickAction)]); + await notifier.notify(subscription, notification); } /** diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index b385f481..77437729 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -16,7 +16,7 @@ export const formatTitle = (m) => { return m.title; }; -export const formatTitleWithDefault = (m, fallback) => { +const formatTitleWithDefault = (m, fallback) => { if (m.title) { return formatTitle(m); } @@ -33,3 +33,38 @@ export const formatMessage = (m) => { } return m.message; }; + +const isImage = (filenameOrUrl) => filenameOrUrl?.match(/\.(png|jpe?g|gif|webp)$/i) ?? false; + +export const icon = "/static/images/ntfy.png"; +export const badge = "/static/images/mask-icon.svg"; + +export const getNotificationParams = ({ subscriptionId, message, defaultTitle, topicRoute }) => { + const image = isImage(message.attachment?.name) ? message.attachment.url : undefined; + + // https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API + return [ + formatTitleWithDefault(message, defaultTitle), + { + body: formatMessage(message), + badge, + icon, + image, + timestamp: message.time * 1_000, + tag: subscriptionId, + renotify: true, + silent: false, + // This is used by the notification onclick event + data: { + message, + topicRoute, + }, + actions: message.actions + ?.filter(({ action }) => action === "view" || action === "http") + .map(({ label }) => ({ + action: label, + title: label, + })), + }, + ]; +}; diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index a096911f..85cabd9a 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -1,4 +1,4 @@ -import { useNavigate, useParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { useEffect, useMemo, useState } from "react"; import subscriptionManager from "../app/SubscriptionManager"; import { disallowedTopic, expandSecureUrl, topicUrl } from "../app/utils"; @@ -21,7 +21,6 @@ import { webPush, useWebPushTopicListener } from "../app/WebPush"; * topics, such as sync topics (st_...). */ export const useConnectionListeners = (account, subscriptions, users, webPushTopics) => { - const navigate = useNavigate(); const wsSubscriptions = useMemo( () => (subscriptions && webPushTopics ? subscriptions.filter((s) => !webPushTopics.includes(s.topic)) : []), // wsSubscriptions should stay stable unless the list of subscription IDs changes. Without the memo, the connection @@ -51,8 +50,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop const handleNotification = async (subscriptionId, notification) => { const added = await subscriptionManager.addNotification(subscriptionId, notification); if (added) { - const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription)); - await subscriptionManager.notify(subscriptionId, notification, defaultClickAction); + await subscriptionManager.notify(subscriptionId, notification); } };