Make notification history the last ten notifications on disk
History was a pair of in-memory lists mirrored into notifications.json, split into "pending" and "past" by a seen/unseen distinction no surface exposed, capped at 100, deduped by an id that repeats across server generations, and pruned by a 15-minute TTL. Replaying it showed five rows drawn from whichever list happened to hold them. Every toast already writes a file under ~/.local/state/omarchy/notifications so it can survive a shell restart. That file is now the history record: when the popup leaves the screen it moves into notifications/history instead of being deleted, the newest ten are kept, and showHistory replays exactly what is in there, including the toasts still on screen when it is asked for. A notification DND silenced is written straight into the same directory, since a toast that never showed is the one worth looking back at. That leaves the models, notifications.json history payload, past pruning, and the /tmp image cache that existed to keep century-old history thumbnails alive with nothing to do, so they go. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d6b21f8075
commit
ab57ad65fd
@@ -0,0 +1,10 @@
|
|||||||
|
echo "Drop the retired notification image cache"
|
||||||
|
|
||||||
|
# Notification history used to be a pair of long-lived lists in
|
||||||
|
# notifications.json, and thumbnails for it were copied out of /tmp into this
|
||||||
|
# cache so they would outlive the screenshot they came from. History is now the
|
||||||
|
# last ten notification files under ~/.local/state/omarchy/notifications/history,
|
||||||
|
# which reference their image where it already lives, so nothing writes or reads
|
||||||
|
# this directory anymore.
|
||||||
|
|
||||||
|
rm -rf "$HOME/.cache/omarchy/notification-images"
|
||||||
@@ -110,114 +110,34 @@ function historyEntry(value, normalUrgency) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function dedupeByOriginalId(rows) {
|
// notifications.json holds nothing but the last-set DND preference now that
|
||||||
var values = Array.isArray(rows) ? rows : []
|
// history is a directory of files. Older versions kept `pending`/`past`
|
||||||
var keep = {}
|
// (and, older still, `entries`) arrays in there; their presence is reported
|
||||||
for (var i = 0; i < values.length; i++) {
|
// so the service can rewrite the file without the dead payload.
|
||||||
var row = values[i]
|
function parseSettings(raw) {
|
||||||
if (!row) continue
|
|
||||||
var key = row.originalId
|
|
||||||
if (key === undefined || key === null) key = "_" + i
|
|
||||||
var prior = keep[key]
|
|
||||||
if (!prior || (row.timestamp || 0) >= (prior.timestamp || 0)) keep[key] = row
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseHistory(raw, normalUrgency, historyCap) {
|
|
||||||
var text = String(raw || "").trim()
|
var text = String(raw || "").trim()
|
||||||
var cap = historyCap === undefined || historyCap === null ? 100 : Number(historyCap)
|
if (!text) return { error: false, dnd: null, legacy: false }
|
||||||
if (isNaN(cap)) cap = 100
|
|
||||||
cap = Math.max(0, cap)
|
|
||||||
if (!text) return { empty: true, error: false, dnd: null, pending: [], past: [], hadDuplicates: false }
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var parsed = JSON.parse(text)
|
var parsed = JSON.parse(text)
|
||||||
var pendingRaw = (parsed && Array.isArray(parsed.pending)) ? parsed.pending : []
|
|
||||||
var pastRaw = (parsed && Array.isArray(parsed.past)) ? parsed.past : []
|
|
||||||
if (parsed && Array.isArray(parsed.entries)) pastRaw = pastRaw.concat(parsed.entries)
|
|
||||||
|
|
||||||
var pendingDeduped = dedupeByOriginalId(pendingRaw)
|
|
||||||
var pastDeduped = dedupeByOriginalId(pastRaw)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
empty: false,
|
|
||||||
error: false,
|
error: false,
|
||||||
dnd: parsed && typeof parsed.dnd === "boolean" ? parsed.dnd : null,
|
dnd: parsed && typeof parsed.dnd === "boolean" ? parsed.dnd : null,
|
||||||
pending: pendingDeduped.slice(0, cap).map(function(entry) { return historyEntry(entry, normalUrgency) }),
|
legacy: !!(parsed && (parsed.pending || parsed.past || parsed.entries))
|
||||||
past: pastDeduped.slice(0, cap).map(function(entry) { return historyEntry(entry, normalUrgency) }),
|
|
||||||
hadDuplicates: pendingDeduped.length !== pendingRaw.length || pastDeduped.length !== pastRaw.length
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { empty: false, error: true, errorMessage: String(e), dnd: null, pending: [], past: [], hadDuplicates: false }
|
return { error: true, errorMessage: String(e), dnd: null, legacy: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = []
|
|
||||||
for (var i = 0; i < values.length; i++) {
|
|
||||||
var r = values[i]
|
|
||||||
if (!r) continue
|
|
||||||
out.push({
|
|
||||||
id: r.id,
|
|
||||||
originalId: r.originalId,
|
|
||||||
app: r.app,
|
|
||||||
appIcon: r.appIcon,
|
|
||||||
summary: r.summary,
|
|
||||||
body: r.body,
|
|
||||||
image: r.image,
|
|
||||||
glyph: r.glyph || "",
|
|
||||||
exec: r.exec || "",
|
|
||||||
urgency: r.urgency,
|
|
||||||
timestamp: r.timestamp
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------- popup persistence
|
// ---------------------------------------------------- popup persistence
|
||||||
//
|
//
|
||||||
// Each on-screen popup is mirrored to its own file under
|
// Each on-screen popup is mirrored to its own file under
|
||||||
// ~/.local/state/omarchy/notifications/ so toasts survive shell restarts
|
// ~/.local/state/omarchy/notifications/ so toasts survive shell restarts
|
||||||
// (e.g. the restart `omarchy-update` performs). The file exists exactly as
|
// (e.g. the restart `omarchy-update` performs). The file exists exactly as
|
||||||
// long as the popup is on screen: it is written when the toast appears and
|
// long as the popup is on screen: it is written when the toast appears and
|
||||||
// deleted when the toast expires, is dismissed, or its action is invoked.
|
// moved into the history/ subdirectory when the toast expires, is dismissed,
|
||||||
|
// or its action is invoked. History is those moved files, newest last-10.
|
||||||
|
|
||||||
function popupEntry(value, normalUrgency) {
|
function popupEntry(value, normalUrgency) {
|
||||||
var entry = historyEntry(value, normalUrgency)
|
var entry = historyEntry(value, normalUrgency)
|
||||||
@@ -300,13 +220,37 @@ function popupPlacement(barPosition, barClearance, gapsOut) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageExtension(srcPath) {
|
// The archived files are the history. They are read back exactly like the
|
||||||
var lower = String(srcPath || "").toLowerCase()
|
// live popup files, then normalized into history rows: replaying a toast
|
||||||
var dot = lower.lastIndexOf(".")
|
// must not inherit the original's expire timeout or restore deadline, so it
|
||||||
if (dot < 0) return "png"
|
// gets the standard on-screen lifetime for its urgency instead.
|
||||||
var ext = lower.substring(dot + 1)
|
//
|
||||||
if (ext.length === 0 || ext.length > 5) return "png"
|
// liveRows are the toasts still on screen when the replay was asked for.
|
||||||
return ext
|
// They belong in it — they're the newest notifications there are — but the
|
||||||
|
// directory read races their archival, so they're carried across by hand and
|
||||||
|
// keyed by file name (timestamp + id) to drop the copy the read already saw.
|
||||||
|
function historyRows(raw, liveRows, normalUrgency, limit) {
|
||||||
|
var max = limit === undefined || limit === null ? 10 : Number(limit)
|
||||||
|
if (isNaN(max)) max = 10
|
||||||
|
max = Math.max(0, max)
|
||||||
|
|
||||||
|
var out = []
|
||||||
|
var seen = {}
|
||||||
|
function collect(rows) {
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
var entry = rows[i]
|
||||||
|
if (!entry) continue
|
||||||
|
var key = popupFileName(entry)
|
||||||
|
if (seen[key]) continue
|
||||||
|
seen[key] = true
|
||||||
|
out.push(historyEntry(entry, normalUrgency))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collect(Array.isArray(liveRows) ? liveRows : [])
|
||||||
|
collect(parsePopupFiles(raw, normalUrgency))
|
||||||
|
out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
|
||||||
|
return out.slice(0, max)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== "undefined") {
|
if (typeof module !== "undefined") {
|
||||||
@@ -322,16 +266,13 @@ if (typeof module !== "undefined") {
|
|||||||
shouldRenderCompactGlyph: shouldRenderCompactGlyph,
|
shouldRenderCompactGlyph: shouldRenderCompactGlyph,
|
||||||
snapshotOf: snapshotOf,
|
snapshotOf: snapshotOf,
|
||||||
historyEntry: historyEntry,
|
historyEntry: historyEntry,
|
||||||
dedupeByOriginalId: dedupeByOriginalId,
|
parseSettings: parseSettings,
|
||||||
parseHistory: parseHistory,
|
historyRows: historyRows,
|
||||||
recentHistoryRows: recentHistoryRows,
|
|
||||||
dumpRows: dumpRows,
|
|
||||||
popupEntry: popupEntry,
|
popupEntry: popupEntry,
|
||||||
popupFileName: popupFileName,
|
popupFileName: popupFileName,
|
||||||
serializePopup: serializePopup,
|
serializePopup: serializePopup,
|
||||||
parsePopupFiles: parsePopupFiles,
|
parsePopupFiles: parsePopupFiles,
|
||||||
popupExpired: popupExpired,
|
popupExpired: popupExpired,
|
||||||
popupPlacement: popupPlacement,
|
popupPlacement: popupPlacement
|
||||||
imageExtension: imageExtension
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,19 +20,19 @@ Item {
|
|||||||
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
|
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
|
||||||
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 + DND live under XDG_STATE_HOME: they're persistent user state
|
||||||
// (history of received notifications, last-set DND preference), not
|
// (the notifications received, the last-set DND preference), not
|
||||||
// regeneratable cache that a `rm -rf ~/.cache` should wipe.
|
// regeneratable cache that a `rm -rf ~/.cache` should wipe.
|
||||||
readonly property string stateDir: home + "/.local/state/omarchy/"
|
readonly property string stateDir: home + "/.local/state/omarchy/"
|
||||||
readonly property string historyPath: stateDir + "notifications.json"
|
readonly property string settingsPath: stateDir + "notifications.json"
|
||||||
// One file per on-screen popup, so live toasts survive shell restarts.
|
// One file per on-screen popup, so live toasts survive shell restarts.
|
||||||
// A file exists exactly as long as its popup is showing: written when the
|
// A file exists exactly as long as its popup is showing: written when the
|
||||||
// toast appears, deleted when it expires, is dismissed, or is acted upon.
|
// toast appears, moved into historyDir when it expires, is dismissed, or is
|
||||||
|
// acted upon.
|
||||||
readonly property string popupStateDir: stateDir + "notifications/"
|
readonly property string popupStateDir: stateDir + "notifications/"
|
||||||
// Thumbnails copied from /tmp screenshots are genuinely disposable — if
|
// The notifications that already left the screen, one file each, trimmed to
|
||||||
// they vanish the row just renders without an image — so they stay in
|
// the newest historyLimit. This directory IS the history: `showHistory`
|
||||||
// ~/.cache where regeneratable artifacts belong.
|
// replays exactly what has been moved in here.
|
||||||
readonly property string cacheDir: home + "/.cache/omarchy/"
|
readonly property string historyDir: popupStateDir + "history/"
|
||||||
readonly property string imageCacheDir: cacheDir + "notification-images/"
|
|
||||||
// Corner radius is shared with the menu and shell panels.
|
// Corner radius is shared with the menu and shell panels.
|
||||||
// It mirrors Hyprland's current decoration:rounding value.
|
// It mirrors Hyprland's current decoration:rounding value.
|
||||||
readonly property int cornerRadius: Style.cornerRadius
|
readonly property int cornerRadius: Style.cornerRadius
|
||||||
@@ -57,7 +57,7 @@ Item {
|
|||||||
// PersistentProperties handles in-process QML reloads. The on-disk
|
// PersistentProperties handles in-process QML reloads. The on-disk
|
||||||
// notifications.json file is the cross-restart backstop — its `dnd` key
|
// notifications.json file is the cross-restart backstop — its `dnd` key
|
||||||
// is hydrated into persisted.doNotDisturb on startup and written back via
|
// is hydrated into persisted.doNotDisturb on startup and written back via
|
||||||
// the same debounced save timer used for history entries.
|
// a debounced save timer.
|
||||||
PersistentProperties {
|
PersistentProperties {
|
||||||
id: persisted
|
id: persisted
|
||||||
reloadableId: "omarchy-notifications"
|
reloadableId: "omarchy-notifications"
|
||||||
@@ -65,7 +65,7 @@ Item {
|
|||||||
onDoNotDisturbChanged: {
|
onDoNotDisturbChanged: {
|
||||||
// Suppress the write that load-time hydration would otherwise trigger.
|
// Suppress the write that load-time hydration would otherwise trigger.
|
||||||
if (service._hydrating) return
|
if (service._hydrating) return
|
||||||
service.scheduleHistorySave()
|
service.scheduleSettingsSave()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,27 +79,17 @@ Item {
|
|||||||
persisted.doNotDisturb = !!value
|
persisted.doNotDisturb = !!value
|
||||||
}
|
}
|
||||||
|
|
||||||
// popupModel feeds the on-screen toast stack.
|
// popupModel feeds the on-screen toast stack — the only model the service
|
||||||
// pendingModel = notifications received but not yet "seen" by the user.
|
// keeps. Everything a toast leaves behind lives on disk under historyDir.
|
||||||
// Anything DND-suppressed lands here and stays there until
|
|
||||||
// the user reviews it; anything that pops up also lives
|
|
||||||
// here until the popup dismisses, then moves to pastModel.
|
|
||||||
// pastModel = notifications the user has already seen on-screen.
|
|
||||||
// Surfaced under the Past tab in the history panel.
|
|
||||||
//
|
//
|
||||||
// Aliased as properties so the bar widget and HistoryPanel (outside this
|
// Aliased as a property so consumers outside this Item's id scope can bind
|
||||||
// Item's id scope) can bind to them. QML ids aren't visible to external
|
// to it. QML ids aren't visible to external consumers without the alias.
|
||||||
// consumers without the alias.
|
|
||||||
property alias popupModel: popupModel
|
property alias popupModel: popupModel
|
||||||
property alias pendingModel: pendingModel
|
|
||||||
property alias pastModel: pastModel
|
|
||||||
ListModel { id: popupModel }
|
ListModel { id: popupModel }
|
||||||
ListModel { id: pendingModel }
|
|
||||||
ListModel { id: pastModel }
|
|
||||||
|
|
||||||
readonly property int historyCap: 100
|
// How many notifications the history directory keeps, and therefore how
|
||||||
readonly property int historyReplayLimit: 5
|
// many `showHistory` can replay.
|
||||||
property var imageCacheQueue: []
|
readonly property int historyLimit: 10
|
||||||
|
|
||||||
readonly property int lowPopupDuration: 5000
|
readonly property int lowPopupDuration: 5000
|
||||||
readonly property int normalPopupDuration: 8000
|
readonly property int normalPopupDuration: 8000
|
||||||
@@ -141,6 +131,24 @@ Item {
|
|||||||
return NotificationLogic.snapshotOf(notification, Date.now())
|
return NotificationLogic.snapshotOf(notification, Date.now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A notification nobody looks back at:
|
||||||
|
// - the freedesktop `transient` hint is set ("popup only, don't store")
|
||||||
|
// - app_name is "notify-send" (the CLI default — means the sender
|
||||||
|
// didn't bother declaring an identity, so it's almost certainly
|
||||||
|
// ephemeral test/feedback noise)
|
||||||
|
// - app_name is "omarchy-action" (Omarchy's own user-action toasts —
|
||||||
|
// the user just triggered them)
|
||||||
|
// Their toasts still land in history like any other once they've been on
|
||||||
|
// screen; the distinction only decides whether a DND-silenced one is worth
|
||||||
|
// recording at all.
|
||||||
|
function isEphemeral(notification) {
|
||||||
|
var transient = false
|
||||||
|
try {
|
||||||
|
transient = !!(notification.hints && notification.hints["transient"])
|
||||||
|
} catch (e) { transient = false }
|
||||||
|
return transient || NotificationLogic.isEphemeralApp(String(notification.appName || ""))
|
||||||
|
}
|
||||||
|
|
||||||
function handleNotification(notification) {
|
function handleNotification(notification) {
|
||||||
// Without `tracked = true` the Notification object is destroyed as soon
|
// Without `tracked = true` the Notification object is destroyed as soon
|
||||||
// as this signal handler returns, which would null out the `ref` we just
|
// as this signal handler returns, which would null out the `ref` we just
|
||||||
@@ -154,52 +162,15 @@ Item {
|
|||||||
if (service.liveRefs[snapshot.originalId] === notification)
|
if (service.liveRefs[snapshot.originalId] === notification)
|
||||||
delete service.liveRefs[snapshot.originalId]
|
delete service.liveRefs[snapshot.originalId]
|
||||||
})
|
})
|
||||||
// History is for notifications from real apps (Slack, Discord, mailer,
|
|
||||||
// etc.) — things the user might want to look back at. Skip the pending
|
|
||||||
// / past bookkeeping when:
|
|
||||||
// - the freedesktop `transient` hint is set ("popup only, don't store")
|
|
||||||
// - app_name is "notify-send" (the CLI default — means the sender
|
|
||||||
// didn't bother declaring an identity, so it's almost certainly
|
|
||||||
// ephemeral test/feedback noise)
|
|
||||||
// - app_name is "omarchy-action" (Omarchy's own user-action
|
|
||||||
// toasts — the user just triggered them, they don't
|
|
||||||
// need to be archived)
|
|
||||||
var transient = false
|
|
||||||
try {
|
|
||||||
transient = !!(notification.hints && notification.hints["transient"])
|
|
||||||
} catch (e) { transient = false }
|
|
||||||
var appName = String(notification.appName || "")
|
|
||||||
var ephemeralApp = NotificationLogic.isEphemeralApp(appName)
|
|
||||||
if (transient || ephemeralApp) {
|
|
||||||
if (service.doNotDisturb && !shouldBypassDnd(notification)) {
|
|
||||||
delete liveRefs[snapshot.originalId]
|
|
||||||
notification.tracked = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
persistPopupFile(snapshot)
|
|
||||||
Qt.callLater(function() {
|
|
||||||
removePopupsByOriginalId(snapshot.originalId, NotificationLogic.popupFileName(snapshot))
|
|
||||||
popupModel.insert(0, snapshot)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pending first, unconditionally. DND only suppresses the toast — the
|
// DND bypass rules: chat apps abuse urgency=critical to force
|
||||||
// record still has to land somewhere the user can review later.
|
// visibility, so critical alone isn't enough — we also require the
|
||||||
addToPending(snapshot)
|
// sender to be CLI-style. See shouldBypassDnd().
|
||||||
|
|
||||||
// Kick off a copy of any /tmp screenshot into the persistent image cache.
|
|
||||||
// The cp races the popup; the popup keeps the original path so it always
|
|
||||||
// renders, and the history row gets rewritten to the cached path once
|
|
||||||
// cp.exits.
|
|
||||||
maybeCacheImage(snapshot)
|
|
||||||
|
|
||||||
// DND bypass rules — see ~/Work/omarchy/dnd-fix-plan.md. The pending
|
|
||||||
// entry already captured this notification above; we just decide here
|
|
||||||
// whether to also pop a toast. Chat apps abuse urgency=critical to
|
|
||||||
// force visibility, so critical alone isn't enough — we also require
|
|
||||||
// the sender to be CLI-style. See shouldBypassDnd().
|
|
||||||
if (service.doNotDisturb && !shouldBypassDnd(notification)) {
|
if (service.doNotDisturb && !shouldBypassDnd(notification)) {
|
||||||
|
// The toast never shows, so the only record a silenced notification
|
||||||
|
// can leave is a history entry. Write it straight into history —
|
||||||
|
// "what did I miss while silenced" is exactly what history is for.
|
||||||
|
if (!isEphemeral(notification)) writeHistoryFile(snapshot)
|
||||||
delete liveRefs[snapshot.originalId]
|
delete liveRefs[snapshot.originalId]
|
||||||
notification.tracked = false
|
notification.tracked = false
|
||||||
return
|
return
|
||||||
@@ -223,9 +194,11 @@ Item {
|
|||||||
return !!row && !!restoredPopups[NotificationLogic.popupFileName(row)]
|
return !!row && !!restoredPopups[NotificationLogic.popupFileName(row)]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Popup-specific variant of removeByOriginalId: a replaced popup
|
// A notification arriving under an originalId a popup on screen already
|
||||||
// (freedesktop replaces_id) leaves the screen, so its persisted file has
|
// holds supersedes it, so that row leaves the screen. Its file is deleted
|
||||||
// to go too — otherwise it resurrects on the next shell restart.
|
// rather than archived: the row taking its place archives itself when it
|
||||||
|
// goes, and history would otherwise hold two entries for what the sender
|
||||||
|
// means as one notification.
|
||||||
// keepFileName is the replacement's own file: a same-millisecond
|
// keepFileName is the replacement's own file: a same-millisecond
|
||||||
// replacement shares the replaced row's filename, and the new write is
|
// replacement shares the replaced row's filename, and the new write is
|
||||||
// already queued — deleting that path here would erase the replacement's
|
// already queued — deleting that path here would erase the replacement's
|
||||||
@@ -242,85 +215,6 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
|
||||||
Qt.callLater(function() {
|
|
||||||
removeByOriginalId(pendingModel, snapshot.originalId)
|
|
||||||
pendingModel.insert(0, snapshot)
|
|
||||||
while (pendingModel.count > service.historyCap) {
|
|
||||||
pendingModel.remove(pendingModel.count - 1)
|
|
||||||
}
|
|
||||||
scheduleHistorySave()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find a pending entry by its libnotify id and move it to pastModel. Called
|
|
||||||
// when a popup naturally dismisses (timer expired or user clicked X / the
|
|
||||||
// default action) — the user is assumed to have seen it. Matches on
|
|
||||||
// timestamp too: a popup row and its pending row are born from the same
|
|
||||||
// snapshot, and id alone can point at an unrelated notification when a
|
|
||||||
// restored popup's old-generation id has been reused.
|
|
||||||
function markSeenByOriginalId(originalId, timestamp) {
|
|
||||||
Qt.callLater(function() {
|
|
||||||
for (var i = 0; i < pendingModel.count; i++) {
|
|
||||||
var entry = pendingModel.get(i)
|
|
||||||
if (!entry || entry.originalId !== originalId || entry.timestamp !== timestamp) continue
|
|
||||||
var snapshot = service.snapshotFromRow(entry)
|
|
||||||
pendingModel.remove(i)
|
|
||||||
pastModel.insert(0, snapshot)
|
|
||||||
while (pastModel.count > service.historyCap) {
|
|
||||||
pastModel.remove(pastModel.count - 1)
|
|
||||||
}
|
|
||||||
scheduleHistorySave()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy a ListModel row into a plain JS object so we can re-insert it into
|
|
||||||
// a different model without sharing references.
|
|
||||||
function snapshotFromRow(row) {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
originalId: row.originalId,
|
|
||||||
app: row.app,
|
|
||||||
appIcon: row.appIcon,
|
|
||||||
summary: row.summary,
|
|
||||||
body: row.body,
|
|
||||||
image: row.image,
|
|
||||||
glyph: row.glyph || "",
|
|
||||||
exec: row.exec || "",
|
|
||||||
urgency: row.urgency,
|
|
||||||
expireTimeout: row.expireTimeout || 0,
|
|
||||||
timestamp: row.timestamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function markAllSeen() {
|
|
||||||
Qt.callLater(function() {
|
|
||||||
while (pendingModel.count > 0) {
|
|
||||||
var entry = pendingModel.get(0)
|
|
||||||
var snapshot = service.snapshotFromRow(entry)
|
|
||||||
pendingModel.remove(0)
|
|
||||||
pastModel.insert(0, snapshot)
|
|
||||||
}
|
|
||||||
while (pastModel.count > service.historyCap) {
|
|
||||||
pastModel.remove(pastModel.count - 1)
|
|
||||||
}
|
|
||||||
scheduleHistorySave()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissPopup(index) {
|
function dismissPopup(index) {
|
||||||
removePopup(index, "dismiss")
|
removePopup(index, "dismiss")
|
||||||
}
|
}
|
||||||
@@ -333,16 +227,17 @@ Item {
|
|||||||
if (index < 0 || index >= popupModel.count) return
|
if (index < 0 || index >= popupModel.count) return
|
||||||
var entry = popupModel.get(index)
|
var entry = popupModel.get(index)
|
||||||
var originalId = entry ? entry.originalId : -1
|
var originalId = entry ? entry.originalId : -1
|
||||||
var timestamp = entry ? entry.timestamp : 0
|
|
||||||
// A restored row has no live server object, and its old-generation id
|
// A restored row has no live server object, and its old-generation id
|
||||||
// may meanwhile belong to a fresh notification — resolving liveRefs by
|
// may meanwhile belong to a fresh notification — resolving liveRefs by
|
||||||
// id would dismiss that unrelated notification at the server.
|
// id would dismiss that unrelated notification at the server.
|
||||||
var restored = isRestoredRow(entry)
|
var restored = isRestoredRow(entry)
|
||||||
var ref = !restored && originalId >= 0 ? liveRefs[originalId] : null
|
var ref = !restored && originalId >= 0 ? liveRefs[originalId] : null
|
||||||
// The popup is leaving the screen — for any reason — so its persisted
|
// The popup is leaving the screen — for any reason — so its file must not
|
||||||
// file must not survive to the next shell restart.
|
// survive to the next shell restart. It becomes the newest history entry
|
||||||
|
// instead. Rows that never had a file (a history replay, the empty-history
|
||||||
|
// placeholder) archive to nothing, which the move tolerates.
|
||||||
if (entry) {
|
if (entry) {
|
||||||
deletePopupFileFor(entry)
|
archivePopupFileFor(entry)
|
||||||
if (restored) delete restoredPopups[NotificationLogic.popupFileName(entry)]
|
if (restored) delete restoredPopups[NotificationLogic.popupFileName(entry)]
|
||||||
}
|
}
|
||||||
popupModel.remove(index)
|
popupModel.remove(index)
|
||||||
@@ -356,90 +251,12 @@ Item {
|
|||||||
// Object already torn down by the server — nothing to dismiss.
|
// Object already torn down by the server — nothing to dismiss.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// User (or the lifetime timer) saw the popup — archive it.
|
|
||||||
if (originalId >= 0) markSeenByOriginalId(originalId, timestamp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPopups() {
|
function clearPopups() {
|
||||||
while (popupModel.count > 0) dismissPopup(0)
|
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)
|
|
||||||
|
|
||||||
// Replaying nothing at all looks like a dead keybinding, so say so.
|
|
||||||
if (rows.length === 0) {
|
|
||||||
popupModel.insert(0, {
|
|
||||||
id: -1,
|
|
||||||
originalId: -1,
|
|
||||||
app: "omarchy-action",
|
|
||||||
appIcon: "",
|
|
||||||
summary: "No recent notifications",
|
|
||||||
body: "",
|
|
||||||
image: "",
|
|
||||||
glyph: "",
|
|
||||||
exec: "",
|
|
||||||
urgency: NotificationUrgency.Low,
|
|
||||||
expireTimeout: 0,
|
|
||||||
timestamp: Date.now()
|
|
||||||
})
|
|
||||||
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)
|
|
||||||
if (entry) maybeDeleteCachedImage(entry.image)
|
|
||||||
pendingModel.remove(index)
|
|
||||||
scheduleHistorySave()
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissPast(index) {
|
|
||||||
if (index < 0 || index >= pastModel.count) return
|
|
||||||
var entry = pastModel.get(index)
|
|
||||||
if (entry) maybeDeleteCachedImage(entry.image)
|
|
||||||
pastModel.remove(index)
|
|
||||||
scheduleHistorySave()
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearPending() {
|
|
||||||
for (var i = 0; i < pendingModel.count; i++) {
|
|
||||||
var entry = pendingModel.get(i)
|
|
||||||
if (entry) maybeDeleteCachedImage(entry.image)
|
|
||||||
}
|
|
||||||
pendingModel.clear()
|
|
||||||
scheduleHistorySave()
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearPast() {
|
|
||||||
for (var i = 0; i < pastModel.count; i++) {
|
|
||||||
var entry = pastModel.get(i)
|
|
||||||
if (entry) maybeDeleteCachedImage(entry.image)
|
|
||||||
}
|
|
||||||
pastModel.clear()
|
|
||||||
scheduleHistorySave()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the popup's click action, then dismiss. Omarchy's own toasts carry the
|
// Run the popup's click action, then dismiss. Omarchy's own toasts carry the
|
||||||
// action as a command in the `exec` role (see execFromHints), which the
|
// action as a command in the `exec` role (see execFromHints), which the
|
||||||
// persistence files preserve, so restored toasts stay clickable. Third-party
|
// persistence files preserve, so restored toasts stay clickable. Third-party
|
||||||
@@ -496,109 +313,18 @@ Item {
|
|||||||
|
|
||||||
Process { id: focusAppProc; running: false }
|
Process { id: focusAppProc; running: false }
|
||||||
|
|
||||||
// ---------------------------------------------------- image cache
|
|
||||||
//
|
|
||||||
// Notifications coming from screenshot helpers ship an `image-path` hint
|
|
||||||
// pointing at /tmp/<file>. We want the history thumbnail to outlive that
|
|
||||||
// file, so we copy it into a long-lived cache dir on ingress and rewrite
|
|
||||||
// the history row's `image` to point at the cache once cp finishes.
|
|
||||||
// image:// (raw-bytes) URIs aren't trivially copyable from QML; document
|
|
||||||
// and skip them for v1.
|
|
||||||
|
|
||||||
function imageExtension(srcPath) {
|
|
||||||
return NotificationLogic.imageExtension(srcPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeCacheImage(snapshot) {
|
|
||||||
var image = String(snapshot.image || "")
|
|
||||||
if (!image) return
|
|
||||||
// image:// URIs are decoded from raw bytes by Quickshell's image provider.
|
|
||||||
// We can't copy them out from QML, so let history reference them by URI
|
|
||||||
// and accept that they disappear with the source notification.
|
|
||||||
if (image.indexOf("image://") === 0) return
|
|
||||||
if (image.indexOf("file:///tmp/") !== 0) return
|
|
||||||
|
|
||||||
var srcPath = decodeURIComponent(image.substring(7))
|
|
||||||
var ext = imageExtension(srcPath)
|
|
||||||
var destPath = imageCacheDir + snapshot.timestamp + "-" + snapshot.originalId + "." + ext
|
|
||||||
var destUri = Util.fileUrl(destPath)
|
|
||||||
|
|
||||||
imageCacheQueue = imageCacheQueue.concat([{
|
|
||||||
srcPath: srcPath,
|
|
||||||
destPath: destPath,
|
|
||||||
targetUri: destUri,
|
|
||||||
originalId: snapshot.originalId,
|
|
||||||
timestamp: snapshot.timestamp
|
|
||||||
}])
|
|
||||||
runNextImageCacheJob()
|
|
||||||
}
|
|
||||||
|
|
||||||
function runNextImageCacheJob() {
|
|
||||||
if (imageCacheProc.running || imageCacheQueue.length === 0) return
|
|
||||||
|
|
||||||
var job = imageCacheQueue[0]
|
|
||||||
imageCacheQueue = imageCacheQueue.slice(1)
|
|
||||||
imageCacheProc.targetUri = job.targetUri
|
|
||||||
imageCacheProc.matchOriginalId = job.originalId
|
|
||||||
imageCacheProc.matchTimestamp = job.timestamp
|
|
||||||
imageCacheProc.command = ["cp", "-f", job.srcPath, job.destPath]
|
|
||||||
imageCacheProc.running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function rewriteCachedImage(targetUri, originalId, timestamp) {
|
|
||||||
function rewrite(model) {
|
|
||||||
for (var i = 0; i < model.count; i++) {
|
|
||||||
var row = model.get(i)
|
|
||||||
if (row && row.originalId === originalId && row.timestamp === timestamp) {
|
|
||||||
model.setProperty(i, "image", targetUri)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return rewrite(pendingModel) || rewrite(pastModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeDeleteCachedImage(image) {
|
|
||||||
var path = String(image || "")
|
|
||||||
if (!path) return
|
|
||||||
if (path.indexOf("file://") !== 0) return
|
|
||||||
var local = decodeURIComponent(path.substring(7))
|
|
||||||
if (local.indexOf(imageCacheDir) !== 0) return
|
|
||||||
deleteImageProc.command = ["rm", "-f", local]
|
|
||||||
deleteImageProc.running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: ensureDirsProc
|
id: ensureDirsProc
|
||||||
command: ["mkdir", "-p", service.stateDir, service.popupStateDir, service.imageCacheDir]
|
command: ["mkdir", "-p", service.stateDir, service.popupStateDir, service.historyDir]
|
||||||
running: false
|
running: false
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
|
||||||
id: imageCacheProc
|
|
||||||
property string targetUri: ""
|
|
||||||
property int matchOriginalId: -1
|
|
||||||
property double matchTimestamp: 0
|
|
||||||
onExited: function(exitCode) {
|
|
||||||
if (exitCode === 0 && targetUri && rewriteCachedImage(targetUri, matchOriginalId, matchTimestamp))
|
|
||||||
scheduleHistorySave()
|
|
||||||
targetUri = ""
|
|
||||||
matchOriginalId = -1
|
|
||||||
matchTimestamp = 0
|
|
||||||
runNextImageCacheJob()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process { id: deleteImageProc; running: false }
|
|
||||||
|
|
||||||
// ---------------------------------------------------- popup persistence
|
// ---------------------------------------------------- popup persistence
|
||||||
//
|
//
|
||||||
// Mirror every on-screen popup to its own file under popupStateDir so
|
// Mirror every on-screen popup to its own file under popupStateDir so
|
||||||
// toasts survive shell restarts (notably the restart `omarchy-update`
|
// toasts survive shell restarts (notably the restart `omarchy-update`
|
||||||
// performs). Writes and deletes go through one serialized queue: a burst
|
// performs). Writes, moves and deletes go through one serialized queue: a
|
||||||
// of replaces_id updates must not race a single reused Process, and
|
// burst of replaces_id updates must not race a single reused Process, and
|
||||||
// ordering guarantees a delete issued after a write wins.
|
// ordering guarantees a delete issued after a write wins.
|
||||||
|
|
||||||
// Popups restored from a previous shell process, keyed by their file
|
// Popups restored from a previous shell process, keyed by their file
|
||||||
@@ -645,6 +371,142 @@ Item {
|
|||||||
enqueuePopupFileJob(["rm", "-f", popupStateDir + NotificationLogic.popupFileName(row)])
|
enqueuePopupFileJob(["rm", "-f", popupStateDir + NotificationLogic.popupFileName(row)])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------- history
|
||||||
|
//
|
||||||
|
// A popup that leaves the screen keeps its file — it just moves one level
|
||||||
|
// down, into historyDir. Trimming happens right there in the same shell
|
||||||
|
// job: the names sort numerically by their leading millisecond timestamp,
|
||||||
|
// so everything but the newest historyLimit files is the tail to drop.
|
||||||
|
// $1 is historyDir and $2 the limit in both jobs below.
|
||||||
|
readonly property string trimHistoryScript:
|
||||||
|
"ls -1 \"$1\" 2>/dev/null | sort -n | head -n \"-$2\" | while IFS= read -r stale; do rm -f \"$1/$stale\"; done"
|
||||||
|
|
||||||
|
function archivePopupFileFor(row) {
|
||||||
|
if (!row) return
|
||||||
|
// A history replay or the empty-history placeholder has no file to move;
|
||||||
|
// the failed mv leaves the history untouched, trimming included.
|
||||||
|
enqueuePopupFileJob(["bash", "-c",
|
||||||
|
"mkdir -p \"$1\" || exit 0\n" +
|
||||||
|
"mv -f \"$4/$3\" \"$1/$3\" 2>/dev/null || exit 0\n" +
|
||||||
|
trimHistoryScript, "--",
|
||||||
|
historyDir,
|
||||||
|
String(historyLimit),
|
||||||
|
NotificationLogic.popupFileName(row),
|
||||||
|
popupStateDir])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record a notification that never made it to the screen (DND silenced it),
|
||||||
|
// straight into history. Same file format as an archived popup, so the
|
||||||
|
// replay can't tell the two apart.
|
||||||
|
//
|
||||||
|
// A silenced notification is untracked the moment it arrives, so the server
|
||||||
|
// has nothing left for a later replaces_id to replace and hands the sender a
|
||||||
|
// fresh id instead. Every update from a chatty thread is therefore its own
|
||||||
|
// notification here, and several can sit in the ten slots together — there
|
||||||
|
// is no id to recognize them by, and guessing from app and summary would
|
||||||
|
// merge genuinely separate messages.
|
||||||
|
function writeHistoryFile(entry) {
|
||||||
|
if (!entry) return
|
||||||
|
enqueuePopupFileJob(["bash", "-c",
|
||||||
|
"mkdir -p \"$1\" || exit 0\n" +
|
||||||
|
"printf '%s\\n' \"$4\" > \"$1/$3\" || exit 0\n" +
|
||||||
|
trimHistoryScript, "--",
|
||||||
|
historyDir,
|
||||||
|
String(historyLimit),
|
||||||
|
NotificationLogic.popupFileName(entry),
|
||||||
|
NotificationLogic.serializePopup(entry, NotificationUrgency.Normal)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHistory() {
|
||||||
|
enqueuePopupFileJob(["bash", "-c",
|
||||||
|
"rm -f \"$1\"/*.json", "--", historyDir])
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: readHistoryProc
|
||||||
|
running: false
|
||||||
|
stdout: StdioCollector {
|
||||||
|
waitForEnd: true
|
||||||
|
onStreamFinished: service.replayHistory(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toasts that were on screen when the replay was asked for. The clear in
|
||||||
|
// replayHistory archives them, but the directory read is already in flight
|
||||||
|
// by then, so they're handed over in memory instead of being waited for.
|
||||||
|
property var replayCarryOver: []
|
||||||
|
|
||||||
|
// Re-show what's in historyDir as toasts. Reading the directory is a
|
||||||
|
// subprocess, so the replay lands in replayHistory a moment later.
|
||||||
|
function showRecentHistory() {
|
||||||
|
if (readHistoryProc.running) return "ok"
|
||||||
|
service.replayCarryOver = liveRowsForReplay()
|
||||||
|
readHistoryProc.command = ["bash", "-c",
|
||||||
|
"awk 1 \"$1\"/*.json 2>/dev/null || true", "--", historyDir]
|
||||||
|
readHistoryProc.running = true
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the on-screen rows out of the model. The placeholder from an earlier
|
||||||
|
// empty replay carries originalId -1 and is not a notification, so it is
|
||||||
|
// left behind rather than replayed as one.
|
||||||
|
function liveRowsForReplay() {
|
||||||
|
var rows = []
|
||||||
|
for (var i = 0; i < popupModel.count; i++) {
|
||||||
|
var row = popupModel.get(i)
|
||||||
|
if (!row || row.originalId < 0) continue
|
||||||
|
rows.push({
|
||||||
|
id: row.id,
|
||||||
|
originalId: row.originalId,
|
||||||
|
app: row.app,
|
||||||
|
appIcon: row.appIcon,
|
||||||
|
summary: row.summary,
|
||||||
|
body: row.body,
|
||||||
|
image: row.image,
|
||||||
|
glyph: row.glyph || "",
|
||||||
|
exec: row.exec || "",
|
||||||
|
urgency: row.urgency,
|
||||||
|
timestamp: row.timestamp
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
function replayHistory(raw) {
|
||||||
|
var rows = NotificationLogic.historyRows(
|
||||||
|
raw, service.replayCarryOver, NotificationUrgency.Normal, service.historyLimit)
|
||||||
|
service.replayCarryOver = []
|
||||||
|
|
||||||
|
// Replaying nothing at all looks like a dead keybinding, so say so.
|
||||||
|
if (rows.length === 0) {
|
||||||
|
popupModel.insert(0, {
|
||||||
|
id: -1,
|
||||||
|
originalId: -1,
|
||||||
|
app: "omarchy-action",
|
||||||
|
appIcon: "",
|
||||||
|
summary: "No recent notifications",
|
||||||
|
body: "",
|
||||||
|
image: "",
|
||||||
|
glyph: "",
|
||||||
|
exec: "",
|
||||||
|
urgency: NotificationUrgency.Low,
|
||||||
|
expireTimeout: 0,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPopups()
|
||||||
|
// Rows arrive newest-first, and index 0 is the top of the toast stack.
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
// Replayed rows are restored rows: their notification died with the
|
||||||
|
// sender long ago, so they must never resolve to a live server object
|
||||||
|
// that has since been handed their old id.
|
||||||
|
service.restoredPopups[NotificationLogic.popupFileName(rows[i])] = true
|
||||||
|
popupModel.append(rows[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: restorePopupsProc
|
id: restorePopupsProc
|
||||||
running: false
|
running: false
|
||||||
@@ -662,7 +524,9 @@ Item {
|
|||||||
var entry = entries[i]
|
var entry = entries[i]
|
||||||
var duration = durationFor(entry.urgency, entry.expireTimeout)
|
var duration = durationFor(entry.urgency, entry.expireTimeout)
|
||||||
if (NotificationLogic.popupExpired(entry, duration, now)) {
|
if (NotificationLogic.popupExpired(entry, duration, now)) {
|
||||||
deletePopupFileFor(entry)
|
// It would have expired on screen had the shell kept running, so it
|
||||||
|
// gets archived exactly like an expiry that happened while it did.
|
||||||
|
archivePopupFileFor(entry)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Survivors restart with a full lifetime on purpose: shell restarts
|
// Survivors restart with a full lifetime on purpose: shell restarts
|
||||||
@@ -710,82 +574,44 @@ Item {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------- history persistence
|
// ---------------------------------------------------- settings persistence
|
||||||
|
|
||||||
FileView {
|
FileView {
|
||||||
id: historyFile
|
id: settingsFile
|
||||||
path: service.historyPath
|
path: service.settingsPath
|
||||||
watchChanges: false
|
watchChanges: false
|
||||||
atomicWrites: true
|
atomicWrites: true
|
||||||
printErrors: false
|
printErrors: false
|
||||||
onLoaded: service.loadHistory(text())
|
onLoaded: service.loadSettings(text())
|
||||||
// First-run: the file doesn't exist yet. Without this branch,
|
// First-run: the file doesn't exist yet. Without this branch,
|
||||||
// `historyLoaded` stays false forever and `scheduleHistorySave` becomes
|
// `settingsLoaded` stays false forever and `scheduleSettingsSave` becomes
|
||||||
// a no-op — so the file is never created and history vanishes on
|
// a no-op — so the file is never created and the DND preference vanishes
|
||||||
// shell restart.
|
// on shell restart.
|
||||||
onLoadFailed: service.loadHistory("")
|
onLoadFailed: service.loadSettings("")
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: historySaveTimer
|
id: settingsSaveTimer
|
||||||
interval: 200
|
interval: 200
|
||||||
repeat: false
|
repeat: false
|
||||||
onTriggered: service.flushHistory()
|
onTriggered: service.flushSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Past is a rolling "recently" window. Sweep every minute and drop
|
function scheduleSettingsSave() {
|
||||||
// anything older than 15 minutes so the tab doesn't accumulate forever.
|
if (!service.settingsLoaded) return
|
||||||
readonly property int pastTtlMs: 15 * 60 * 1000
|
settingsSaveTimer.restart()
|
||||||
|
|
||||||
Timer {
|
|
||||||
id: pastPruneTimer
|
|
||||||
interval: 60 * 1000
|
|
||||||
repeat: true
|
|
||||||
running: true
|
|
||||||
triggeredOnStart: true
|
|
||||||
onTriggered: service.prunePast()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function prunePast() {
|
property bool settingsLoaded: false
|
||||||
if (pastModel.count === 0) return
|
|
||||||
var cutoff = Date.now() - service.pastTtlMs
|
|
||||||
var removed = false
|
|
||||||
for (var i = pastModel.count - 1; i >= 0; i--) {
|
|
||||||
var entry = pastModel.get(i)
|
|
||||||
if (entry && entry.timestamp && entry.timestamp < cutoff) {
|
|
||||||
if (entry.image) maybeDeleteCachedImage(entry.image)
|
|
||||||
pastModel.remove(i)
|
|
||||||
removed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (removed) scheduleHistorySave()
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleHistorySave() {
|
function loadSettings(raw) {
|
||||||
if (!service.historyLoaded) return
|
|
||||||
historySaveTimer.restart()
|
|
||||||
}
|
|
||||||
|
|
||||||
property bool historyLoaded: false
|
|
||||||
|
|
||||||
function loadHistory(raw) {
|
|
||||||
// FileView can fire onLoaded more than once during startup — the implicit
|
// FileView can fire onLoaded more than once during startup — the implicit
|
||||||
// preload when `path` resolves, plus the explicit `historyFile.reload()`
|
// preload when `path` resolves, plus the explicit `settingsFile.reload()`
|
||||||
// in Component.onCompleted can both end up calling here. Without this
|
// in Component.onCompleted can both end up calling here.
|
||||||
// guard, the second fire appends a second copy of every persisted row
|
if (service.settingsLoaded) return
|
||||||
// to the in-memory model.
|
|
||||||
if (service.historyLoaded) return
|
|
||||||
|
|
||||||
var parsed = NotificationLogic.parseHistory(raw, NotificationUrgency.Normal, service.historyCap)
|
var parsed = NotificationLogic.parseSettings(raw)
|
||||||
if (parsed.empty) {
|
if (parsed.error) console.warn("notifications: settings parse failed:", parsed.errorMessage || "")
|
||||||
service.historyLoaded = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (parsed.error) {
|
|
||||||
console.warn("notifications: history parse failed:", parsed.errorMessage || "")
|
|
||||||
service.historyLoaded = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.dnd !== null) {
|
if (parsed.dnd !== null) {
|
||||||
service._hydrating = true
|
service._hydrating = true
|
||||||
@@ -793,54 +619,24 @@ Item {
|
|||||||
service._hydrating = false
|
service._hydrating = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Newest-first on disk; append in order so models match.
|
service.settingsLoaded = true
|
||||||
Qt.callLater(function() {
|
// Versions before the history moved into its own directory kept every
|
||||||
for (var i = 0; i < parsed.pending.length; i++) pendingModel.append(parsed.pending[i])
|
// notification in here. Rewrite once so that dead payload doesn't sit in
|
||||||
for (var j = 0; j < parsed.past.length; j++) pastModel.append(parsed.past[j])
|
// the file until the next DND toggle happens to clear it.
|
||||||
service.historyLoaded = true
|
if (parsed.legacy) service.scheduleSettingsSave()
|
||||||
if (parsed.hadDuplicates) service.scheduleHistorySave()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushHistory() {
|
function flushSettings() {
|
||||||
function dump(model) {
|
settingsFile.setText(JSON.stringify({ version: 3, dnd: persisted.doNotDisturb }, null, 2) + "\n")
|
||||||
var out = []
|
|
||||||
for (var i = 0; i < model.count; i++) {
|
|
||||||
var r = model.get(i)
|
|
||||||
if (!r) continue
|
|
||||||
out.push({
|
|
||||||
id: r.id,
|
|
||||||
originalId: r.originalId,
|
|
||||||
app: r.app,
|
|
||||||
appIcon: r.appIcon,
|
|
||||||
summary: r.summary,
|
|
||||||
body: r.body,
|
|
||||||
image: r.image,
|
|
||||||
glyph: r.glyph || "",
|
|
||||||
exec: r.exec || "",
|
|
||||||
urgency: r.urgency,
|
|
||||||
expireTimeout: r.expireTimeout || 0,
|
|
||||||
timestamp: r.timestamp
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
var payload = {
|
|
||||||
version: 2,
|
|
||||||
dnd: persisted.doNotDisturb,
|
|
||||||
pending: dump(pendingModel),
|
|
||||||
past: dump(pastModel)
|
|
||||||
}
|
|
||||||
historyFile.setText(JSON.stringify(payload, null, 2) + "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
ensureDirsProc.running = true
|
ensureDirsProc.running = true
|
||||||
// Once mkdir has had a tick, load the existing history file. FileView
|
// Once mkdir has had a tick, load the existing settings file. FileView
|
||||||
// surfaces an empty string when the file doesn't exist; loadHistory
|
// surfaces an empty string when the file doesn't exist; loadSettings
|
||||||
// handles that path.
|
// handles that path.
|
||||||
Qt.callLater(function() {
|
Qt.callLater(function() {
|
||||||
historyFile.reload()
|
settingsFile.reload()
|
||||||
// Re-show popups that were on screen when the previous shell died.
|
// Re-show popups that were on screen when the previous shell died.
|
||||||
// The glob-through-bash tolerates a missing/empty dir (first run).
|
// The glob-through-bash tolerates a missing/empty dir (first run).
|
||||||
// awk 1 (not cat) so a torn file missing its trailing newline can't
|
// awk 1 (not cat) so a torn file missing its trailing newline can't
|
||||||
@@ -876,49 +672,27 @@ Item {
|
|||||||
return dndState()
|
return dndState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replay the notifications that have been moved into the history dir.
|
||||||
function showHistory(): string {
|
function showHistory(): string {
|
||||||
return service.showRecentHistory()
|
return service.showRecentHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
// `clear` empties the past tab (the "I already saw these" bucket).
|
// `clear` forgets the recorded history; the toasts on screen stay put.
|
||||||
function clear(): string {
|
function clear(): string {
|
||||||
service.clearPast()
|
service.clearHistory()
|
||||||
return "ok"
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearPending(): string {
|
|
||||||
service.clearPending()
|
|
||||||
return "ok"
|
|
||||||
}
|
|
||||||
|
|
||||||
function markAllSeen(): string {
|
|
||||||
service.markAllSeen()
|
|
||||||
return "ok"
|
return "ok"
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissAll(): string {
|
function dismissAll(): string {
|
||||||
service.clearPopups()
|
service.clearPopups()
|
||||||
service.clearPending()
|
|
||||||
service.clearPast()
|
|
||||||
return "ok"
|
return "ok"
|
||||||
}
|
}
|
||||||
|
|
||||||
// dismiss the most recent popup; fall back to the most recent pending
|
// Dismiss the most recent popup.
|
||||||
// entry, then past, if no popup is currently showing.
|
|
||||||
function dismissOne(): string {
|
function dismissOne(): string {
|
||||||
if (popupModel.count > 0) {
|
if (popupModel.count === 0) return "none"
|
||||||
service.dismissPopup(0)
|
service.dismissPopup(0)
|
||||||
return "ok"
|
return "ok"
|
||||||
}
|
|
||||||
if (pendingModel.count > 0) {
|
|
||||||
service.dismissPending(0)
|
|
||||||
return "ok"
|
|
||||||
}
|
|
||||||
if (pastModel.count > 0) {
|
|
||||||
service.dismissPast(0)
|
|
||||||
return "ok"
|
|
||||||
}
|
|
||||||
return "none"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire the default action on the most recent popup, then dismiss it.
|
// Fire the default action on the most recent popup, then dismiss it.
|
||||||
@@ -928,22 +702,19 @@ Item {
|
|||||||
return "ok"
|
return "ok"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Take a toast off the screen by summary substring, used by the
|
||||||
|
// first-run notifications once their action has been clicked.
|
||||||
function dismiss(summary: string): string {
|
function dismiss(summary: string): string {
|
||||||
var needle = String(summary || "")
|
var needle = String(summary || "")
|
||||||
if (!needle) return "none"
|
if (!needle) return "none"
|
||||||
var hit = false
|
var hit = false
|
||||||
function sweep(model, dismissFn) {
|
for (var i = popupModel.count - 1; i >= 0; i--) {
|
||||||
for (var i = model.count - 1; i >= 0; i--) {
|
var row = popupModel.get(i)
|
||||||
var row = model.get(i)
|
if (row && String(row.summary || "").indexOf(needle) !== -1) {
|
||||||
if (row && String(row.summary || "").indexOf(needle) !== -1) {
|
service.dismissPopup(i)
|
||||||
dismissFn(i)
|
hit = true
|
||||||
hit = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sweep(pendingModel, service.dismissPending)
|
|
||||||
sweep(pastModel, service.dismissPast)
|
|
||||||
sweep(popupModel, service.dismissPopup)
|
|
||||||
return hit ? "ok" : "none"
|
return hit ? "ok" : "none"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,64 +124,55 @@ assertDeepEqual(
|
|||||||
'notifications create stable snapshots'
|
'notifications create stable snapshots'
|
||||||
)
|
)
|
||||||
|
|
||||||
const history = notifications.parseHistory(JSON.stringify({
|
const settings = notifications.parseSettings(JSON.stringify({ version: 3, dnd: true }))
|
||||||
dnd: true,
|
assertEqual(settings.dnd, true, 'notifications parse the persisted DND state')
|
||||||
pending: [
|
assertEqual(settings.legacy, false, 'notifications do not flag a current settings file as legacy')
|
||||||
{ id: 1, originalId: 10, summary: 'old', timestamp: 100 },
|
assertEqual(notifications.parseSettings('').dnd, null, 'notifications leave DND unset without a settings file')
|
||||||
{ id: 2, originalId: 10, summary: 'new', timestamp: 200 },
|
assertEqual(
|
||||||
{ id: 3, originalId: 11, summary: 'other', timestamp: 150 }
|
notifications.parseSettings(JSON.stringify({ dnd: false, pending: [], past: [] })).legacy,
|
||||||
],
|
true,
|
||||||
past: [
|
'notifications flag a settings file still carrying the retired history rows'
|
||||||
{ id: 4, summary: 'past', timestamp: 50 }
|
)
|
||||||
],
|
assert(notifications.parseSettings('{').error, 'notifications flag invalid settings JSON')
|
||||||
entries: [
|
|
||||||
{ id: 5, summary: 'legacy', timestamp: 75 }
|
|
||||||
]
|
|
||||||
}), 1, 100)
|
|
||||||
|
|
||||||
assertEqual(history.dnd, true, 'notifications parse persisted DND state')
|
// History is the notification files moved into the history dir, read back
|
||||||
assertEqual(history.hadDuplicates, true, 'notifications report duplicate history rows')
|
// exactly like live popup files.
|
||||||
assertDeepEqual(
|
const archived = [
|
||||||
history.pending.map(row => ({ id: row.id, originalId: row.originalId, summary: row.summary, urgency: row.urgency, timestamp: row.timestamp })),
|
notifications.serializePopup({ id: 1, originalId: 1, summary: 'oldest', timestamp: 100 }, 1),
|
||||||
[
|
notifications.serializePopup({ id: 2, originalId: 2, summary: 'newest', timestamp: 900, expireTimeout: 30000, deadline: 5000 }, 1),
|
||||||
{ id: 2, originalId: 10, summary: 'new', urgency: 1, timestamp: 200 },
|
notifications.serializePopup({ id: 3, originalId: 3, summary: 'middle', timestamp: 500 }, 1)
|
||||||
{ id: 3, originalId: 11, summary: 'other', urgency: 1, timestamp: 150 }
|
].join('\n')
|
||||||
],
|
|
||||||
'notifications dedupe pending history by original id'
|
|
||||||
)
|
|
||||||
assertDeepEqual(
|
|
||||||
history.past.map(row => row.summary),
|
|
||||||
['legacy', 'past'],
|
|
||||||
'notifications merge legacy entries into past history'
|
|
||||||
)
|
|
||||||
assertDeepEqual(
|
|
||||||
notifications.parseHistory(JSON.stringify({ pending: [{ id: 1, timestamp: 1 }] }), 1, 0).pending,
|
|
||||||
[],
|
|
||||||
'notifications history parser supports zero result cap'
|
|
||||||
)
|
|
||||||
assert(notifications.parseHistory('{', 1, 100).error, 'notifications flag invalid history JSON')
|
|
||||||
|
|
||||||
const recentRows = notifications.recentHistoryRows(
|
const historyReplay = notifications.historyRows(archived, [], 1, 2)
|
||||||
[
|
assertDeepEqual(
|
||||||
{ id: 1, originalId: 10, summary: 'pending-old', timestamp: 100 },
|
historyReplay.map(row => row.summary),
|
||||||
{ id: 2, originalId: 11, summary: 'pending-new', timestamp: 700 },
|
['newest', 'middle'],
|
||||||
{ id: 3, originalId: 12, summary: 'pending-mid', timestamp: 300 }
|
'notifications replay the newest history rows up to the limit'
|
||||||
],
|
)
|
||||||
[
|
assertEqual(historyReplay[0].expireTimeout, 0, 'notifications replay history rows with the standard toast lifetime')
|
||||||
{ id: 4, originalId: 13, summary: 'past-newest', timestamp: 900 },
|
assertEqual('deadline' in historyReplay[0], false, 'notifications drop the restore deadline from replayed history rows')
|
||||||
{ id: 5, originalId: 14, summary: 'past-second', timestamp: 800 },
|
assertDeepEqual(notifications.historyRows('', [], 1, 10), [], 'notifications replay nothing from an empty history dir')
|
||||||
{ id: 6, originalId: 10, summary: 'past-replaced', timestamp: 200 },
|
|
||||||
{ id: 7, originalId: 15, summary: 'past-extra', timestamp: 50 }
|
// A toast still on screen is the newest notification there is, and its move
|
||||||
],
|
// into the history dir races the read, so the replay takes it from memory.
|
||||||
5,
|
assertDeepEqual(
|
||||||
1
|
notifications.historyRows(archived, [{ id: 4, originalId: 4, summary: 'on screen', timestamp: 1500 }], 1, 10)
|
||||||
|
.map(row => row.summary),
|
||||||
|
['on screen', 'newest', 'middle', 'oldest'],
|
||||||
|
'notifications replay the toasts still on screen alongside the archived ones'
|
||||||
)
|
)
|
||||||
assertDeepEqual(
|
assertDeepEqual(
|
||||||
recentRows.map(row => row.summary),
|
notifications.historyRows(archived, [{ id: 2, originalId: 2, summary: 'newest', timestamp: 900 }], 1, 10)
|
||||||
['past-newest', 'past-second', 'pending-new', 'pending-mid', 'past-replaced'],
|
.map(row => row.summary),
|
||||||
'notifications pick the last five history rows across pending and past'
|
['newest', 'middle', 'oldest'],
|
||||||
|
'notifications replay a toast once when its archived file already landed'
|
||||||
|
)
|
||||||
|
assertDeepEqual(
|
||||||
|
notifications.historyRows('', [{ id: 4, originalId: 4, summary: 'on screen', timestamp: 1500 }], 1, 10)
|
||||||
|
.map(row => row.summary),
|
||||||
|
['on screen'],
|
||||||
|
'notifications replay an on-screen toast even when nothing is archived yet'
|
||||||
)
|
)
|
||||||
assertEqual(recentRows.length, 5, 'notifications history replay is capped at five rows')
|
|
||||||
|
|
||||||
const popup = {
|
const popup = {
|
||||||
id: 7,
|
id: 7,
|
||||||
@@ -286,20 +277,11 @@ assertEqual(
|
|||||||
'xdg-open /tmp/received',
|
'xdg-open /tmp/received',
|
||||||
'notifications keep the click command on history rows'
|
'notifications keep the click command on history rows'
|
||||||
)
|
)
|
||||||
assertEqual(
|
|
||||||
notifications.dumpRows([{ id: 1, exec: 'xdg-open /tmp/received' }])[0].exec,
|
|
||||||
'xdg-open /tmp/received',
|
|
||||||
'notifications write the click command back out with history'
|
|
||||||
)
|
|
||||||
|
|
||||||
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 serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/Service.qml'), 'utf8')
|
||||||
assert(
|
assert(
|
||||||
/readonly property int historyReplayLimit: 5/.test(serviceQml),
|
/readonly property int historyLimit: 10/.test(serviceQml),
|
||||||
'notifications service limits history replay to five rows'
|
'notifications service keeps the last ten notifications in history'
|
||||||
)
|
)
|
||||||
assert(
|
assert(
|
||||||
/function showHistory\(\): string \{\s*return service\.showRecentHistory\(\)\s*\}/.test(serviceQml),
|
/function showHistory\(\): string \{\s*return service\.showRecentHistory\(\)\s*\}/.test(serviceQml),
|
||||||
@@ -310,12 +292,32 @@ assert(
|
|||||||
'notifications service persists popups under the omarchy state dir'
|
'notifications service persists popups under the omarchy state dir'
|
||||||
)
|
)
|
||||||
assert(
|
assert(
|
||||||
serviceQml.split('persistPopupFile(snapshot)').length === 4,
|
/readonly property string historyDir: popupStateDir \+ "history\/"/.test(serviceQml),
|
||||||
'notifications service persists both ephemeral and regular popups'
|
'notifications service keeps history in a subdirectory of the popup state dir'
|
||||||
)
|
)
|
||||||
assert(
|
assert(
|
||||||
/if \(entry\) \{\s*\n\s*deletePopupFileFor\(entry\)[\s\S]{0,200}?popupModel\.remove\(index\)/.test(serviceQml),
|
/if \(entry\) \{\s*\n\s*archivePopupFileFor\(entry\)[\s\S]{0,200}?popupModel\.remove\(index\)/.test(serviceQml),
|
||||||
'notifications service deletes the popup file when a popup leaves the screen'
|
'notifications service archives the popup file when a popup leaves the screen'
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
/mv -f \\"\$4\/\$3\\" \\"\$1\/\$3\\"/.test(serviceQml),
|
||||||
|
'notifications service archives by moving the popup file into the history dir'
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
/head -n \\"-\$2\\"/.test(serviceQml),
|
||||||
|
'notifications service trims history to the newest entries in the same job'
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
/if \(!isEphemeral\(notification\)\) writeHistoryFile\(snapshot\)/.test(serviceQml),
|
||||||
|
'notifications service records DND-silenced notifications straight into history'
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
/service\.replayCarryOver = liveRowsForReplay\(\)/.test(serviceQml),
|
||||||
|
'notifications service carries the toasts still on screen into the replay'
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
/awk 1 \\"\$1\\"\/\*\.json 2>\/dev\/null \|\| true", "--", historyDir/.test(serviceQml),
|
||||||
|
'notifications service replays history by reading the archived files'
|
||||||
)
|
)
|
||||||
assert(
|
assert(
|
||||||
/restorePopupsProc\.running = true/.test(serviceQml),
|
/restorePopupsProc\.running = true/.test(serviceQml),
|
||||||
@@ -330,8 +332,8 @@ assert(
|
|||||||
'notifications service never resolves a restored popup to a live server object'
|
'notifications service never resolves a restored popup to a live server object'
|
||||||
)
|
)
|
||||||
assert(
|
assert(
|
||||||
/markSeenByOriginalId\(originalId, timestamp\)/.test(serviceQml),
|
/service\.restoredPopups\[NotificationLogic\.popupFileName\(rows\[i\]\)\] = true/.test(serviceQml),
|
||||||
'notifications service archives pending rows by id and timestamp'
|
'notifications service treats replayed history rows as restored, never as live notifications'
|
||||||
)
|
)
|
||||||
assert(
|
assert(
|
||||||
/popupFileName\(row\) !== keepFileName/.test(serviceQml),
|
/popupFileName\(row\) !== keepFileName/.test(serviceQml),
|
||||||
@@ -346,11 +348,11 @@ assert(
|
|||||||
'notifications service runs the popup click command itself instead of a libnotify action'
|
'notifications service runs the popup click command itself instead of a libnotify action'
|
||||||
)
|
)
|
||||||
assert(
|
assert(
|
||||||
/exec: row\.exec \|\| ""/.test(serviceQml),
|
/function clear\(\): string \{\s*service\.clearHistory\(\)/.test(serviceQml),
|
||||||
'notifications service carries the click command between models'
|
'notifications clear IPC forgets the recorded history'
|
||||||
)
|
)
|
||||||
assert(
|
assert(
|
||||||
/exec: r\.exec \|\| ""/.test(serviceQml),
|
!/pendingModel|pastModel/.test(serviceQml),
|
||||||
'notifications service saves the click command with history'
|
'notifications service keeps no in-memory history models'
|
||||||
)
|
)
|
||||||
JS
|
JS
|
||||||
|
|||||||
Reference in New Issue
Block a user