diff --git a/server/server.go b/server/server.go index 7ef43275..3bd53ea6 100644 --- a/server/server.go +++ b/server/server.go @@ -86,8 +86,6 @@ var ( webConfigPath = "/config.js" webManifestPath = "/manifest.webmanifest" - webRootHTMLPath = "/app.html" - webServiceWorkerPath = "/sw.js" accountPath = "/account" matrixPushPath = "/_matrix/push/v1/notify" metricsPath = "/metrics" @@ -111,7 +109,7 @@ var ( apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}" apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`) apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`) - staticRegex = regexp.MustCompile(`^/static/.+`) + staticRegex = regexp.MustCompile(`^/(static/.+|app.html|sw.js|sw.js.map)$`) docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) urlRegex = regexp.MustCompile(`^https?://`) @@ -534,7 +532,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.handleMatrixDiscovery(w) } else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil { return s.handleMetrics(w, r, v) - } else if r.Method == http.MethodGet && (staticRegex.MatchString(r.URL.Path) || r.URL.Path == webServiceWorkerPath || r.URL.Path == webRootHTMLPath) { + } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { return s.ensureWebEnabled(s.handleStatic)(w, r, v) } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { return s.ensureWebEnabled(s.handleDocs)(w, r, v) diff --git a/web/public/sw.js b/web/public/sw.js index 0dc1afef..07a89415 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -3,13 +3,10 @@ import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from import { NavigationRoute, registerRoute } from "workbox-routing"; import { NetworkFirst } from "workbox-strategies"; import { clientsClaim } from "workbox-core"; - import { dbAsync } from "../src/app/db"; - -import { toNotificationParams, icon, badge } from "../src/app/notificationUtils"; +import { badge, icon, messageWithSequenceId, toNotificationParams } from "../src/app/notificationUtils"; import initI18n from "../src/app/i18n"; -import { messageWithSequenceId } from "../src/app/utils"; -import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../src/app/events"; +import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE, EVENT_SUBSCRIPTION_EXPIRING } from "../src/app/events"; /** * General docs for service workers and PWAs: @@ -23,10 +20,17 @@ import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../src const broadcastChannel = new BroadcastChannel("web-push-broadcast"); -const addNotification = async ({ subscriptionId, message }) => { +/** + * Handle a received web push message and show notification. + * + * Since the service worker cannot play a sound, we send a broadcast to the web app, which (if it is running) + * receives the broadcast and plays a sound (see web/src/app/WebPush.js). + */ +const handlePushMessage = async (data) => { + const { subscription_id: subscriptionId, message } = data; const db = await dbAsync(); - // Note: SubscriptionManager duplicates this logic, so if you change it here, change it there too + console.log("[ServiceWorker] Message received", data); // Delete existing notification with same sequence ID (if any) const sequenceId = message.sequence_id || message.id; @@ -46,22 +50,9 @@ const addNotification = async ({ subscriptionId, message }) => { last: message.id, }); + // Update badge in PWA const badgeCount = await db.notifications.where({ new: 1 }).count(); - console.log("[ServiceWorker] Setting new app badge count", { badgeCount }); self.navigator.setAppBadge?.(badgeCount); -}; - -/** - * Handle a received web push message and show notification. - * - * Since the service worker cannot play a sound, we send a broadcast to the web app, which (if it is running) - * receives the broadcast and plays a sound (see web/src/app/WebPush.js). - */ -const handlePushMessage = async (data) => { - const { subscription_id: subscriptionId, message } = data; - - // Add notification to database - await addNotification({ subscriptionId, message }); // Broadcast the message to potentially play a sound broadcastChannel.postMessage(message); @@ -82,11 +73,11 @@ const handlePushMessage = async (data) => { const handlePushMessageDelete = async (data) => { const { subscription_id: subscriptionId, message } = data; const db = await dbAsync(); + console.log("[ServiceWorker] Deleting notification sequence", data); // Delete notification with the same sequence_id const sequenceId = message.sequence_id; if (sequenceId) { - console.log("[ServiceWorker] Deleting notification with sequenceId", { subscriptionId, sequenceId }); await db.notifications.where({ subscriptionId, sequenceId }).delete(); } @@ -102,11 +93,11 @@ const handlePushMessageDelete = async (data) => { const handlePushMessageClear = async (data) => { const { subscription_id: subscriptionId, message } = data; const db = await dbAsync(); + console.log("[ServiceWorker] Marking notification as read", data); // Mark notification as read (set new = 0) const sequenceId = message.sequence_id; if (sequenceId) { - console.log("[ServiceWorker] Marking notification as read", { subscriptionId, sequenceId }); await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 }); } @@ -126,6 +117,7 @@ const handlePushMessageClear = async (data) => { */ const handlePushSubscriptionExpiring = async (data) => { const t = await initI18n(); + console.log("[ServiceWorker] Handling incoming subscription expiring event", data); await self.registration.showNotification(t("web_push_subscription_expiring_title"), { body: t("web_push_subscription_expiring_body"), @@ -141,6 +133,7 @@ const handlePushSubscriptionExpiring = async (data) => { */ const handlePushUnknown = async (data) => { const t = await initI18n(); + console.log("[ServiceWorker] Unknown event received", data); await self.registration.showNotification(t("web_push_unknown_notification_title"), { body: t("web_push_unknown_notification_body"), @@ -155,13 +148,15 @@ const handlePushUnknown = async (data) => { * @param {object} data see server/types.go, type webPushPayload */ const handlePush = async (data) => { - if (data.event === EVENT_MESSAGE) { + const { message } = data; + + if (message.event === EVENT_MESSAGE) { await handlePushMessage(data); - } else if (data.event === EVENT_MESSAGE_DELETE) { + } else if (message.event === EVENT_MESSAGE_DELETE) { await handlePushMessageDelete(data); - } else if (data.event === EVENT_MESSAGE_CLEAR) { + } else if (message.event === EVENT_MESSAGE_CLEAR) { await handlePushMessageClear(data); - } else if (data.event === "subscription_expiring") { + } else if (message.event === EVENT_SUBSCRIPTION_EXPIRING) { await handlePushSubscriptionExpiring(data); } else { await handlePushUnknown(data); @@ -176,10 +171,8 @@ const handleClick = async (event) => { const t = await initI18n(); const clients = await self.clients.matchAll({ type: "window" }); - const rootUrl = new URL(self.location.origin); const rootClient = clients.find((client) => client.url === rootUrl.toString()); - // perhaps open on another topic const fallbackClient = clients[0]; if (!event.notification.data?.message) { @@ -295,6 +288,7 @@ precacheAndRoute( // Claim all open windows clientsClaim(); + // Delete any cached old dist files from previous service worker versions cleanupOutdatedCaches(); diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 430c5e2c..f909778e 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -2,8 +2,9 @@ import api from "./Api"; import notifier from "./Notifier"; import prefs from "./Prefs"; import db from "./db"; -import { messageWithSequenceId, topicUrl } from "./utils"; -import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "./events"; +import { topicUrl } from "./utils"; +import { messageWithSequenceId } from "./notificationUtils"; +import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from "./events"; class SubscriptionManager { constructor(dbImpl) { diff --git a/web/src/app/events.js b/web/src/app/events.js index 48537652..94d7dc79 100644 --- a/web/src/app/events.js +++ b/web/src/app/events.js @@ -7,6 +7,7 @@ export const EVENT_MESSAGE = "message"; export const EVENT_MESSAGE_DELETE = "message_delete"; export const EVENT_MESSAGE_CLEAR = "message_clear"; export const EVENT_POLL_REQUEST = "poll_request"; +export const EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring"; // Check if an event is a notification event (message, delete, or read) export const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR; diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index 2d80e0be..a9b8f8ff 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -25,13 +25,13 @@ const formatTitleWithDefault = (m, fallback) => { export const formatMessage = (m) => { if (m.title) { - return m.message; + return m.message || ""; } const emojiList = toEmojis(m.tags); if (emojiList.length > 0) { - return `${emojiList.join(" ")} ${m.message}`; + return `${emojiList.join(" ")} ${m.message || ""}`; } - return m.message; + return m.message || ""; }; const imageRegex = /\.(png|jpe?g|gif|webp)$/i; @@ -79,3 +79,10 @@ export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => { }, ]; }; + +export const messageWithSequenceId = (message) => { + if (message.sequenceId) { + return message; + } + return { ...message, sequenceId: message.sequence_id || message.id }; +}; diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 9e095c7e..935f2024 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -103,13 +103,6 @@ export const maybeActionErrors = (notification) => { return actionErrors; }; -export const messageWithSequenceId = (message) => { - if (message.sequenceId) { - return message; - } - return { ...message, sequenceId: message.sequence_id || message.id }; -}; - export const shuffle = (arr) => { const returnArr = [...arr]; diff --git a/web/src/registerSW.js b/web/src/registerSW.js index adef4746..5ae85628 100644 --- a/web/src/registerSW.js +++ b/web/src/registerSW.js @@ -5,10 +5,21 @@ import { registerSW as viteRegisterSW } from "virtual:pwa-register"; const intervalMS = 60 * 60 * 1000; // https://vite-pwa-org.netlify.app/guide/periodic-sw-updates.html -const registerSW = () => +const registerSW = () => { + console.log("[ServiceWorker] Registering service worker"); + console.log("[ServiceWorker] serviceWorker in navigator:", "serviceWorker" in navigator); + + if (!("serviceWorker" in navigator)) { + console.warn("[ServiceWorker] Service workers not supported"); + return; + } + viteRegisterSW({ onRegisteredSW(swUrl, registration) { + console.log("[ServiceWorker] Registered:", { swUrl, registration }); + if (!registration) { + console.warn("[ServiceWorker] No registration returned"); return; } @@ -23,9 +34,16 @@ const registerSW = () => }, }); - if (resp?.status === 200) await registration.update(); + if (resp?.status === 200) { + console.log("[ServiceWorker] Updating service worker"); + await registration.update(); + } }, intervalMS); }, + onRegisterError(error) { + console.error("[ServiceWorker] Registration error:", error); + }, }); +}; export default registerSW;