Use the same notification pipeline everywhere

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

View file

@ -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() {

View file

@ -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);
}
/**

View file

@ -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,
})),
},
];
};

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 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);
}
};