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:
nimbleghost 2023-05-24 21:36:01 +02:00
parent 733ef4664b
commit ff5c854192
53 changed files with 4363 additions and 249 deletions

View file

@ -382,6 +382,10 @@ class AccountApi {
setTimeout(() => this.runWorker(), delayMillis);
}
stopWorker() {
clearTimeout(this.timer);
}
async runWorker() {
if (!session.token()) {
return;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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;

View file

@ -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;

View 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;

View file

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

View file

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

View 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();

View file

@ -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
View 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;

View file

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