Use the same notification pipeline everywhere

This means less duplication and `actions` support for all
notifications.
pull/751/head
nimbleghost 2023-06-14 23:20:48 +02:00
parent fa418eef16
commit b197ea3ab6
5 changed files with 102 additions and 73 deletions

View File

@ -4,7 +4,8 @@ import { NavigationRoute, registerRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies"; import { NetworkFirst } from "workbox-strategies";
import { dbAsync } from "../src/app/db"; 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"; import i18n from "../src/app/i18n";
@ -20,15 +21,9 @@ import i18n from "../src/app/i18n";
const broadcastChannel = new BroadcastChannel("web-push-broadcast"); const broadcastChannel = new BroadcastChannel("web-push-broadcast");
const isImage = (filenameOrUrl) => filenameOrUrl?.match(/\.(png|jpe?g|gif|webp)$/i) ?? false; const addNotification = async ({ subscriptionId, message }) => {
const icon = "/static/images/ntfy.png";
const addNotification = async (data) => {
const db = await dbAsync(); const db = await dbAsync();
const { subscription_id: subscriptionId, message } = data;
await db.notifications.add({ await db.notifications.add({
...message, ...message,
subscriptionId, subscriptionId,
@ -45,27 +40,6 @@ const addNotification = async (data) => {
self.navigator.setAppBadge?.(badgeCount); 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 * Handle a received web push notification
* @param {object} data see server/types.go, type webPushPayload * @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"), body: i18n.t("web_push_subscription_expiring_body"),
icon, icon,
data, data,
badge,
}); });
} else if (data.event === "message") { } else if (data.event === "message") {
const { subscription_id: subscriptionId, message } = data;
// see: web/src/app/WebPush.js // see: web/src/app/WebPush.js
// the service worker cannot play a sound, so if the web app // the service worker cannot play a sound, so if the web app
// is running, it receives the broadcast and plays it. // is running, it receives the broadcast and plays it.
broadcastChannel.postMessage(data.message); broadcastChannel.postMessage(message);
await addNotification(data); await addNotification({ subscriptionId, message });
await showNotification(data);
await self.registration.showNotification(
...getNotificationParams({
subscriptionId,
message,
defaultTitle: message.topic,
topicRoute: new URL(message.topic, self.location.origin).toString(),
})
);
} else { } else {
// We can't ignore the push, since permission can be revoked by the browser // 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"), { await self.registration.showNotification(i18n.t("web_push_unknown_notification_title"), {
body: i18n.t("web_push_unknown_notification_body"), body: i18n.t("web_push_unknown_notification_body"),
icon, icon,
data, data,
badge,
}); });
} }
}; };
@ -104,17 +90,23 @@ const handleClick = async (event) => {
const rootUrl = new URL(self.location.origin); const rootUrl = new URL(self.location.origin);
const rootClient = clients.find((client) => client.url === rootUrl.toString()); const rootClient = clients.find((client) => client.url === rootUrl.toString());
// perhaps open on another topic
const fallbackClient = clients[0];
if (event.notification.data?.event !== "message") { if (!event.notification.data?.message) {
// e.g. subscription_expiring event, simply open the web app on the root route (/) // e.g. something other than a message, e.g. a subscription_expiring event
// simply open the web app on the root route (/)
if (rootClient) { if (rootClient) {
rootClient.focus(); rootClient.focus();
} else if (fallbackClient) {
fallbackClient.focus();
fallbackClient.navigate(rootUrl.toString());
} else { } else {
self.clients.openWindow(rootUrl); self.clients.openWindow(rootUrl);
} }
event.notification.close(); event.notification.close();
} else { } else {
const { message } = event.notification.data; const { message, topicRoute } = event.notification.data;
if (event.action) { if (event.action) {
const action = event.notification.data.message.actions.find(({ label }) => event.action === label); const action = event.notification.data.message.actions.find(({ label }) => event.action === label);
@ -134,9 +126,10 @@ const handleClick = async (event) => {
} }
} catch (e) { } catch (e) {
console.error("[ServiceWorker] Error performing http action", 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, body: e.message,
icon, icon,
badge,
}); });
} }
} }
@ -151,18 +144,22 @@ const handleClick = async (event) => {
} else { } else {
// If no action was clicked, and the message doesn't have a click url: // If no action was clicked, and the message doesn't have a click url:
// - first try focus an open tab on the `/:topic` route // - first try focus an open tab on the `/:topic` route
// - if not, an open tab on the root route (`/`) // - if not, use an open tab on the root route (`/`) and navigate to the topic
// - if no ntfy window is open, open a new tab on the `/:topic` route // - 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 === topicRoute);
const topicClient = clients.find((client) => client.url === topicUrl.toString());
if (topicClient) { if (topicClient) {
topicClient.focus(); topicClient.focus();
} else if (rootClient) { } else if (rootClient) {
rootClient.focus(); rootClient.focus();
rootClient.navigate(topicRoute);
} else if (fallbackClient) {
fallbackClient.focus();
fallbackClient.navigate(topicRoute);
} else { } else {
self.clients.openWindow(topicUrl); self.clients.openWindow(topicRoute);
} }
event.notification.close(); event.notification.close();

View File

@ -1,39 +1,34 @@
import { openUrl, playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils"; import { playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils";
import { formatMessage, formatTitleWithDefault } from "./notificationUtils"; import { getNotificationParams } from "./notificationUtils";
import prefs from "./Prefs"; 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 * 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. * support this; most importantly, all iOS browsers do not support window.Notification.
*/ */
class Notifier { class Notifier {
async notify(subscription, notification, onClickFallback) { async notify(subscription, notification) {
if (!this.supported()) { if (!this.supported()) {
return; return;
} }
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); await this.playSound();
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;
// Show notification const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`); const defaultTitle = topicDisplayName(subscription);
// Please update sw.js if formatting changes
const n = new Notification(title, { console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}`);
body: message,
tag: subscription.id, const registration = await this.serviceWorkerRegistration();
icon: image ?? logo, await registration.showNotification(
image, ...getNotificationParams({
timestamp: message.time * 1_000, subscriptionId: subscription.id,
}); message: notification,
if (notification.click) { defaultTitle,
n.onclick = () => openUrl(notification.click); topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(),
} else { })
n.onclick = () => onClickFallback(subscription); );
}
} }
async playSound() { async playSound() {
@ -73,11 +68,15 @@ class Notifier {
} }
async pushManager() { async pushManager() {
return (await this.serviceWorkerRegistration()).pushManager;
}
async serviceWorkerRegistration() {
const registration = await navigator.serviceWorker.getRegistration(); const registration = await navigator.serviceWorker.getRegistration();
if (!registration) { if (!registration) {
throw new Error("No service worker registration found"); throw new Error("No service worker registration found");
} }
return registration.pushManager; return registration;
} }
notRequested() { notRequested() {

View File

@ -42,7 +42,7 @@ class SubscriptionManager {
return this.db.subscriptions.get(subscriptionId); return this.db.subscriptions.get(subscriptionId);
} }
async notify(subscriptionId, notification, defaultClickAction) { async notify(subscriptionId, notification) {
const subscription = await this.get(subscriptionId); const subscription = await this.get(subscriptionId);
if (subscription.mutedUntil > 0) { if (subscription.mutedUntil > 0) {
return; return;
@ -53,7 +53,7 @@ class SubscriptionManager {
return; return;
} }
await Promise.all([notifier.playSound(), notifier.notify(subscription, notification, defaultClickAction)]); await notifier.notify(subscription, notification);
} }
/** /**

View File

@ -16,7 +16,7 @@ export const formatTitle = (m) => {
return m.title; return m.title;
}; };
export const formatTitleWithDefault = (m, fallback) => { const formatTitleWithDefault = (m, fallback) => {
if (m.title) { if (m.title) {
return formatTitle(m); return formatTitle(m);
} }
@ -33,3 +33,38 @@ export const formatMessage = (m) => {
} }
return m.message; 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,
})),
},
];
};

View File

@ -1,4 +1,4 @@
import { useNavigate, useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import { disallowedTopic, expandSecureUrl, topicUrl } from "../app/utils"; import { disallowedTopic, expandSecureUrl, topicUrl } from "../app/utils";
@ -21,7 +21,6 @@ import { webPush, useWebPushTopicListener } from "../app/WebPush";
* topics, such as sync topics (st_...). * topics, such as sync topics (st_...).
*/ */
export const useConnectionListeners = (account, subscriptions, users, webPushTopics) => { export const useConnectionListeners = (account, subscriptions, users, webPushTopics) => {
const navigate = useNavigate();
const wsSubscriptions = useMemo( const wsSubscriptions = useMemo(
() => (subscriptions && webPushTopics ? subscriptions.filter((s) => !webPushTopics.includes(s.topic)) : []), () => (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 // 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 handleNotification = async (subscriptionId, notification) => {
const added = await subscriptionManager.addNotification(subscriptionId, notification); const added = await subscriptionManager.addNotification(subscriptionId, notification);
if (added) { if (added) {
const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription)); await subscriptionManager.notify(subscriptionId, notification);
await subscriptionManager.notify(subscriptionId, notification, defaultClickAction);
} }
}; };