diff --git a/server/message_cache.go b/server/message_cache.go index 58080979..bd4dddd4 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -31,7 +31,6 @@ const ( mid TEXT NOT NULL, sid TEXT NOT NULL, time INT NOT NULL, - mtime INT NOT NULL, expires INT NOT NULL, topic TEXT NOT NULL, message TEXT NOT NULL, @@ -57,7 +56,6 @@ const ( CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid); CREATE INDEX IF NOT EXISTS idx_sid ON messages (sid); CREATE INDEX IF NOT EXISTS idx_time ON messages (time); - CREATE INDEX IF NOT EXISTS idx_mtime ON messages (mtime); CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires); CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender); @@ -71,53 +69,53 @@ const ( COMMIT; ` insertMessageQuery = ` - INSERT INTO messages (mid, sid, time, mtime, 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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO messages (mid, sid, 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) + 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, sid, time, mtime, 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, sid, 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 FROM messages WHERE mid = ? ` selectMessagesSinceTimeQuery = ` - SELECT mid, sid, time, mtime, 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, sid, 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 FROM messages WHERE topic = ? AND time >= ? AND published = 1 - ORDER BY mtime, id + ORDER BY time, id ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT mid, sid, time, mtime, 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, sid, 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 FROM messages WHERE topic = ? AND time >= ? - ORDER BY mtime, id + ORDER BY time, id ` selectMessagesSinceIDQuery = ` - SELECT mid, sid, time, mtime, 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, sid, 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 FROM messages WHERE topic = ? AND id > ? AND published = 1 - ORDER BY mtime, id + ORDER BY time, id ` selectMessagesSinceIDIncludeScheduledQuery = ` - SELECT mid, sid, time, mtime, 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, sid, 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 FROM messages WHERE topic = ? AND (id > ? OR published = 0) - ORDER BY mtime, id + ORDER BY time, id ` selectMessagesLatestQuery = ` - SELECT mid, sid, time, mtime, 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, sid, 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 FROM messages WHERE topic = ? AND published = 1 ORDER BY time DESC, id DESC LIMIT 1 ` selectMessagesDueQuery = ` - SELECT mid, sid, time, mtime, 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, sid, 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 FROM messages WHERE time <= ? AND published = 0 - ORDER BY mtime, id + ORDER BY time, id ` selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` @@ -270,10 +268,8 @@ const ( //13 -> 14 migrate13To14AlterMessagesTableQuery = ` ALTER TABLE messages ADD COLUMN sid TEXT NOT NULL DEFAULT(''); - ALTER TABLE messages ADD COLUMN mtime INT NOT NULL DEFAULT('0'); ALTER TABLE messages ADD COLUMN deleted INT NOT NULL DEFAULT('0'); CREATE INDEX IF NOT EXISTS idx_sid ON messages (sid); - CREATE INDEX IF NOT EXISTS idx_mtime ON messages (mtime); ` ) @@ -415,7 +411,6 @@ func (c *messageCache) addMessages(ms []*message) error { m.ID, m.SID, m.Time, - m.MTime, m.Expires, m.Topic, m.Message, @@ -723,14 +718,13 @@ func readMessages(rows *sql.Rows) ([]*message, error) { } func readMessage(rows *sql.Rows) (*message, error) { - var timestamp, mtimestamp, expires, attachmentSize, attachmentExpires int64 + var timestamp, expires, attachmentSize, attachmentExpires int64 var priority, deleted int var id, sid, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string err := rows.Scan( &id, &sid, ×tamp, - &mtimestamp, &expires, &topic, &msg, @@ -782,7 +776,6 @@ func readMessage(rows *sql.Rows) (*message, error) { ID: id, SID: sid, Time: timestamp, - MTime: mtimestamp, Expires: expires, Event: messageEvent, Topic: topic, diff --git a/server/message_cache_test.go b/server/message_cache_test.go index 18f69fd3..64203136 100644 --- a/server/message_cache_test.go +++ b/server/message_cache_test.go @@ -24,11 +24,9 @@ func TestMemCache_Messages(t *testing.T) { func testCacheMessages(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "my message") m1.Time = 1 - m1.MTime = 1000 m2 := newDefaultMessage("mytopic", "my other message") m2.Time = 2 - m2.MTime = 2000 require.Nil(t, c.AddMessage(m1)) require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message"))) @@ -126,13 +124,10 @@ func testCacheMessagesScheduled(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "message 1") m2 := newDefaultMessage("mytopic", "message 2") m2.Time = time.Now().Add(time.Hour).Unix() - m2.MTime = time.Now().Add(time.Hour).UnixMilli() m3 := newDefaultMessage("mytopic", "message 3") - m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2! - m3.MTime = time.Now().Add(time.Minute).UnixMilli() // earlier than m2! + m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2! m4 := newDefaultMessage("mytopic2", "message 4") m4.Time = time.Now().Add(time.Minute).Unix() - m4.MTime = time.Now().Add(time.Minute).UnixMilli() require.Nil(t, c.AddMessage(m1)) require.Nil(t, c.AddMessage(m2)) require.Nil(t, c.AddMessage(m3)) @@ -206,25 +201,18 @@ func TestMemCache_MessagesSinceID(t *testing.T) { func testCacheMessagesSinceID(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "message 1") m1.Time = 100 - m1.MTime = 100000 m2 := newDefaultMessage("mytopic", "message 2") m2.Time = 200 - m2.MTime = 200000 m3 := newDefaultMessage("mytopic", "message 3") - m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5 - m3.MTime = time.Now().Add(time.Hour).UnixMilli() // Scheduled, in the future, later than m7 and m5 + m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5 m4 := newDefaultMessage("mytopic", "message 4") m4.Time = 400 - m4.MTime = 400000 m5 := newDefaultMessage("mytopic", "message 5") - m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7 - m5.MTime = time.Now().Add(time.Minute).UnixMilli() // Scheduled, in the future, later than m7 + m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7 m6 := newDefaultMessage("mytopic", "message 6") m6.Time = 600 - m6.MTime = 600000 m7 := newDefaultMessage("mytopic", "message 7") m7.Time = 700 - m7.MTime = 700000 require.Nil(t, c.AddMessage(m1)) require.Nil(t, c.AddMessage(m2)) @@ -285,17 +273,14 @@ func testCachePrune(t *testing.T, c *messageCache) { m1 := newDefaultMessage("mytopic", "my message") m1.Time = now - 10 - m1.MTime = (now - 10) * 1000 m1.Expires = now - 5 m2 := newDefaultMessage("mytopic", "my other message") m2.Time = now - 5 - m2.MTime = (now - 5) * 1000 m2.Expires = now + 5 // In the future m3 := newDefaultMessage("another_topic", "and another one") m3.Time = now - 12 - m3.MTime = (now - 12) * 1000 m3.Expires = now - 2 require.Nil(t, c.AddMessage(m1)) @@ -546,7 +531,6 @@ func TestSqliteCache_Migration_From1(t *testing.T) { // Add delayed message delayedMessage := newDefaultMessage("mytopic", "some delayed message") delayedMessage.Time = time.Now().Add(time.Minute).Unix() - delayedMessage.MTime = time.Now().Add(time.Minute).UnixMilli() require.Nil(t, c.AddMessage(delayedMessage)) // 10, not 11! diff --git a/server/server.go b/server/server.go index 39c08c7d..9c612ebd 100644 --- a/server/server.go +++ b/server/server.go @@ -874,7 +874,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito return err } minc(metricMessagesPublishedSuccess) - return s.writeJSON(w, m) + return s.writeJSON(w, m.forJSON()) } func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -1291,7 +1291,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(&msg); err != nil { + if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { return "", err } return buf.String(), nil @@ -1302,7 +1302,7 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v * func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(&msg); err != nil { + if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil { return "", err } if msg.Event != messageEvent { diff --git a/server/types.go b/server/types.go index c8376673..467e80f5 100644 --- a/server/types.go +++ b/server/types.go @@ -24,10 +24,9 @@ const ( // message represents a message published to a topic type message struct { - ID string `json:"id"` // Random message ID - SID string `json:"sid"` // Message sequence ID for updating message contents - Time int64 `json:"time"` // Unix time in seconds - MTime int64 `json:"mtime"` // Unix time in milliseconds + ID string `json:"id"` // Random message ID + SID string `json:"sid,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID) + Time int64 `json:"time"` // Unix time in seconds Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive) Event string `json:"event"` // One of the above Topic string `json:"topic"` @@ -53,7 +52,6 @@ func (m *message) Context() log.Context { "message_id": m.ID, "message_sid": m.SID, "message_time": m.Time, - "message_mtime": m.MTime, "message_event": m.Event, "message_body_size": len(m.Message), } @@ -66,6 +64,16 @@ func (m *message) Context() log.Context { return fields } +// forJSON returns a copy of the message prepared for JSON output. +// It clears SID if it equals ID (to avoid redundant output). +func (m *message) forJSON() *message { + msg := *m + if msg.SID == msg.ID { + msg.SID = "" // Will be omitted due to omitempty + } + return &msg +} + type attachment struct { Name string `json:"name"` Type string `json:"type,omitempty"` @@ -123,7 +131,6 @@ func newMessage(event, topic, msg string) *message { return &message{ ID: util.RandomString(messageIDLength), Time: time.Now().Unix(), - MTime: time.Now().UnixMilli(), Event: event, Topic: topic, Message: msg, @@ -162,11 +169,7 @@ type sinceMarker struct { } func newSinceTime(timestamp int64) sinceMarker { - return newSinceMTime(timestamp * 1000) -} - -func newSinceMTime(mtimestamp int64) sinceMarker { - return sinceMarker{time.UnixMilli(mtimestamp), ""} + return sinceMarker{time.Unix(timestamp, 0), ""} } func newSinceID(id string) sinceMarker { @@ -557,7 +560,7 @@ func newWebPushPayload(subscriptionID string, message *message) *webPushPayload return &webPushPayload{ Event: webPushMessageEvent, SubscriptionID: subscriptionID, - Message: message, + Message: message.forJSON(), } } diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 2094f0c2..0895b2eb 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -70,8 +70,7 @@ "notifications_delete": "Delete", "notifications_copied_to_clipboard": "Copied to clipboard", "notifications_tags": "Tags", - "notifications_sid": "Sequence ID", - "notifications_revisions": "Revisions", + "notifications_modified": "modified {{date}}", "notifications_priority_x": "Priority {{priority}}", "notifications_new_indicator": "New notification", "notifications_attachment_image": "Attachment image", diff --git a/web/public/sw.js b/web/public/sw.js index 471cbee2..e010e4d4 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -25,9 +25,6 @@ const addNotification = async ({ subscriptionId, message }) => { const db = await dbAsync(); const populatedMessage = message; - if (!("mtime" in populatedMessage)) { - populatedMessage.mtime = message.time * 1000; - } if (!("sid" in populatedMessage)) { populatedMessage.sid = message.id; } diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 00d15d89..086fc048 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -157,7 +157,7 @@ class SubscriptionManager { // killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach const notifications = await this.db.notifications - .orderBy("mtime") // Sort by time first + .orderBy("time") // Sort by time .filter((n) => n.subscriptionId === subscriptionId) .reverse() .toArray(); @@ -167,30 +167,39 @@ class SubscriptionManager { async getAllNotifications() { const notifications = await this.db.notifications - .orderBy("mtime") // Efficient, see docs + .orderBy("time") // Efficient, see docs .reverse() .toArray(); return this.groupNotificationsBySID(notifications); } - // Collapse notification updates based on sids + // Collapse notification updates based on sids, keeping only the latest version + // Also tracks the original time (earliest) for each sequence groupNotificationsBySID(notifications) { - const results = {}; + const latestBySid = {}; + const originalTimeBySid = {}; + notifications.forEach((notification) => { const key = `${notification.subscriptionId}:${notification.sid}`; - if (key in results) { - if ("history" in results[key]) { - results[key].history.push(notification); - } else { - results[key].history = [notification]; - } - } else { - results[key] = notification; + + // Track the latest notification for each sid (first one since sorted DESC) + if (!(key in latestBySid)) { + latestBySid[key] = notification; + } + + // Track the original (earliest) time for each sid + const currentOriginal = originalTimeBySid[key]; + if (currentOriginal === undefined || notification.time < currentOriginal) { + originalTimeBySid[key] = notification.time; } }); - return Object.values(results); + // Return latest notifications with originalTime set + return Object.entries(latestBySid).map(([key, notification]) => ({ + ...notification, + originalTime: originalTimeBySid[key], + })); } /** Adds notification, or returns false if it already exists */ @@ -201,9 +210,6 @@ class SubscriptionManager { } try { const populatedNotification = notification; - if (!("mtime" in populatedNotification)) { - populatedNotification.mtime = notification.time * 1000; - } if (!("sid" in populatedNotification)) { populatedNotification.sid = notification.id; } @@ -227,9 +233,6 @@ class SubscriptionManager { async addNotifications(subscriptionId, notifications) { const notificationsWithSubscriptionId = notifications.map((notification) => { const populatedNotification = notification; - if (!("mtime" in populatedNotification)) { - populatedNotification.mtime = notification.time * 1000; - } if (!("sid" in populatedNotification)) { populatedNotification.sid = notification.id; } diff --git a/web/src/app/db.js b/web/src/app/db.js index f4d36c1b..f11a5d0b 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -11,9 +11,9 @@ const createDatabase = (username) => { const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user const db = new Dexie(dbName); - db.version(3).stores({ + db.version(4).stores({ subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]", - notifications: "&id,sid,subscriptionId,time,mtime,new,[subscriptionId+new]", // compound key for query performance + notifications: "&id,sid,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance users: "&baseUrl,username", prefs: "&key", }); diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index 2884e2f3..55d398c9 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -69,7 +69,7 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to badge, icon, image, - timestamp: message.mtime, + timestamp: message.time * 1000, tag, renotify: true, silent: false, diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index 6872f46e..343a284a 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -236,7 +236,9 @@ const NotificationItem = (props) => { const { t, i18n } = useTranslation(); const { notification } = props; const { attachment } = notification; - const date = formatShortDateTime(notification.time, i18n.language); + const isModified = notification.originalTime && notification.originalTime !== notification.time; + const originalDate = formatShortDateTime(notification.originalTime || notification.time, i18n.language); + const modifiedDate = isModified ? formatShortDateTime(notification.time, i18n.language) : null; const otherTags = unmatchedTags(notification.tags); const tags = otherTags.length > 0 ? otherTags.join(", ") : null; const handleDelete = async () => { @@ -267,8 +269,6 @@ const NotificationItem = (props) => { const hasUserActions = notification.actions && notification.actions.length > 0; const showActions = hasAttachmentActions || hasClickAction || hasUserActions; - const showSid = notification.id !== notification.sid || notification.history; - return ( @@ -289,7 +289,8 @@ const NotificationItem = (props) => { )} - {date} + {originalDate} + {modifiedDate && ` (${t("notifications_modified", { date: modifiedDate })})`} {[1, 2, 4, 5].includes(notification.priority) && ( { {t("notifications_tags")}: {tags} )} - {showSid && ( - - {t("notifications_sid")}: {notification.sid} - - )} - {notification.history && ( - - {t("notifications_revisions")}: {notification.history.length + 1} - - )} {showActions && (