From beff26d8e03d76e455a4d1a0ce2ba52986031c9c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 29 Jun 2026 15:19:32 -0500 Subject: [PATCH] Replay recent notification history --- shell/plugins/notifications/BarWidget.qml | 11 ------ .../notifications/NotificationLogic.js | 32 ++++++++++++++++ shell/plugins/notifications/Service.qml | 33 +++++++++++++--- test/shell.d/notifications-test.sh | 38 +++++++++++++++++++ 4 files changed, 97 insertions(+), 17 deletions(-) diff --git a/shell/plugins/notifications/BarWidget.qml b/shell/plugins/notifications/BarWidget.qml index 282ce215..13c4c538 100644 --- a/shell/plugins/notifications/BarWidget.qml +++ b/shell/plugins/notifications/BarWidget.qml @@ -91,17 +91,6 @@ BarWidget { } } - // Service-side IPC (omarchy-shell notifications showHistory) flips - // historyOpenRequested; we toggle our local popup state from here so the - // keybind path lands in the same PopupCard the click path uses. - Connections { - target: root.notificationService - ignoreUnknownSignals: true - function onHistoryOpenRequested() { - root.popupOpen = true - } - } - PopupCard { id: popup anchorItem: button diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index 9fb1688c..ba482191 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -143,6 +143,37 @@ function parseHistory(raw, normalUrgency, historyCap) { } } +function recentHistoryRows(pending, past, limit, normalUrgency) { + var max = limit === undefined || limit === null ? 5 : Number(limit) + if (isNaN(max)) max = 5 + max = Math.max(0, max) + + var values = [] + function collect(rows) { + var source = Array.isArray(rows) ? rows : [] + for (var i = 0; i < source.length; i++) { + if (source[i]) values.push(source[i]) + } + } + collect(pending) + collect(past) + + var keep = {} + for (var j = 0; j < values.length; j++) { + var row = values[j] + var key = row.originalId + if (key === undefined || key === null) key = row.id + if (key === undefined || key === null) key = "_" + j + var prior = keep[key] + if (!prior || (row.timestamp || 0) >= (prior.timestamp || 0)) keep[key] = row + } + + var out = [] + for (var id in keep) out.push(historyEntry(keep[id], normalUrgency)) + out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) }) + return out.slice(0, max) +} + function dumpRows(rows) { var values = Array.isArray(rows) ? rows : [] var out = [] @@ -205,6 +236,7 @@ if (typeof module !== "undefined") { historyEntry: historyEntry, dedupeByOriginalId: dedupeByOriginalId, parseHistory: parseHistory, + recentHistoryRows: recentHistoryRows, dumpRows: dumpRows, popupPlacement: popupPlacement, imageExtension: imageExtension diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index 258ca82d..3300a9ef 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -43,10 +43,6 @@ Item { readonly property int liveBarSize: shell && shell.bar && !shell.bar.barHidden ? Math.max(0, shell.bar.barSize) : defaultBarSize readonly property int barClearance: liveBarSize + Style.gapsOut - // Fired by IPC (`omarchy-shell notifications showHistory`) so the - // bar widget can drop its PopupCard from the same anchor a click would. - signal historyOpenRequested() - // PersistentProperties handles in-process QML reloads. The on-disk // notifications.json file is the cross-restart backstop — its `dnd` key // is hydrated into persisted.doNotDisturb on startup and written back via @@ -91,6 +87,7 @@ Item { ListModel { id: pastModel } readonly property int historyCap: 100 + readonly property int historyReplayLimit: 5 property var imageCacheQueue: [] readonly property int lowPopupDuration: 5000 @@ -302,6 +299,31 @@ Item { while (popupModel.count > 0) dismissPopup(0) } + function rowsFromModel(model) { + var rows = [] + for (var i = 0; i < model.count; i++) { + var entry = model.get(i) + if (entry) rows.push(snapshotFromRow(entry)) + } + return rows + } + + function showRecentHistory() { + var rows = NotificationLogic.recentHistoryRows( + rowsFromModel(pendingModel), + rowsFromModel(pastModel), + service.historyReplayLimit, + NotificationUrgency.Normal) + + if (rows.length === 0) return "none" + + clearPopups() + for (var i = 0; i < rows.length; i++) { + popupModel.append(rows[i]) + } + return "ok" + } + function dismissPending(index) { if (index < 0 || index >= pendingModel.count) return var entry = pendingModel.get(index) @@ -629,8 +651,7 @@ Item { } function showHistory(): string { - service.historyOpenRequested() - return "ok" + return service.showRecentHistory() } // `clear` empties the past tab (the "I already saw these" bucket). diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index d687657c..b8b7ccee 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -5,6 +5,7 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" run_node_test <<'JS' +const fs = require('fs') const notifications = requireFromRoot('shell/plugins/notifications/NotificationLogic.js') assert(notifications.isChromiumDerived('Brave Browser', ''), 'notifications detect chromium-derived apps by name') @@ -159,7 +160,44 @@ assertDeepEqual( ) assert(notifications.parseHistory('{', 1, 100).error, 'notifications flag invalid history JSON') +const recentRows = notifications.recentHistoryRows( + [ + { id: 1, originalId: 10, summary: 'pending-old', timestamp: 100 }, + { id: 2, originalId: 11, summary: 'pending-new', timestamp: 700 }, + { id: 3, originalId: 12, summary: 'pending-mid', timestamp: 300 } + ], + [ + { id: 4, originalId: 13, summary: 'past-newest', timestamp: 900 }, + { id: 5, originalId: 14, summary: 'past-second', timestamp: 800 }, + { id: 6, originalId: 10, summary: 'past-replaced', timestamp: 200 }, + { id: 7, originalId: 15, summary: 'past-extra', timestamp: 50 } + ], + 5, + 1 +) +assertDeepEqual( + recentRows.map(row => row.summary), + ['past-newest', 'past-second', 'pending-new', 'pending-mid', 'past-replaced'], + 'notifications pick the last five history rows across pending and past' +) +assertEqual(recentRows.length, 5, 'notifications history replay is capped at five rows') + assertEqual(notifications.imageExtension('/tmp/screenshot.PNG'), 'png', 'notifications normalize image extensions') assertEqual(notifications.imageExtension('/tmp/no-extension'), 'png', 'notifications default missing image extension') assertEqual(notifications.imageExtension('/tmp/archive.reallylong'), 'png', 'notifications reject suspicious image extensions') + +const serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/Service.qml'), 'utf8') +const barWidgetQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/BarWidget.qml'), 'utf8') +assert( + /readonly property int historyReplayLimit: 5/.test(serviceQml), + 'notifications service limits history replay to five rows' +) +assert( + /function showHistory\(\): string \{\s*return service\.showRecentHistory\(\)\s*\}/.test(serviceQml), + 'notifications history IPC replays recent notifications' +) +assert( + !barWidgetQml.includes('historyOpenRequested'), + 'notifications history IPC does not depend on the bar widget' +) JS