Dedupe notifications by id and persist history to ~/.local/state
Chat apps (Vesktop, etc.) can rapid-fire the same Notify D-Bus call when
they sync, leaving the pending tab with hundreds of byte-identical rows
because the old handler appended unconditionally and never re-entered the
`markSeenByOriginalId` path for follow-up arrivals.
- addToPending + popup-insert now remove any existing row matching the
same `originalId` before insertion, honoring the freedesktop
"replaces_id" semantics that chat apps rely on.
- loadHistory dedupes by originalId on hydration, so existing on-disk
files with the old duplicate-laden schema collapse on next start.
- loadHistory also guards against re-entry: FileView fires onLoaded
more than once during startup (initial preload + Component.onCompleted
reload), and the old version would double the model each fire.
While in here, move the persistent notifications.json out of `~/.cache`
to `~/.local/state/omarchy` so a "clear cache" habit no longer drops
DND preference and notification history. The image thumbnail cache stays
in ~/.cache where regeneratable artifacts belong.
This commit is contained in:
@@ -22,8 +22,15 @@ Item {
|
|||||||
property var manifest: null
|
property var manifest: null
|
||||||
|
|
||||||
readonly property string home: Quickshell.env("HOME")
|
readonly property string home: Quickshell.env("HOME")
|
||||||
|
// History + DND live under XDG_STATE_HOME: they're persistent user state
|
||||||
|
// (history of received notifications, last-set DND preference), not
|
||||||
|
// regeneratable cache that a `rm -rf ~/.cache` should wipe.
|
||||||
|
readonly property string stateDir: home + "/.local/state/omarchy/"
|
||||||
|
readonly property string historyPath: stateDir + "notifications.json"
|
||||||
|
// Thumbnails copied from /tmp screenshots are genuinely disposable — if
|
||||||
|
// they vanish the row just renders without an image — so they stay in
|
||||||
|
// ~/.cache where regeneratable artifacts belong.
|
||||||
readonly property string cacheDir: home + "/.cache/omarchy/"
|
readonly property string cacheDir: home + "/.cache/omarchy/"
|
||||||
readonly property string historyPath: cacheDir + "notifications.json"
|
|
||||||
readonly property string imageCacheDir: cacheDir + "notification-images/"
|
readonly property string imageCacheDir: cacheDir + "notification-images/"
|
||||||
readonly property string styleStatePath: home + "/.local/state/omarchy/toggles/quickshell-menu.json"
|
readonly property string styleStatePath: home + "/.local/state/omarchy/toggles/quickshell-menu.json"
|
||||||
// Optional per-theme override file. Themes that want notification colors
|
// Optional per-theme override file. Themes that want notification colors
|
||||||
@@ -227,7 +234,10 @@ Item {
|
|||||||
notification.tracked = false
|
notification.tracked = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Qt.callLater(function() { popupModel.insert(0, snapshot) })
|
Qt.callLater(function() {
|
||||||
|
removeByOriginalId(popupModel, snapshot.originalId)
|
||||||
|
popupModel.insert(0, snapshot)
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,12 +265,25 @@ Item {
|
|||||||
// Repeater is mid-incubation while we mutate its model — see noctalia
|
// Repeater is mid-incubation while we mutate its model — see noctalia
|
||||||
// NotificationService.qml ~L307.
|
// NotificationService.qml ~L307.
|
||||||
Qt.callLater(function() {
|
Qt.callLater(function() {
|
||||||
|
removeByOriginalId(popupModel, snapshot.originalId)
|
||||||
popupModel.insert(0, snapshot)
|
popupModel.insert(0, snapshot)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove every row in `model` whose originalId matches. Chat apps reuse
|
||||||
|
// `replaces_id` per the freedesktop spec to update a single notification
|
||||||
|
// in place — without this, every Discord/Slack ping leaves a fresh row
|
||||||
|
// behind and pending fills with hundreds of duplicates.
|
||||||
|
function removeByOriginalId(model, originalId) {
|
||||||
|
for (var i = model.count - 1; i >= 0; i--) {
|
||||||
|
var row = model.get(i)
|
||||||
|
if (row && row.originalId === originalId) model.remove(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function addToPending(snapshot) {
|
function addToPending(snapshot) {
|
||||||
Qt.callLater(function() {
|
Qt.callLater(function() {
|
||||||
|
removeByOriginalId(pendingModel, snapshot.originalId)
|
||||||
pendingModel.insert(0, snapshot)
|
pendingModel.insert(0, snapshot)
|
||||||
while (pendingModel.count > service.historyCap) {
|
while (pendingModel.count > service.historyCap) {
|
||||||
pendingModel.remove(pendingModel.count - 1)
|
pendingModel.remove(pendingModel.count - 1)
|
||||||
@@ -490,7 +513,7 @@ Item {
|
|||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: ensureDirsProc
|
id: ensureDirsProc
|
||||||
command: ["mkdir", "-p", service.cacheDir, service.imageCacheDir]
|
command: ["mkdir", "-p", service.stateDir, service.imageCacheDir]
|
||||||
running: false
|
running: false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,6 +601,12 @@ Item {
|
|||||||
property bool historyLoaded: false
|
property bool historyLoaded: false
|
||||||
|
|
||||||
function loadHistory(raw) {
|
function loadHistory(raw) {
|
||||||
|
// FileView can fire onLoaded more than once during startup — the implicit
|
||||||
|
// preload when `path` resolves, plus the explicit `historyFile.reload()`
|
||||||
|
// in Component.onCompleted can both end up calling here. Without this
|
||||||
|
// guard, the second fire appends a second copy of every persisted row
|
||||||
|
// to the in-memory model.
|
||||||
|
if (service.historyLoaded) return
|
||||||
var text = String(raw || "").trim()
|
var text = String(raw || "").trim()
|
||||||
if (!text) { service.historyLoaded = true; return }
|
if (!text) { service.historyLoaded = true; return }
|
||||||
try {
|
try {
|
||||||
@@ -610,19 +639,41 @@ Item {
|
|||||||
ref: null
|
ref: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Older builds didn't dedupe chat-app replacements, so hydrated files
|
||||||
|
// can hold hundreds of identical rows (same originalId). Collapse on
|
||||||
|
// load — keep the newest occurrence (highest timestamp) and drop the
|
||||||
|
// rest. Save is rescheduled below so the disk file rewrites cleanly.
|
||||||
|
function dedupeByOriginalId(rows) {
|
||||||
|
var keep = {}
|
||||||
|
for (var k = 0; k < rows.length; k++) {
|
||||||
|
var r = rows[k]
|
||||||
|
if (!r) continue
|
||||||
|
var key = r.originalId
|
||||||
|
if (key === undefined || key === null) { keep["_" + k] = r; continue }
|
||||||
|
var prior = keep[key]
|
||||||
|
if (!prior || (r.timestamp || 0) >= (prior.timestamp || 0)) keep[key] = r
|
||||||
|
}
|
||||||
|
var out = []
|
||||||
|
for (var id in keep) out.push(keep[id])
|
||||||
|
out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
var pendingDeduped = dedupeByOriginalId(pending)
|
||||||
|
var pastDeduped = dedupeByOriginalId(past)
|
||||||
|
var hadDuplicates = pendingDeduped.length !== pending.length
|
||||||
|
|| pastDeduped.length !== past.length
|
||||||
// Newest-first on disk; insert in order so models match.
|
// Newest-first on disk; insert in order so models match.
|
||||||
Qt.callLater(function() {
|
Qt.callLater(function() {
|
||||||
for (var i = 0; i < pending.length; i++) {
|
for (var i = 0; i < pendingDeduped.length; i++) {
|
||||||
if (!pending[i]) continue
|
pendingModel.append(entryFor(pendingDeduped[i]))
|
||||||
pendingModel.append(entryFor(pending[i]))
|
|
||||||
if (pendingModel.count > service.historyCap) pendingModel.remove(pendingModel.count - 1)
|
if (pendingModel.count > service.historyCap) pendingModel.remove(pendingModel.count - 1)
|
||||||
}
|
}
|
||||||
for (var j = 0; j < past.length; j++) {
|
for (var j = 0; j < pastDeduped.length; j++) {
|
||||||
if (!past[j]) continue
|
pastModel.append(entryFor(pastDeduped[j]))
|
||||||
pastModel.append(entryFor(past[j]))
|
|
||||||
if (pastModel.count > service.historyCap) pastModel.remove(pastModel.count - 1)
|
if (pastModel.count > service.historyCap) pastModel.remove(pastModel.count - 1)
|
||||||
}
|
}
|
||||||
service.historyLoaded = true
|
service.historyLoaded = true
|
||||||
|
if (hadDuplicates) service.scheduleHistorySave()
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("notifications: history parse failed:", e)
|
console.warn("notifications: history parse failed:", e)
|
||||||
|
|||||||
Reference in New Issue
Block a user