Add PWA, service worker and Web Push
- Use new notification request/opt-in flow for push - Implement unsubscribing - Implement muting - Implement emojis in title - Add iOS specific PWA warning - Don’t use websockets when web push is enabled - Fix duplicate notifications - Implement default web push setting - Implement changing subscription type - Implement web push subscription refresh - Implement web push notification click
This commit is contained in:
parent
733ef4664b
commit
ff5c854192
53 changed files with 4363 additions and 249 deletions
|
@ -382,6 +382,10 @@ class AccountApi {
|
|||
setTimeout(() => this.runWorker(), delayMillis);
|
||||
}
|
||||
|
||||
stopWorker() {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
async runWorker() {
|
||||
if (!session.token()) {
|
||||
return;
|
||||
|
|
|
@ -6,6 +6,9 @@ import {
|
|||
topicUrlAuth,
|
||||
topicUrlJsonPoll,
|
||||
topicUrlJsonPollWithSince,
|
||||
topicUrlWebPushSubscribe,
|
||||
topicUrlWebPushUnsubscribe,
|
||||
webPushConfigUrl,
|
||||
} from "./utils";
|
||||
import userManager from "./UserManager";
|
||||
import { fetchOrThrow } from "./errors";
|
||||
|
@ -113,6 +116,62 @@ class Api {
|
|||
}
|
||||
throw new Error(`Unexpected server response ${response.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<{ public_key: string } | undefined>}
|
||||
*/
|
||||
async getWebPushConfig(baseUrl) {
|
||||
const response = await fetch(webPushConfigUrl(baseUrl));
|
||||
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
// web push is not enabled
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected server response ${response.status}`);
|
||||
}
|
||||
|
||||
async subscribeWebPush(baseUrl, topic, browserSubscription) {
|
||||
const user = await userManager.get(baseUrl);
|
||||
|
||||
const url = topicUrlWebPushSubscribe(baseUrl, topic);
|
||||
console.log(`[Api] Sending Web Push Subscription ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: maybeWithAuth({}, user),
|
||||
body: JSON.stringify({ browser_subscription: browserSubscription }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected server response ${response.status}`);
|
||||
}
|
||||
|
||||
async unsubscribeWebPush(subscription) {
|
||||
const user = await userManager.get(subscription.baseUrl);
|
||||
|
||||
const url = topicUrlWebPushUnsubscribe(subscription.baseUrl, subscription.topic);
|
||||
console.log(`[Api] Unsubscribing Web Push Subscription ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: maybeWithAuth({}, user),
|
||||
body: JSON.stringify({ endpoint: subscription.webPushEndpoint }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected server response ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
const api = new Api();
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import Connection from "./Connection";
|
||||
import { NotificationType } from "./SubscriptionManager";
|
||||
import { hashCode } from "./utils";
|
||||
|
||||
const makeConnectionId = async (subscription, user) =>
|
||||
const makeConnectionId = (subscription, user) =>
|
||||
user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`);
|
||||
|
||||
/**
|
||||
|
@ -45,13 +46,19 @@ class ConnectionManager {
|
|||
return;
|
||||
}
|
||||
console.log(`[ConnectionManager] Refreshing connections`);
|
||||
const subscriptionsWithUsersAndConnectionId = await Promise.all(
|
||||
subscriptions.map(async (s) => {
|
||||
const subscriptionsWithUsersAndConnectionId = subscriptions
|
||||
.map((s) => {
|
||||
const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
|
||||
const connectionId = await makeConnectionId(s, user);
|
||||
const connectionId = makeConnectionId(s, user);
|
||||
return { ...s, user, connectionId };
|
||||
})
|
||||
);
|
||||
// we want to create a ws for both sound-only and active browser notifications,
|
||||
// only background notifications don't need this as they come over web push.
|
||||
// however, if background notifications are muted, we again need the ws while
|
||||
// the page is active
|
||||
.filter((s) => s.notificationType !== NotificationType.BACKGROUND && s.mutedUntil !== 1);
|
||||
|
||||
console.log();
|
||||
const targetIds = subscriptionsWithUsersAndConnectionId.map((s) => s.connectionId);
|
||||
const deletedIds = Array.from(this.connections.keys()).filter((id) => !targetIds.includes(id));
|
||||
|
||||
|
|
|
@ -1,22 +1,18 @@
|
|||
import { formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl } from "./utils";
|
||||
import { formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils";
|
||||
import prefs from "./Prefs";
|
||||
import subscriptionManager from "./SubscriptionManager";
|
||||
import logo from "../img/ntfy.png";
|
||||
import api from "./Api";
|
||||
|
||||
/**
|
||||
* 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(subscriptionId, notification, onClickFallback) {
|
||||
async notify(subscription, notification, onClickFallback) {
|
||||
if (!this.supported()) {
|
||||
return;
|
||||
}
|
||||
const subscription = await subscriptionManager.get(subscriptionId);
|
||||
const shouldNotify = await this.shouldNotify(subscription, notification);
|
||||
if (!shouldNotify) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||
const displayName = topicDisplayName(subscription);
|
||||
const message = formatMessage(notification);
|
||||
|
@ -26,6 +22,7 @@ class Notifier {
|
|||
console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`);
|
||||
const n = new Notification(title, {
|
||||
body: message,
|
||||
tag: subscription.id,
|
||||
icon: logo,
|
||||
});
|
||||
if (notification.click) {
|
||||
|
@ -33,45 +30,88 @@ class Notifier {
|
|||
} else {
|
||||
n.onclick = () => onClickFallback(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
async playSound() {
|
||||
// Play sound
|
||||
const sound = await prefs.sound();
|
||||
if (sound && sound !== "none") {
|
||||
try {
|
||||
await playSound(sound);
|
||||
} catch (e) {
|
||||
console.log(`[Notifier, ${shortUrl}] Error playing audio`, e);
|
||||
console.log(`[Notifier] Error playing audio`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async unsubscribeWebPush(subscription) {
|
||||
try {
|
||||
await api.unsubscribeWebPush(subscription);
|
||||
} catch (e) {
|
||||
console.error("[Notifier.subscribeWebPush] Error subscribing to web push", e);
|
||||
}
|
||||
}
|
||||
|
||||
async subscribeWebPush(baseUrl, topic) {
|
||||
if (!this.supported() || !this.pushSupported()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// only subscribe to web push for the current server. this is a limitation of the web push API,
|
||||
// which only allows a single server per service worker origin.
|
||||
if (baseUrl !== config.base_url) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
|
||||
if (!registration) {
|
||||
console.log("[Notifier.subscribeWebPush] Web push supported but no service worker registration found, skipping");
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const webPushConfig = await api.getWebPushConfig(baseUrl);
|
||||
|
||||
if (!webPushConfig) {
|
||||
console.log("[Notifier.subscribeWebPush] Web push not configured on server");
|
||||
}
|
||||
|
||||
const browserSubscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlB64ToUint8Array(webPushConfig.public_key),
|
||||
});
|
||||
|
||||
await api.subscribeWebPush(baseUrl, topic, browserSubscription);
|
||||
|
||||
console.log("[Notifier.subscribeWebPush] Successfully subscribed to web push");
|
||||
|
||||
return browserSubscription;
|
||||
} catch (e) {
|
||||
console.error("[Notifier.subscribeWebPush] Error subscribing to web push", e);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
granted() {
|
||||
return this.supported() && Notification.permission === "granted";
|
||||
}
|
||||
|
||||
maybeRequestPermission(cb) {
|
||||
if (!this.supported()) {
|
||||
cb(false);
|
||||
return;
|
||||
}
|
||||
if (!this.granted()) {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
const granted = permission === "granted";
|
||||
cb(granted);
|
||||
});
|
||||
}
|
||||
denied() {
|
||||
return this.supported() && Notification.permission === "denied";
|
||||
}
|
||||
|
||||
async shouldNotify(subscription, notification) {
|
||||
if (subscription.mutedUntil === 1) {
|
||||
async maybeRequestPermission() {
|
||||
if (!this.supported()) {
|
||||
return false;
|
||||
}
|
||||
const priority = notification.priority ? notification.priority : 3;
|
||||
const minPriority = await prefs.minPriority();
|
||||
if (priority < minPriority) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
Notification.requestPermission((permission) => {
|
||||
resolve(permission === "granted");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
supported() {
|
||||
|
@ -82,6 +122,10 @@ class Notifier {
|
|||
return "Notification" in window;
|
||||
}
|
||||
|
||||
pushSupported() {
|
||||
return "serviceWorker" in navigator && "PushManager" in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
|
||||
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
|
||||
|
@ -89,6 +133,10 @@ class Notifier {
|
|||
contextSupported() {
|
||||
return window.location.protocol === "https:" || window.location.hostname.match("^127.") || window.location.hostname === "localhost";
|
||||
}
|
||||
|
||||
iosSupportedButInstallRequired() {
|
||||
return "standalone" in window.navigator && window.navigator.standalone === false;
|
||||
}
|
||||
}
|
||||
|
||||
const notifier = new Notifier();
|
||||
|
|
|
@ -18,6 +18,10 @@ class Poller {
|
|||
setTimeout(() => this.pollAll(), delayMillis);
|
||||
}
|
||||
|
||||
stopWorker() {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
async pollAll() {
|
||||
console.log(`[Poller] Polling all subscriptions`);
|
||||
const subscriptions = await subscriptionManager.all();
|
||||
|
@ -47,14 +51,13 @@ class Poller {
|
|||
}
|
||||
|
||||
pollInBackground(subscription) {
|
||||
const fn = async () => {
|
||||
(async () => {
|
||||
try {
|
||||
await this.poll(subscription);
|
||||
} catch (e) {
|
||||
console.error(`[App] Error polling subscription ${subscription.id}`, e);
|
||||
}
|
||||
};
|
||||
setTimeout(() => fn(), 0);
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,33 +1,45 @@
|
|||
import db from "./db";
|
||||
import getDb from "./getDb";
|
||||
|
||||
class Prefs {
|
||||
constructor(db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async setSound(sound) {
|
||||
db.prefs.put({ key: "sound", value: sound.toString() });
|
||||
this.db.prefs.put({ key: "sound", value: sound.toString() });
|
||||
}
|
||||
|
||||
async sound() {
|
||||
const sound = await db.prefs.get("sound");
|
||||
const sound = await this.db.prefs.get("sound");
|
||||
return sound ? sound.value : "ding";
|
||||
}
|
||||
|
||||
async setMinPriority(minPriority) {
|
||||
db.prefs.put({ key: "minPriority", value: minPriority.toString() });
|
||||
this.db.prefs.put({ key: "minPriority", value: minPriority.toString() });
|
||||
}
|
||||
|
||||
async minPriority() {
|
||||
const minPriority = await db.prefs.get("minPriority");
|
||||
const minPriority = await this.db.prefs.get("minPriority");
|
||||
return minPriority ? Number(minPriority.value) : 1;
|
||||
}
|
||||
|
||||
async setDeleteAfter(deleteAfter) {
|
||||
db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() });
|
||||
this.db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() });
|
||||
}
|
||||
|
||||
async deleteAfter() {
|
||||
const deleteAfter = await db.prefs.get("deleteAfter");
|
||||
const deleteAfter = await this.db.prefs.get("deleteAfter");
|
||||
return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week
|
||||
}
|
||||
|
||||
async webPushDefaultEnabled() {
|
||||
const obj = await this.db.prefs.get("webPushDefaultEnabled");
|
||||
return obj?.value ?? "initial";
|
||||
}
|
||||
|
||||
async setWebPushDefaultEnabled(enabled) {
|
||||
await this.db.prefs.put({ key: "webPushDefaultEnabled", value: enabled ? "enabled" : "disabled" });
|
||||
}
|
||||
}
|
||||
|
||||
const prefs = new Prefs();
|
||||
export default prefs;
|
||||
export default new Prefs(getDb());
|
||||
|
|
|
@ -18,6 +18,10 @@ class Pruner {
|
|||
setTimeout(() => this.prune(), delayMillis);
|
||||
}
|
||||
|
||||
stopWorker() {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
async prune() {
|
||||
const deleteAfterSeconds = await prefs.deleteAfter();
|
||||
const pruneThresholdTimestamp = Math.round(Date.now() / 1000) - deleteAfterSeconds;
|
||||
|
|
|
@ -1,12 +1,22 @@
|
|||
import sessionReplica from "./SessionReplica";
|
||||
|
||||
class Session {
|
||||
constructor(replica) {
|
||||
this.replica = replica;
|
||||
}
|
||||
|
||||
store(username, token) {
|
||||
localStorage.setItem("user", username);
|
||||
localStorage.setItem("token", token);
|
||||
|
||||
this.replica.store(username, token);
|
||||
}
|
||||
|
||||
reset() {
|
||||
localStorage.removeItem("user");
|
||||
localStorage.removeItem("token");
|
||||
|
||||
this.replica.reset();
|
||||
}
|
||||
|
||||
resetAndRedirect(url) {
|
||||
|
@ -27,5 +37,5 @@ class Session {
|
|||
}
|
||||
}
|
||||
|
||||
const session = new Session();
|
||||
const session = new Session(sessionReplica);
|
||||
export default session;
|
||||
|
|
44
web/src/app/SessionReplica.js
Normal file
44
web/src/app/SessionReplica.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
import Dexie from "dexie";
|
||||
|
||||
// Store to IndexedDB as well so that the
|
||||
// service worker can access it
|
||||
// TODO: Probably make everything depend on this and not use localStorage,
|
||||
// but that's a larger refactoring effort for another PR
|
||||
|
||||
class SessionReplica {
|
||||
constructor() {
|
||||
const db = new Dexie("session-replica");
|
||||
|
||||
db.version(1).stores({
|
||||
keyValueStore: "&key",
|
||||
});
|
||||
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async store(username, token) {
|
||||
try {
|
||||
await this.db.keyValueStore.bulkPut([
|
||||
{ key: "user", value: username },
|
||||
{ key: "token", value: token },
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error("[Session] Error replicating session to IndexedDB", e);
|
||||
}
|
||||
}
|
||||
|
||||
async reset() {
|
||||
try {
|
||||
await this.db.delete();
|
||||
} catch (e) {
|
||||
console.error("[Session] Error resetting session on IndexedDB", e);
|
||||
}
|
||||
}
|
||||
|
||||
async username() {
|
||||
return (await this.db.keyValueStore.get({ key: "user" }))?.value;
|
||||
}
|
||||
}
|
||||
|
||||
const sessionReplica = new SessionReplica();
|
||||
export default sessionReplica;
|
|
@ -1,47 +1,112 @@
|
|||
import db from "./db";
|
||||
import notifier from "./Notifier";
|
||||
import prefs from "./Prefs";
|
||||
import getDb from "./getDb";
|
||||
import { topicUrl } from "./utils";
|
||||
|
||||
/** @typedef {string} NotificationTypeEnum */
|
||||
|
||||
/** @enum {NotificationTypeEnum} */
|
||||
export const NotificationType = {
|
||||
/** sound-only */
|
||||
SOUND: "sound",
|
||||
/** browser notifications when there is an active tab, via websockets */
|
||||
BROWSER: "browser",
|
||||
/** web push notifications, regardless of whether the window is open */
|
||||
BACKGROUND: "background",
|
||||
};
|
||||
|
||||
class SubscriptionManager {
|
||||
constructor(db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
|
||||
async all() {
|
||||
const subscriptions = await db.subscriptions.toArray();
|
||||
const subscriptions = await this.db.subscriptions.toArray();
|
||||
return Promise.all(
|
||||
subscriptions.map(async (s) => ({
|
||||
...s,
|
||||
new: await db.notifications.where({ subscriptionId: s.id, new: 1 }).count(),
|
||||
new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
async get(subscriptionId) {
|
||||
return db.subscriptions.get(subscriptionId);
|
||||
return this.db.subscriptions.get(subscriptionId);
|
||||
}
|
||||
|
||||
async add(baseUrl, topic, internal) {
|
||||
async notify(subscriptionId, notification, defaultClickAction) {
|
||||
const subscription = await this.get(subscriptionId);
|
||||
|
||||
if (subscription.mutedUntil === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const priority = notification.priority ?? 3;
|
||||
if (priority < (await prefs.minPriority())) {
|
||||
return;
|
||||
}
|
||||
|
||||
await notifier.playSound();
|
||||
|
||||
// sound only
|
||||
if (subscription.notificationType === "sound") {
|
||||
return;
|
||||
}
|
||||
|
||||
await notifier.notify(subscription, notification, defaultClickAction);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} baseUrl
|
||||
* @param {string} topic
|
||||
* @param {object} opts
|
||||
* @param {boolean} opts.internal
|
||||
* @param {NotificationTypeEnum} opts.notificationType
|
||||
* @returns
|
||||
*/
|
||||
async add(baseUrl, topic, opts = {}) {
|
||||
const id = topicUrl(baseUrl, topic);
|
||||
|
||||
const webPushFields = opts.notificationType === "background" ? await notifier.subscribeWebPush(baseUrl, topic) : {};
|
||||
|
||||
const existingSubscription = await this.get(id);
|
||||
if (existingSubscription) {
|
||||
if (webPushFields.endpoint) {
|
||||
await this.db.subscriptions.update(existingSubscription.id, {
|
||||
webPushEndpoint: webPushFields.endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
return existingSubscription;
|
||||
}
|
||||
|
||||
const subscription = {
|
||||
id: topicUrl(baseUrl, topic),
|
||||
baseUrl,
|
||||
topic,
|
||||
mutedUntil: 0,
|
||||
last: null,
|
||||
internal: internal || false,
|
||||
...opts,
|
||||
webPushEndpoint: webPushFields.endpoint,
|
||||
};
|
||||
await db.subscriptions.put(subscription);
|
||||
|
||||
await this.db.subscriptions.put(subscription);
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async syncFromRemote(remoteSubscriptions, remoteReservations) {
|
||||
console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
|
||||
|
||||
const notificationType = (await prefs.webPushDefaultEnabled()) === "enabled" ? "background" : "browser";
|
||||
|
||||
// Add remote subscriptions
|
||||
const remoteIds = await Promise.all(
|
||||
remoteSubscriptions.map(async (remote) => {
|
||||
const local = await this.add(remote.base_url, remote.topic, false);
|
||||
const local = await this.add(remote.base_url, remote.topic, {
|
||||
notificationType,
|
||||
});
|
||||
const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
|
||||
|
||||
await this.update(local.id, {
|
||||
|
@ -54,29 +119,33 @@ class SubscriptionManager {
|
|||
);
|
||||
|
||||
// Remove local subscriptions that do not exist remotely
|
||||
const localSubscriptions = await db.subscriptions.toArray();
|
||||
const localSubscriptions = await this.db.subscriptions.toArray();
|
||||
|
||||
await Promise.all(
|
||||
localSubscriptions.map(async (local) => {
|
||||
const remoteExists = remoteIds.includes(local.id);
|
||||
if (!local.internal && !remoteExists) {
|
||||
await this.remove(local.id);
|
||||
await this.remove(local);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async updateState(subscriptionId, state) {
|
||||
db.subscriptions.update(subscriptionId, { state });
|
||||
this.db.subscriptions.update(subscriptionId, { state });
|
||||
}
|
||||
|
||||
async remove(subscriptionId) {
|
||||
await db.subscriptions.delete(subscriptionId);
|
||||
await db.notifications.where({ subscriptionId }).delete();
|
||||
async remove(subscription) {
|
||||
await this.db.subscriptions.delete(subscription.id);
|
||||
await this.db.notifications.where({ subscriptionId: subscription.id }).delete();
|
||||
|
||||
if (subscription.webPushEndpoint) {
|
||||
await notifier.unsubscribeWebPush(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
async first() {
|
||||
return db.subscriptions.toCollection().first(); // May be undefined
|
||||
return this.db.subscriptions.toCollection().first(); // May be undefined
|
||||
}
|
||||
|
||||
async getNotifications(subscriptionId) {
|
||||
|
@ -84,7 +153,7 @@ class SubscriptionManager {
|
|||
// It's actually fine, because the reading and filtering is quite fast. The rendering is what's
|
||||
// killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach
|
||||
|
||||
return db.notifications
|
||||
return this.db.notifications
|
||||
.orderBy("time") // Sort by time first
|
||||
.filter((n) => n.subscriptionId === subscriptionId)
|
||||
.reverse()
|
||||
|
@ -92,7 +161,7 @@ class SubscriptionManager {
|
|||
}
|
||||
|
||||
async getAllNotifications() {
|
||||
return db.notifications
|
||||
return this.db.notifications
|
||||
.orderBy("time") // Efficient, see docs
|
||||
.reverse()
|
||||
.toArray();
|
||||
|
@ -100,18 +169,19 @@ class SubscriptionManager {
|
|||
|
||||
/** Adds notification, or returns false if it already exists */
|
||||
async addNotification(subscriptionId, notification) {
|
||||
const exists = await db.notifications.get(notification.id);
|
||||
const exists = await this.db.notifications.get(notification.id);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await db.notifications.add({
|
||||
// sw.js duplicates this logic, so if you change it here, change it there too
|
||||
await this.db.notifications.add({
|
||||
...notification,
|
||||
subscriptionId,
|
||||
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||
new: 1,
|
||||
}); // FIXME consider put() for double tab
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
await this.db.subscriptions.update(subscriptionId, {
|
||||
last: notification.id,
|
||||
});
|
||||
} catch (e) {
|
||||
|
@ -124,19 +194,19 @@ class SubscriptionManager {
|
|||
async addNotifications(subscriptionId, notifications) {
|
||||
const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId }));
|
||||
const lastNotificationId = notifications.at(-1).id;
|
||||
await db.notifications.bulkPut(notificationsWithSubscriptionId);
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
await this.db.notifications.bulkPut(notificationsWithSubscriptionId);
|
||||
await this.db.subscriptions.update(subscriptionId, {
|
||||
last: lastNotificationId,
|
||||
});
|
||||
}
|
||||
|
||||
async updateNotification(notification) {
|
||||
const exists = await db.notifications.get(notification.id);
|
||||
const exists = await this.db.notifications.get(notification.id);
|
||||
if (!exists) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await db.notifications.put({ ...notification });
|
||||
await this.db.notifications.put({ ...notification });
|
||||
} catch (e) {
|
||||
console.error(`[SubscriptionManager] Error updating notification`, e);
|
||||
}
|
||||
|
@ -144,47 +214,105 @@ class SubscriptionManager {
|
|||
}
|
||||
|
||||
async deleteNotification(notificationId) {
|
||||
await db.notifications.delete(notificationId);
|
||||
await this.db.notifications.delete(notificationId);
|
||||
}
|
||||
|
||||
async deleteNotifications(subscriptionId) {
|
||||
await db.notifications.where({ subscriptionId }).delete();
|
||||
await this.db.notifications.where({ subscriptionId }).delete();
|
||||
}
|
||||
|
||||
async markNotificationRead(notificationId) {
|
||||
await db.notifications.where({ id: notificationId }).modify({ new: 0 });
|
||||
await this.db.notifications.where({ id: notificationId }).modify({ new: 0 });
|
||||
}
|
||||
|
||||
async markNotificationsRead(subscriptionId) {
|
||||
await db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });
|
||||
await this.db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });
|
||||
}
|
||||
|
||||
async setMutedUntil(subscriptionId, mutedUntil) {
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
await this.db.subscriptions.update(subscriptionId, {
|
||||
mutedUntil,
|
||||
});
|
||||
|
||||
const subscription = await this.get(subscriptionId);
|
||||
|
||||
if (subscription.notificationType === "background") {
|
||||
if (mutedUntil === 1) {
|
||||
await notifier.unsubscribeWebPush(subscription);
|
||||
} else {
|
||||
const webPushFields = await notifier.subscribeWebPush(subscription.baseUrl, subscription.topic);
|
||||
await this.db.subscriptions.update(subscriptionId, {
|
||||
webPushEndpoint: webPushFields.endpoint,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} subscription
|
||||
* @param {NotificationTypeEnum} newNotificationType
|
||||
* @returns
|
||||
*/
|
||||
async setNotificationType(subscription, newNotificationType) {
|
||||
const oldNotificationType = subscription.notificationType ?? "browser";
|
||||
|
||||
if (oldNotificationType === newNotificationType) {
|
||||
return;
|
||||
}
|
||||
|
||||
let { webPushEndpoint } = subscription;
|
||||
|
||||
if (oldNotificationType === "background") {
|
||||
await notifier.unsubscribeWebPush(subscription);
|
||||
webPushEndpoint = undefined;
|
||||
} else if (newNotificationType === "background") {
|
||||
const webPushFields = await notifier.subscribeWebPush(subscription.baseUrl, subscription.topic);
|
||||
webPushEndpoint = webPushFields.webPushEndpoint;
|
||||
}
|
||||
|
||||
await this.db.subscriptions.update(subscription.id, {
|
||||
notificationType: newNotificationType,
|
||||
webPushEndpoint,
|
||||
});
|
||||
}
|
||||
|
||||
// for logout/delete, unsubscribe first to prevent receiving dangling notifications
|
||||
async unsubscribeAllWebPush() {
|
||||
const subscriptions = await this.db.subscriptions.where({ notificationType: "background" }).toArray();
|
||||
await Promise.all(subscriptions.map((subscription) => notifier.unsubscribeWebPush(subscription)));
|
||||
}
|
||||
|
||||
async refreshWebPushSubscriptions() {
|
||||
const subscriptions = await this.db.subscriptions.where({ notificationType: "background" }).toArray();
|
||||
const browserSubscription = await (await navigator.serviceWorker.getRegistration())?.pushManager?.getSubscription();
|
||||
|
||||
if (browserSubscription) {
|
||||
await Promise.all(subscriptions.map((subscription) => notifier.subscribeWebPush(subscription.baseUrl, subscription.topic)));
|
||||
} else {
|
||||
await Promise.all(subscriptions.map((subscription) => this.setNotificationType(subscription, "sound")));
|
||||
}
|
||||
}
|
||||
|
||||
async setDisplayName(subscriptionId, displayName) {
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
await this.db.subscriptions.update(subscriptionId, {
|
||||
displayName,
|
||||
});
|
||||
}
|
||||
|
||||
async setReservation(subscriptionId, reservation) {
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
await this.db.subscriptions.update(subscriptionId, {
|
||||
reservation,
|
||||
});
|
||||
}
|
||||
|
||||
async update(subscriptionId, params) {
|
||||
await db.subscriptions.update(subscriptionId, params);
|
||||
await this.db.subscriptions.update(subscriptionId, params);
|
||||
}
|
||||
|
||||
async pruneNotifications(thresholdTimestamp) {
|
||||
await db.notifications.where("time").below(thresholdTimestamp).delete();
|
||||
await this.db.notifications.where("time").below(thresholdTimestamp).delete();
|
||||
}
|
||||
}
|
||||
|
||||
const subscriptionManager = new SubscriptionManager();
|
||||
export default subscriptionManager;
|
||||
export default new SubscriptionManager(getDb());
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
import db from "./db";
|
||||
import getDb from "./getDb";
|
||||
import session from "./Session";
|
||||
|
||||
class UserManager {
|
||||
constructor(db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async all() {
|
||||
const users = await db.users.toArray();
|
||||
const users = await this.db.users.toArray();
|
||||
if (session.exists()) {
|
||||
users.unshift(this.localUser());
|
||||
}
|
||||
|
@ -14,21 +18,21 @@ class UserManager {
|
|||
if (session.exists() && baseUrl === config.base_url) {
|
||||
return this.localUser();
|
||||
}
|
||||
return db.users.get(baseUrl);
|
||||
return this.db.users.get(baseUrl);
|
||||
}
|
||||
|
||||
async save(user) {
|
||||
if (session.exists() && user.baseUrl === config.base_url) {
|
||||
return;
|
||||
}
|
||||
await db.users.put(user);
|
||||
await this.db.users.put(user);
|
||||
}
|
||||
|
||||
async delete(baseUrl) {
|
||||
if (session.exists() && baseUrl === config.base_url) {
|
||||
return;
|
||||
}
|
||||
await db.users.delete(baseUrl);
|
||||
await this.db.users.delete(baseUrl);
|
||||
}
|
||||
|
||||
localUser() {
|
||||
|
@ -43,5 +47,4 @@ class UserManager {
|
|||
}
|
||||
}
|
||||
|
||||
const userManager = new UserManager();
|
||||
export default userManager;
|
||||
export default new UserManager(getDb());
|
||||
|
|
46
web/src/app/WebPushWorker.js
Normal file
46
web/src/app/WebPushWorker.js
Normal file
|
@ -0,0 +1,46 @@
|
|||
import notifier from "./Notifier";
|
||||
import subscriptionManager from "./SubscriptionManager";
|
||||
|
||||
const onMessage = () => {
|
||||
notifier.playSound();
|
||||
};
|
||||
|
||||
const delayMillis = 2000; // 2 seconds
|
||||
const intervalMillis = 300000; // 5 minutes
|
||||
|
||||
class WebPushWorker {
|
||||
constructor() {
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
if (this.timer !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timer = setInterval(() => this.updateSubscriptions(), intervalMillis);
|
||||
setTimeout(() => this.updateSubscriptions(), delayMillis);
|
||||
|
||||
this.broadcastChannel = new BroadcastChannel("web-push-broadcast");
|
||||
this.broadcastChannel.addEventListener("message", onMessage);
|
||||
}
|
||||
|
||||
stopWorker() {
|
||||
clearTimeout(this.timer);
|
||||
|
||||
this.broadcastChannel.removeEventListener("message", onMessage);
|
||||
this.broadcastChannel.close();
|
||||
}
|
||||
|
||||
async updateSubscriptions() {
|
||||
try {
|
||||
console.log("[WebPushBroadcastListener] Refreshing web push subscriptions");
|
||||
|
||||
await subscriptionManager.refreshWebPushSubscriptions();
|
||||
} catch (e) {
|
||||
console.error("[WebPushBroadcastListener] Error refreshing web push subscriptions", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new WebPushWorker();
|
|
@ -1,21 +0,0 @@
|
|||
import Dexie from "dexie";
|
||||
import session from "./Session";
|
||||
|
||||
// Uses Dexie.js
|
||||
// https://dexie.org/docs/API-Reference#quick-reference
|
||||
//
|
||||
// Notes:
|
||||
// - As per docs, we only declare the indexable columns, not all columns
|
||||
|
||||
// The IndexedDB database name is based on the logged-in user
|
||||
const dbName = session.username() ? `ntfy-${session.username()}` : "ntfy";
|
||||
const db = new Dexie(dbName);
|
||||
|
||||
db.version(1).stores({
|
||||
subscriptions: "&id,baseUrl",
|
||||
notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
|
||||
users: "&baseUrl,username",
|
||||
prefs: "&key",
|
||||
});
|
||||
|
||||
export default db;
|
34
web/src/app/getDb.js
Normal file
34
web/src/app/getDb.js
Normal file
|
@ -0,0 +1,34 @@
|
|||
import Dexie from "dexie";
|
||||
import session from "./Session";
|
||||
import sessionReplica from "./SessionReplica";
|
||||
|
||||
// Uses Dexie.js
|
||||
// https://dexie.org/docs/API-Reference#quick-reference
|
||||
//
|
||||
// Notes:
|
||||
// - As per docs, we only declare the indexable columns, not all columns
|
||||
|
||||
const getDbBase = (username) => {
|
||||
// The IndexedDB database name is based on the logged-in user
|
||||
const dbName = username ? `ntfy-${username}` : "ntfy";
|
||||
const db = new Dexie(dbName);
|
||||
|
||||
db.version(2).stores({
|
||||
subscriptions: "&id,baseUrl,notificationType",
|
||||
notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
|
||||
users: "&baseUrl,username",
|
||||
prefs: "&key",
|
||||
});
|
||||
|
||||
return db;
|
||||
};
|
||||
|
||||
export const getDbAsync = async () => {
|
||||
const username = await sessionReplica.username();
|
||||
|
||||
return getDbBase(username);
|
||||
};
|
||||
|
||||
const getDb = () => getDbBase(session.username());
|
||||
|
||||
export default getDb;
|
|
@ -20,7 +20,10 @@ export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/jso
|
|||
export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;
|
||||
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
|
||||
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
|
||||
export const topicUrlWebPushSubscribe = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/web-push`;
|
||||
export const topicUrlWebPushUnsubscribe = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/web-push/unsubscribe`;
|
||||
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
|
||||
export const webPushConfigUrl = (baseUrl) => `${baseUrl}/v1/web-push-config`;
|
||||
export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
|
||||
export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
|
||||
export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
|
||||
|
@ -156,7 +159,7 @@ export const splitNoEmpty = (s, delimiter) =>
|
|||
.filter((x) => x !== "");
|
||||
|
||||
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
|
||||
export const hashCode = async (s) => {
|
||||
export const hashCode = (s) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < s.length; i += 1) {
|
||||
const char = s.charCodeAt(i);
|
||||
|
@ -288,3 +291,16 @@ export const randomAlphanumericString = (len) => {
|
|||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
export const urlB64ToUint8Array = (base64String) => {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; i += 1) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue