Use the same notification pipeline everywhere
This means less duplication and `actions` support for all notifications.pull/751/head
parent
fa418eef16
commit
b197ea3ab6
|
@ -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();
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue