diff --git a/server/errors.go b/server/errors.go index 950d23fc..e8f58d75 100644 --- a/server/errors.go +++ b/server/errors.go @@ -125,7 +125,7 @@ var ( errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil} errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} - errHTTPBadRequestSIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#TODO", nil} + errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#TODO", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} diff --git a/server/message_cache.go b/server/message_cache.go index c00c67c8..396cd7a2 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -51,7 +51,7 @@ const ( content_type TEXT NOT NULL, encoding TEXT NOT NULL, published INT NOT NULL, - deleted INT NOT NULL + event TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id); @@ -69,58 +69,57 @@ const ( COMMIT; ` insertMessageQuery = ` - INSERT INTO messages (mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published, deleted) + INSERT INTO messages (mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published, event) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics selectMessagesByIDQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE mid = ? ` selectMessagesSinceTimeQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE topic = ? AND time >= ? AND published = 1 ORDER BY time, id ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE topic = ? AND time >= ? ORDER BY time, id ` selectMessagesSinceIDQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE topic = ? AND id > ? AND published = 1 ORDER BY time, id ` selectMessagesSinceIDIncludeScheduledQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE topic = ? AND (id > ? OR published = 0) ORDER BY time, id ` selectMessagesLatestQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE topic = ? AND published = 1 ORDER BY time DESC, id DESC LIMIT 1 ` selectMessagesDueQuery = ` - SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, deleted + SELECT mid, sequence_id, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding, event FROM messages WHERE time <= ? AND published = 0 ORDER BY time, id ` - selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` - updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` - updateMessageDeletedQuery = `UPDATE messages SET deleted = 1 WHERE mid = ?` - selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` + selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` + updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` + selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic` selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` @@ -268,7 +267,7 @@ const ( //13 -> 14 migrate13To14AlterMessagesTableQuery = ` ALTER TABLE messages ADD COLUMN sequence_id TEXT NOT NULL DEFAULT(''); - ALTER TABLE messages ADD COLUMN deleted INT NOT NULL DEFAULT('0'); + ALTER TABLE messages ADD COLUMN event TEXT NOT NULL DEFAULT('message'); CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id); ` ) @@ -381,7 +380,7 @@ func (c *messageCache) addMessages(ms []*message) error { } defer stmt.Close() for _, m := range ms { - if m.Event != messageEvent { + if m.Event != messageEvent && m.Event != messageDeleteEvent && m.Event != messageReadEvent { return errUnexpectedMessageType } published := m.Time <= time.Now().Unix() @@ -431,7 +430,7 @@ func (c *messageCache) addMessages(ms []*message) error { m.ContentType, m.Encoding, published, - m.Deleted, + m.Event, ) if err != nil { return err @@ -720,8 +719,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) { func readMessage(rows *sql.Rows) (*message, error) { var timestamp, expires, attachmentSize, attachmentExpires int64 var priority int - var id, sequenceID, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string - var deleted bool + var id, sequenceID, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding, event string err := rows.Scan( &id, &sequenceID, @@ -744,7 +742,7 @@ func readMessage(rows *sql.Rows) (*message, error) { &user, &contentType, &encoding, - &deleted, + &event, ) if err != nil { return nil, err @@ -782,7 +780,7 @@ func readMessage(rows *sql.Rows) (*message, error) { SequenceID: sequenceID, Time: timestamp, Expires: expires, - Event: messageEvent, + Event: event, Topic: topic, Message: msg, Title: title, @@ -796,7 +794,6 @@ func readMessage(rows *sql.Rows) (*message, error) { User: user, ContentType: contentType, Encoding: encoding, - Deleted: deleted, }, nil } diff --git a/server/server.go b/server/server.go index 635258fa..8aff7f7e 100644 --- a/server/server.go +++ b/server/server.go @@ -80,8 +80,9 @@ var ( wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`) authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`) publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) - sidRegex = topicRegex updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`) + markReadPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/read$`) + sequenceIDRegex = topicRegex webConfigPath = "/config.js" webManifestPath = "/manifest.webmanifest" @@ -140,7 +141,6 @@ const ( firebaseControlTopic = "~control" // See Android if changed firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now) emptyMessageBody = "triggered" // Used when a message body is empty - deletedMessageBody = "deleted" // Used when a message is deleted newMessageBody = "New message" // Used in poll requests as generic message defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages @@ -550,6 +550,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v) + } else if r.Method == http.MethodPut && markReadPathRegex.MatchString(r.URL.Path) { + return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleMarkRead))(w, r, v) } else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) { return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v) } else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) { @@ -921,10 +923,8 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor if e != nil { return e.With(t) } - // Create a delete message: empty body, same SequenceID, deleted flag set - m := newDefaultMessage(t.ID, deletedMessageBody) - m.SequenceID = sequenceID - m.Deleted = true + // Create a delete message with event type message_delete + m := newActionMessage(messageDeleteEvent, t.ID, sequenceID) m.Sender = v.IP() m.User = v.MaybeUserID() m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() @@ -951,6 +951,50 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor return s.writeJSON(w, m) } +func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request, v *visitor) error { + t, err := fromContext[*topic](r, contextTopic) + if err != nil { + return err + } + vrate, err := fromContext[*visitor](r, contextRateVisitor) + if err != nil { + return err + } + if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { + return errHTTPTooManyRequestsLimitMessages.With(t) + } + sequenceID, e := s.sequenceIDFromPath(r.URL.Path) + if e != nil { + return e.With(t) + } + // Create a read message with event type message_read + m := newActionMessage(messageReadEvent, t.ID, sequenceID) + m.Sender = v.IP() + m.User = v.MaybeUserID() + m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() + // Publish to subscribers + if err := t.Publish(v, m); err != nil { + return err + } + // Send to Firebase for Android clients + if s.firebaseClient != nil { + go s.sendToFirebase(v, m) + } + // Send to web push endpoints + if s.config.WebPushPublicKey != "" { + go s.publishToWebPushEndpoints(v, m) + } + // Add to message cache + if err := s.messageCache.AddMessage(m); err != nil { + return err + } + logvrm(v, r, m).Tag(tagPublish).Debug("Marked message as read with sequence ID %s", sequenceID) + s.mu.Lock() + s.messages++ + s.mu.Unlock() + return s.writeJSON(w, m) +} + func (s *Server) sendToFirebase(v *visitor, m *message) { logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase") if err := s.firebaseClient.Send(v, m); err != nil { @@ -1017,10 +1061,10 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } else { sequenceID := readParam(r, "x-sequence-id", "sequence-id", "sid") if sequenceID != "" { - if sidRegex.MatchString(sequenceID) { + if sequenceIDRegex.MatchString(sequenceID) { m.SequenceID = sequenceID } else { - return false, false, "", "", "", false, errHTTPBadRequestSIDInvalid + return false, false, "", "", "", false, errHTTPBadRequestSequenceIDInvalid } } else { m.SequenceID = m.ID @@ -1767,8 +1811,8 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) { // sequenceIDFromPath returns the sequence ID from a POST path like /mytopic/sequenceIdHere func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) { parts := strings.Split(path, "/") - if len(parts) != 3 { - return "", errHTTPBadRequestSIDInvalid + if len(parts) < 3 { + return "", errHTTPBadRequestSequenceIDInvalid } return parts[2], nil } diff --git a/server/types.go b/server/types.go index e9c0fdb8..5f9917d1 100644 --- a/server/types.go +++ b/server/types.go @@ -12,10 +12,12 @@ import ( // List of possible events const ( - openEvent = "open" - keepaliveEvent = "keepalive" - messageEvent = "message" - pollRequestEvent = "poll_request" + openEvent = "open" + keepaliveEvent = "keepalive" + messageEvent = "message" + messageDeleteEvent = "message_delete" + messageReadEvent = "message_read" + pollRequestEvent = "poll_request" ) const ( @@ -41,7 +43,6 @@ type message struct { PollID string `json:"poll_id,omitempty"` ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown Encoding string `json:"encoding,omitempty"` // Empty for raw UTF-8, or "base64" for encoded bytes - Deleted bool `json:"deleted,omitempty"` // True if message is marked as deleted Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting User string `json:"-"` // UserID of the uploader, used to associated attachments } @@ -149,6 +150,13 @@ func newPollRequestMessage(topic, pollID string) *message { return m } +// newActionMessage creates a new action message (message_delete or message_read) +func newActionMessage(event, topic, sequenceID string) *message { + m := newMessage(event, topic, "") + m.SequenceID = sequenceID + return m +} + func validMessageID(s string) bool { return util.ValidRandomString(s, messageIDLength) } @@ -227,7 +235,7 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) { } func (q *queryFilter) Pass(msg *message) bool { - if msg.Event != messageEvent { + if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageReadEvent { return true // filters only apply to messages } else if q.ID != "" && msg.ID != q.ID { return false diff --git a/web/public/sw.js b/web/public/sw.js index 38dbc9c1..7d6441e3 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -9,6 +9,7 @@ import { dbAsync } from "../src/app/db"; import { toNotificationParams, icon, badge } from "../src/app/notificationUtils"; import initI18n from "../src/app/i18n"; import { messageWithSequenceId } from "../src/app/utils"; +import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "../src/app/events"; /** * General docs for service workers and PWAs: @@ -62,11 +63,6 @@ const handlePushMessage = async (data) => { // Add notification to database await addNotification({ subscriptionId, message }); - // Don't show a notification for deleted messages - if (message.deleted) { - return; - } - // Broadcast the message to potentially play a sound broadcastChannel.postMessage(message); @@ -80,6 +76,51 @@ const handlePushMessage = async (data) => { ); }; +/** + * Handle a message_delete event: delete the notification from the database. + */ +const handlePushMessageDelete = async (data) => { + const { subscription_id: subscriptionId, message } = data; + const db = await dbAsync(); + + // 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(); + } + + // Update subscription last message id (for ?since=... queries) + await db.subscriptions.update(subscriptionId, { + last: message.id, + }); +}; + +/** + * Handle a message_read event: mark the notification as read. + */ +const handlePushMessageRead = async (data) => { + const { subscription_id: subscriptionId, message } = data; + const db = await dbAsync(); + + // 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 }); + } + + // Update subscription last message id (for ?since=... queries) + await db.subscriptions.update(subscriptionId, { + last: message.id, + }); + + // Update badge count + 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 subscription expiring. */ @@ -114,8 +155,12 @@ const handlePushUnknown = async (data) => { * @param {object} data see server/types.go, type webPushPayload */ const handlePush = async (data) => { - if (data.event === "message") { + if (data.event === EVENT_MESSAGE) { await handlePushMessage(data); + } else if (data.event === EVENT_MESSAGE_DELETE) { + await handlePushMessageDelete(data); + } else if (data.event === EVENT_MESSAGE_READ) { + await handlePushMessageRead(data); } else if (data.event === "subscription_expiring") { await handlePushSubscriptionExpiring(data); } else { diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js index 5358cdde..06043acc 100644 --- a/web/src/app/Connection.js +++ b/web/src/app/Connection.js @@ -1,5 +1,6 @@ /* eslint-disable max-classes-per-file */ import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils"; +import { EVENT_OPEN, isNotificationEvent } from "./events"; const retryBackoffSeconds = [5, 10, 20, 30, 60, 120]; @@ -48,10 +49,11 @@ class Connection { console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`); try { const data = JSON.parse(event.data); - if (data.event === "open") { + if (data.event === EVENT_OPEN) { return; } - const relevantAndValid = data.event === "message" && "id" in data && "time" in data && "message" in data; + // Accept message, message_delete, and message_read events + const relevantAndValid = isNotificationEvent(data.event) && "id" in data && "time" in data; if (!relevantAndValid) { console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`); return; diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index aa0e6dba..56415b76 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -1,6 +1,7 @@ import api from "./Api"; import prefs from "./Prefs"; import subscriptionManager from "./SubscriptionManager"; +import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE } from "./events"; const delayMillis = 2000; // 2 seconds const intervalMillis = 300000; // 5 minutes @@ -55,7 +56,7 @@ class Poller { // Delete all existing notifications for which the latest notification is marked as deleted const deletedSequenceIds = Object.entries(latestBySequenceId) - .filter(([, notification]) => notification.deleted) + .filter(([, notification]) => notification.event === EVENT_MESSAGE_DELETE) .map(([sequenceId]) => sequenceId); if (deletedSequenceIds.length > 0) { console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSequenceIds); @@ -65,7 +66,9 @@ class Poller { } // Add only the latest notification for each non-deleted sequence - const notificationsToAdd = Object.values(latestBySequenceId).filter((n) => !n.deleted); + const notificationsToAdd = Object + .values(latestBySequenceId) + .filter(n => n.event === EVENT_MESSAGE); if (notificationsToAdd.length > 0) { console.log(`[Poller] Adding ${notificationsToAdd.length} notification(s) for ${subscription.id}`); await subscriptionManager.addNotifications(subscription.id, notificationsToAdd); diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 772b30a7..2eecde28 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -3,6 +3,7 @@ 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_READ } from "./events"; class SubscriptionManager { constructor(dbImpl) { @@ -15,7 +16,7 @@ class SubscriptionManager { return Promise.all( subscriptions.map(async (s) => ({ ...s, - new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), + new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count() })) ); } @@ -48,7 +49,7 @@ class SubscriptionManager { } async notify(subscriptionId, notification) { - if (notification.deleted) { + if (notification.event !== EVENT_MESSAGE) { return; } const subscription = await this.get(subscriptionId); @@ -83,7 +84,7 @@ class SubscriptionManager { baseUrl, topic, mutedUntil: 0, - last: null, + last: null }; await this.db.subscriptions.put(subscription); @@ -101,7 +102,7 @@ class SubscriptionManager { const local = await this.add(remote.base_url, remote.topic, { displayName: remote.display_name, // May be undefined - reservation, // May be null! + reservation // May be null! }); return local.id; @@ -174,7 +175,7 @@ class SubscriptionManager { /** Adds notification, or returns false if it already exists */ async addNotification(subscriptionId, notification) { const exists = await this.db.notifications.get(notification.id); - if (exists || notification.deleted) { + if (exists || notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_READ) { return false; } try { @@ -185,13 +186,13 @@ class SubscriptionManager { await this.db.notifications.add({ ...messageWithSequenceId(notification), subscriptionId, - new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + new: 1 // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation }); // FIXME consider put() for double tab // Update subscription last message id (for ?since=... queries) await this.db.subscriptions.update(subscriptionId, { - last: notification.id, + last: notification.id }); } catch (e) { console.error(`[SubscriptionManager] Error adding notification`, e); @@ -207,7 +208,7 @@ class SubscriptionManager { const lastNotificationId = notifications.at(-1).id; await this.db.notifications.bulkPut(notificationsWithSubscriptionId); await this.db.subscriptions.update(subscriptionId, { - last: lastNotificationId, + last: lastNotificationId }); } @@ -250,19 +251,19 @@ class SubscriptionManager { async setMutedUntil(subscriptionId, mutedUntil) { await this.db.subscriptions.update(subscriptionId, { - mutedUntil, + mutedUntil }); } async setDisplayName(subscriptionId, displayName) { await this.db.subscriptions.update(subscriptionId, { - displayName, + displayName }); } async setReservation(subscriptionId, reservation) { await this.db.subscriptions.update(subscriptionId, { - reservation, + reservation }); } diff --git a/web/src/app/db.js b/web/src/app/db.js index 7e3c47e3..cb65c0b6 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -13,7 +13,7 @@ const createDatabase = (username) => { db.version(3).stores({ subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", - notifications: "&id,sequenceId,subscriptionId,time,new,deleted,[subscriptionId+new],[subscriptionId+sequenceId]", + notifications: "&id,sequenceId,subscriptionId,time,new,[subscriptionId+new],[subscriptionId+sequenceId]", users: "&baseUrl,username", prefs: "&key" }); diff --git a/web/src/app/events.js b/web/src/app/events.js new file mode 100644 index 00000000..55dc262c --- /dev/null +++ b/web/src/app/events.js @@ -0,0 +1,14 @@ +// Event types for ntfy messages +// These correspond to the server event types in server/types.go + +export const EVENT_OPEN = "open"; +export const EVENT_KEEPALIVE = "keepalive"; +export const EVENT_MESSAGE = "message"; +export const EVENT_MESSAGE_DELETE = "message_delete"; +export const EVENT_MESSAGE_READ = "message_read"; +export const EVENT_POLL_REQUEST = "poll_request"; + +// 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_READ; + diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 5b50f0a8..5e271b35 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -12,6 +12,7 @@ import accountApi from "../app/AccountApi"; import { UnauthorizedError } from "../app/errors"; import notifier from "../app/Notifier"; import prefs from "../app/Prefs"; +import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "../app/events"; /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection @@ -53,13 +54,18 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop // Note: This logic is duplicated in the Android app in SubscriberService::onNotificationReceived() // and FirebaseService::handleMessage(). - // Delete existing notification with same sequenceId, if any - const sequenceId = notification.sequence_id || notification.id; - if (sequenceId) { - await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, sequenceId); - } - // Add notification to database - if (!notification.deleted) { + if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) { + // Handle delete: remove notification from database + await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id); + } else if (notification.event === EVENT_MESSAGE_READ && notification.sequence_id) { + // Handle read: mark notification as read + await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id); + } else { + // Regular message: delete existing and add new + const sequenceId = notification.sequence_id || notification.id; + if (sequenceId) { + await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, sequenceId); + } const added = await subscriptionManager.addNotification(subscriptionId, notification); if (added) { await subscriptionManager.notify(subscriptionId, notification);