Compare commits

...

4 Commits

Author SHA1 Message Date
binwiederhier
2e39a1329c AI docs, needs work 2026-01-14 21:48:21 -05:00
binwiederhier
ff2b8167b4 Make closing notifications work 2026-01-14 21:17:21 -05:00
binwiederhier
96638b516c Fix service worker handling for updating/deleting 2026-01-14 20:46:18 -05:00
binwiederhier
dd9b36cf0a Fix db crash 2026-01-13 21:29:44 -05:00
14 changed files with 271 additions and 84 deletions

View File

@@ -96,8 +96,8 @@ appreciated.
## Can I email you? Can I DM you on Discord/Matrix? ## Can I email you? Can I DM you on Discord/Matrix?
For community support, please use the public channels listed on the [contact page](contact.md). I generally For community support, please use the public channels listed on the [contact page](contact.md). I generally
**do not respond to direct messages** about ntfy, unless you are paying for a [ntfy Pro](https://ntfy.sh/#pricing) **do not respond to direct messages** about ntfy, unless you are paying for a [ntfy Pro](https://ntfy.sh/#pricing)
plan (see [paid support](contact.md#paid-support-ntfy-pro-subscribers)), or you are inquiring about business plan (see [paid support](contact.md#paid-support)), or you are inquiring about business
opportunities (see [general inquiries](contact.md#general-inquiries)). opportunities (see [other inquiries](contact.md#other-inquiries)).
I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions
in public forums benefits others, since I can link to the discussion at a later point in time, or other users in public forums benefits others, since I can link to the discussion at a later point in time, or other users

View File

@@ -1418,22 +1418,23 @@ The JSON message format closely mirrors the format of the message you can consum
(see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of (see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of
all the supported fields: all the supported fields:
| Field | Required | Type | Example | Description | | Field | Required | Type | Example | Description |
|------------|----------|----------------------------------|-------------------------------------------|-----------------------------------------------------------------------| |---------------|----------|----------------------------------|-------------------------------------------|-------------------------------------------------------------------------------------------|
| `topic` | ✔️ | *string* | `topic1` | Target topic name | | `topic` | ✔️ | *string* | `topic1` | Target topic name |
| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed | | `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed |
| `title` | - | *string* | `Some title` | Message [title](#message-title) | | `title` | - | *string* | `Some title` | Message [title](#message-title) |
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis | | `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis |
| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max | | `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max |
| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications | | `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications |
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) | | `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) | | `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) |
| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted | | `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted |
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) | | `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
| `filename` | - | *string* | `file.jpg` | File name of the attachment | | `filename` | - | *string* | `file.jpg` | File name of the attachment |
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery | | `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications | | `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) | | `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) |
## Action buttons ## Action buttons
_Supported on:_ :material-android: :material-apple: :material-firefox: _Supported on:_ :material-android: :material-apple: :material-firefox:
@@ -2694,6 +2695,134 @@ Here's an example that will open Reddit when the notification is clicked:
])); ]));
``` ```
## Updating + deleting notifications
_Supported on:_ :material-android: :material-firefox:
You can update, clear (mark as read), or delete notifications that have already been delivered. This is useful for scenarios
like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant.
The key concept is the **sequence ID** (`sequence_id` or `sid`): notifications with the same sequence ID are treated as
belonging to the same sequence, and clients will update/replace the notification accordingly.
### Updating notifications
To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous
notification with the new one.
You can either:
1. **Use the message ID**: First publish without a sequence ID, then use the returned message `id` as the sequence ID for updates
2. **Use a custom sequence ID**: Publish directly to `/<topic>/<sequence_id>` with your own identifier
=== "Using the message ID"
```bash
# First, publish a message and capture the message ID
$ curl -d "Downloading file..." ntfy.sh/mytopic
{"id":"xE73Iyuabi","time":1673542291,...}
# Then use the message ID to update it
$ curl -d "Download complete!" ntfy.sh/mytopic/xE73Iyuabi
```
=== "Using a custom sequence ID"
```bash
# Publish with a custom sequence ID
$ curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123
# Update using the same sequence ID
$ curl -d "Download complete!" ntfy.sh/mytopic/my-download-123
```
=== "Using the X-Sequence-ID header"
```bash
# Publish with a sequence ID via header
$ curl -H "X-Sequence-ID: my-download-123" -d "Downloading..." ntfy.sh/mytopic
# Update using the same sequence ID
$ curl -H "X-Sequence-ID: my-download-123" -d "Done!" ntfy.sh/mytopic
```
You can also set the sequence ID via the `sid` query parameter or when [publishing as JSON](#publish-as-json) using the
`sequence_id` field.
### Clearing notifications
To clear a notification (mark it as read and dismiss it from the notification drawer), send a PUT request to
`/<topic>/<sequence_id>/clear` (or `/<topic>/<sequence_id>/read` as an alias):
=== "Command line (curl)"
```bash
curl -X PUT ntfy.sh/mytopic/my-download-123/clear
```
=== "HTTP"
```http
PUT /mytopic/my-download-123/clear HTTP/1.1
Host: ntfy.sh
```
This publishes a `message_clear` event, which tells clients to:
- Mark the notification as read in the app
- Dismiss the browser/Android notification
### Deleting notifications
To delete a notification entirely, send a DELETE request to `/<topic>/<sequence_id>`:
=== "Command line (curl)"
```bash
curl -X DELETE ntfy.sh/mytopic/my-download-123
```
=== "HTTP"
```http
DELETE /mytopic/my-download-123 HTTP/1.1
Host: ntfy.sh
```
This publishes a `message_delete` event, which tells clients to:
- Delete the notification from the database
- Dismiss the browser/Android notification
!!! info
Deleted sequences can be revived by publishing a new message with the same sequence ID. The notification will
reappear as a new message.
### Full example
Here's a complete example showing the lifecycle of a notification with updates, clearing, and deletion:
```bash
# 1. Create a notification with a custom sequence ID
$ curl -d "Starting backup..." ntfy.sh/mytopic/backup-2024
# 2. Update the notification with progress
$ curl -d "Backup 50% complete..." ntfy.sh/mytopic/backup-2024
# 3. Update again when complete
$ curl -d "Backup finished successfully!" ntfy.sh/mytopic/backup-2024
# 4. Clear the notification (dismiss from notification drawer)
$ curl -X PUT ntfy.sh/mytopic/backup-2024/clear
# 5. Later, delete the notification entirely
$ curl -X DELETE ntfy.sh/mytopic/backup-2024
```
When polling the topic, you'll see the complete sequence of events:
```bash
$ curl -s "ntfy.sh/mytopic/json?poll=1&since=all" | jq .
```
```json
{"id":"abc123","time":1673542291,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Starting backup..."}
{"id":"def456","time":1673542295,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Backup 50% complete..."}
{"id":"ghi789","time":1673542300,"event":"message","topic":"mytopic","sequence_id":"backup-2024","message":"Backup finished successfully!"}
{"id":"jkl012","time":1673542305,"event":"message_clear","topic":"mytopic","sequence_id":"backup-2024"}
{"id":"mno345","time":1673542400,"event":"message_delete","topic":"mytopic","sequence_id":"backup-2024"}
```
Clients process these events in order, keeping only the latest state for each sequence ID.
## Attachments ## Attachments
_Supported on:_ :material-android: :material-firefox: _Supported on:_ :material-android: :material-firefox:

View File

@@ -1599,12 +1599,22 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet ## Not released yet
### ntfy server v2.16.x (UNRELEASED)
**Features:**
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications): You can now update,
clear (mark as read), or delete notifications using a sequence ID. This enables use cases like progress updates,
replacing outdated notifications, or dismissing notifications from all clients.
### ntfy Android app v1.22.x (UNRELEASED) ### ntfy Android app v1.22.x (UNRELEASED)
**Features:** **Features:**
* Support for self-signed certs and client certs for mTLS (#215, #530, ntfy-android#149) * Support for self-signed certs and client certs for mTLS (#215, #530, ntfy-android#149)
* Connection error dialog to help diagnose connection issues * Connection error dialog to help diagnose connection issues
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications): Notifications with
the same sequence ID are updated in place, and `message_delete`/`message_clear` events dismiss notifications
**Bug fixes + maintenance:** **Bug fixes + maintenance:**

View File

@@ -324,20 +324,21 @@ format of the message. It's very straight forward:
**Message**: **Message**:
| Field | Required | Type | Example | Description | | Field | Required | Type | Example | Description |
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------| |---------------|----------|---------------------------------------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier | | `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp | | `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
| `expires` | (✔) | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent | | `expires` | (✔) | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent |
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` | | `event` | ✔️ | `open`, `keepalive`, `message`, `message_delete`, `message_clear`, `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events | | `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
| `message` | - | *string* | `Some message` | Message body; always present in `message` events | | `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](../publish.md#updating-deleting-notifications) |
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` | | `message` | - | *string* | `Some message` | Message body; always present in `message` events |
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis | | `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max | | `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) | | `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification | | `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) | | `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification |
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details): **Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):

View File

@@ -86,8 +86,6 @@ var (
webConfigPath = "/config.js" webConfigPath = "/config.js"
webManifestPath = "/manifest.webmanifest" webManifestPath = "/manifest.webmanifest"
webRootHTMLPath = "/app.html"
webServiceWorkerPath = "/sw.js"
accountPath = "/account" accountPath = "/account"
matrixPushPath = "/_matrix/push/v1/notify" matrixPushPath = "/_matrix/push/v1/notify"
metricsPath = "/metrics" metricsPath = "/metrics"
@@ -111,7 +109,7 @@ var (
apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}" apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}"
apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`) apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`)
apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`) 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(|/.*)$`) docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
urlRegex = regexp.MustCompile(`^https?://`) urlRegex = regexp.MustCompile(`^https?://`)
@@ -534,7 +532,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.handleMatrixDiscovery(w) return s.handleMatrixDiscovery(w)
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil { } else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
return s.handleMetrics(w, r, v) 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) return s.ensureWebEnabled(s.handleStatic)(w, r, v)
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
return s.ensureWebEnabled(s.handleDocs)(w, r, v) return s.ensureWebEnabled(s.handleDocs)(w, r, v)

View File

@@ -3,13 +3,10 @@ import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from
import { NavigationRoute, registerRoute } from "workbox-routing"; import { NavigationRoute, registerRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies"; import { NetworkFirst } from "workbox-strategies";
import { clientsClaim } from "workbox-core"; import { clientsClaim } from "workbox-core";
import { dbAsync } from "../src/app/db"; import { dbAsync } from "../src/app/db";
import { badge, icon, messageWithSequenceId, toNotificationParams } from "../src/app/notificationUtils";
import { toNotificationParams, icon, badge } from "../src/app/notificationUtils";
import initI18n from "../src/app/i18n"; import initI18n from "../src/app/i18n";
import { messageWithSequenceId } from "../src/app/utils"; import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE, EVENT_SUBSCRIPTION_EXPIRING } from "../src/app/events";
import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../src/app/events";
/** /**
* General docs for service workers and PWAs: * 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 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(); 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) // Delete existing notification with same sequence ID (if any)
const sequenceId = message.sequence_id || message.id; const sequenceId = message.sequence_id || message.id;
@@ -46,22 +50,9 @@ const addNotification = async ({ subscriptionId, message }) => {
last: message.id, last: message.id,
}); });
// Update badge in PWA
const badgeCount = await db.notifications.where({ new: 1 }).count(); const badgeCount = await db.notifications.where({ new: 1 }).count();
console.log("[ServiceWorker] Setting new app badge count", { badgeCount });
self.navigator.setAppBadge?.(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 // Broadcast the message to potentially play a sound
broadcastChannel.postMessage(message); broadcastChannel.postMessage(message);
@@ -82,14 +73,21 @@ const handlePushMessage = async (data) => {
const handlePushMessageDelete = async (data) => { const handlePushMessageDelete = async (data) => {
const { subscription_id: subscriptionId, message } = data; const { subscription_id: subscriptionId, message } = data;
const db = await dbAsync(); const db = await dbAsync();
console.log("[ServiceWorker] Deleting notification sequence", data);
// Delete notification with the same sequence_id // Delete notification with the same sequence_id
const sequenceId = message.sequence_id; const sequenceId = message.sequence_id;
if (sequenceId) { if (sequenceId) {
console.log("[ServiceWorker] Deleting notification with sequenceId", { subscriptionId, sequenceId });
await db.notifications.where({ subscriptionId, sequenceId }).delete(); await db.notifications.where({ subscriptionId, sequenceId }).delete();
} }
// Close browser notification with matching tag
const tag = message.sequence_id || message.id;
if (tag) {
const notifications = await self.registration.getNotifications({ tag });
notifications.forEach((notification) => notification.close());
}
// Update subscription last message id (for ?since=... queries) // Update subscription last message id (for ?since=... queries)
await db.subscriptions.update(subscriptionId, { await db.subscriptions.update(subscriptionId, {
last: message.id, last: message.id,
@@ -102,14 +100,21 @@ const handlePushMessageDelete = async (data) => {
const handlePushMessageClear = async (data) => { const handlePushMessageClear = async (data) => {
const { subscription_id: subscriptionId, message } = data; const { subscription_id: subscriptionId, message } = data;
const db = await dbAsync(); const db = await dbAsync();
console.log("[ServiceWorker] Marking notification as read", data);
// Mark notification as read (set new = 0) // Mark notification as read (set new = 0)
const sequenceId = message.sequence_id; const sequenceId = message.sequence_id;
if (sequenceId) { if (sequenceId) {
console.log("[ServiceWorker] Marking notification as read", { subscriptionId, sequenceId });
await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 }); await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
} }
// Close browser notification with matching tag
const tag = message.sequence_id || message.id;
if (tag) {
const notifications = await self.registration.getNotifications({ tag });
notifications.forEach((notification) => notification.close());
}
// Update subscription last message id (for ?since=... queries) // Update subscription last message id (for ?since=... queries)
await db.subscriptions.update(subscriptionId, { await db.subscriptions.update(subscriptionId, {
last: message.id, last: message.id,
@@ -117,7 +122,6 @@ const handlePushMessageClear = async (data) => {
// Update badge count // Update badge count
const badgeCount = await db.notifications.where({ new: 1 }).count(); const badgeCount = await db.notifications.where({ new: 1 }).count();
console.log("[ServiceWorker] Setting new app badge count", { badgeCount });
self.navigator.setAppBadge?.(badgeCount); self.navigator.setAppBadge?.(badgeCount);
}; };
@@ -126,6 +130,7 @@ const handlePushMessageClear = async (data) => {
*/ */
const handlePushSubscriptionExpiring = async (data) => { const handlePushSubscriptionExpiring = async (data) => {
const t = await initI18n(); const t = await initI18n();
console.log("[ServiceWorker] Handling incoming subscription expiring event", data);
await self.registration.showNotification(t("web_push_subscription_expiring_title"), { await self.registration.showNotification(t("web_push_subscription_expiring_title"), {
body: t("web_push_subscription_expiring_body"), body: t("web_push_subscription_expiring_body"),
@@ -141,6 +146,7 @@ const handlePushSubscriptionExpiring = async (data) => {
*/ */
const handlePushUnknown = async (data) => { const handlePushUnknown = async (data) => {
const t = await initI18n(); const t = await initI18n();
console.log("[ServiceWorker] Unknown event received", data);
await self.registration.showNotification(t("web_push_unknown_notification_title"), { await self.registration.showNotification(t("web_push_unknown_notification_title"), {
body: t("web_push_unknown_notification_body"), body: t("web_push_unknown_notification_body"),
@@ -155,13 +161,15 @@ const handlePushUnknown = async (data) => {
* @param {object} data see server/types.go, type webPushPayload * @param {object} data see server/types.go, type webPushPayload
*/ */
const handlePush = async (data) => { const handlePush = async (data) => {
if (data.event === EVENT_MESSAGE) { const { message } = data;
if (message.event === EVENT_MESSAGE) {
await handlePushMessage(data); await handlePushMessage(data);
} else if (data.event === EVENT_MESSAGE_DELETE) { } else if (message.event === EVENT_MESSAGE_DELETE) {
await handlePushMessageDelete(data); await handlePushMessageDelete(data);
} else if (data.event === EVENT_MESSAGE_CLEAR) { } else if (message.event === EVENT_MESSAGE_CLEAR) {
await handlePushMessageClear(data); await handlePushMessageClear(data);
} else if (data.event === "subscription_expiring") { } else if (message.event === EVENT_SUBSCRIPTION_EXPIRING) {
await handlePushSubscriptionExpiring(data); await handlePushSubscriptionExpiring(data);
} else { } else {
await handlePushUnknown(data); await handlePushUnknown(data);
@@ -176,10 +184,8 @@ const handleClick = async (event) => {
const t = await initI18n(); const t = await initI18n();
const clients = await self.clients.matchAll({ type: "window" }); const clients = await self.clients.matchAll({ type: "window" });
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]; const fallbackClient = clients[0];
if (!event.notification.data?.message) { if (!event.notification.data?.message) {
@@ -295,6 +301,7 @@ precacheAndRoute(
// Claim all open windows // Claim all open windows
clientsClaim(); clientsClaim();
// Delete any cached old dist files from previous service worker versions // Delete any cached old dist files from previous service worker versions
cleanupOutdatedCaches(); cleanupOutdatedCaches();

View File

@@ -26,11 +26,26 @@ class Notifier {
subscriptionId: subscription.id, subscriptionId: subscription.id,
message: notification, message: notification,
defaultTitle, defaultTitle,
topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(), topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString()
}) })
); );
} }
async cancel(notification) {
if (!this.supported()) {
return;
}
try {
const tag = notification.sequence_id || notification.id;
console.log(`[Notifier] Cancelling notification with ${tag}`);
const registration = await this.serviceWorkerRegistration();
const notifications = await registration.getNotifications({ tag });
notifications.forEach(n => n.close());
} catch (e) {
console.log(`[Notifier] Error cancelling notification`, e);
}
}
async playSound() { async playSound() {
// Play sound // Play sound
const sound = await prefs.sound(); const sound = await prefs.sound();
@@ -57,7 +72,7 @@ class Notifier {
if (hasWebPushTopics) { if (hasWebPushTopics) {
return pushManager.subscribe({ return pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array(config.web_push_public_key), applicationServerKey: urlB64ToUint8Array(config.web_push_public_key)
}); });
} }

View File

@@ -2,8 +2,9 @@ import api from "./Api";
import notifier from "./Notifier"; import notifier from "./Notifier";
import prefs from "./Prefs"; import prefs from "./Prefs";
import db from "./db"; import db from "./db";
import { messageWithSequenceId, topicUrl } from "./utils"; import { topicUrl } from "./utils";
import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "./events"; import { messageWithSequenceId } from "./notificationUtils";
import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from "./events";
class SubscriptionManager { class SubscriptionManager {
constructor(dbImpl) { constructor(dbImpl) {

View File

@@ -18,6 +18,13 @@ const createDatabase = (username) => {
prefs: "&key", prefs: "&key",
}); });
// When another connection (e.g., service worker or another tab) wants to upgrade,
// close this connection gracefully to allow the upgrade to proceed
db.on("versionchange", () => {
console.log("[db] versionchange event: closing database");
db.close();
});
return db; return db;
}; };

View File

@@ -7,6 +7,7 @@ export const EVENT_MESSAGE = "message";
export const EVENT_MESSAGE_DELETE = "message_delete"; export const EVENT_MESSAGE_DELETE = "message_delete";
export const EVENT_MESSAGE_CLEAR = "message_clear"; export const EVENT_MESSAGE_CLEAR = "message_clear";
export const EVENT_POLL_REQUEST = "poll_request"; 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) // 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; export const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR;

View File

@@ -25,13 +25,13 @@ const formatTitleWithDefault = (m, fallback) => {
export const formatMessage = (m) => { export const formatMessage = (m) => {
if (m.title) { if (m.title) {
return m.message; return m.message || "";
} }
const emojiList = toEmojis(m.tags); const emojiList = toEmojis(m.tags);
if (emojiList.length > 0) { 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; 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 };
};

View File

@@ -103,13 +103,6 @@ export const maybeActionErrors = (notification) => {
return actionErrors; return actionErrors;
}; };
export const messageWithSequenceId = (message) => {
if (message.sequenceId) {
return message;
}
return { ...message, sequenceId: message.sequence_id || message.id };
};
export const shuffle = (arr) => { export const shuffle = (arr) => {
const returnArr = [...arr]; const returnArr = [...arr];

View File

@@ -55,11 +55,11 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
// and FirebaseService::handleMessage(). // and FirebaseService::handleMessage().
if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) { if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) {
// Handle delete: remove notification from database
await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id); await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id);
await notifier.cancel(notification);
} else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) { } else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) {
// Handle read: mark notification as read
await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id); await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id);
await notifier.cancel(notification);
} else { } else {
// Regular message: delete existing and add new // Regular message: delete existing and add new
const sequenceId = notification.sequence_id || notification.id; const sequenceId = notification.sequence_id || notification.id;

View File

@@ -5,10 +5,21 @@ import { registerSW as viteRegisterSW } from "virtual:pwa-register";
const intervalMS = 60 * 60 * 1000; const intervalMS = 60 * 60 * 1000;
// https://vite-pwa-org.netlify.app/guide/periodic-sw-updates.html // 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({ viteRegisterSW({
onRegisteredSW(swUrl, registration) { onRegisteredSW(swUrl, registration) {
console.log("[ServiceWorker] Registered:", { swUrl, registration });
if (!registration) { if (!registration) {
console.warn("[ServiceWorker] No registration returned");
return; 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); }, intervalMS);
}, },
onRegisterError(error) {
console.error("[ServiceWorker] Registration error:", error);
},
}); });
};
export default registerSW; export default registerSW;