From 88d8842064588f867026c2d2f890da9546ca38b6 Mon Sep 17 00:00:00 2001 From: Damocles Date: Mon, 13 Apr 2026 16:56:37 +0200 Subject: [PATCH] C1: NotifItem QtObject with owned timer, refactor service to stable identities --- modules/NotifItem.qml | 67 ++++++++++++++++++++++ modules/NotifService.qml | 120 ++++++++++++++++++++++----------------- modules/qmldir | 1 + 3 files changed, 136 insertions(+), 52 deletions(-) create mode 100644 modules/NotifItem.qml diff --git a/modules/NotifItem.qml b/modules/NotifItem.qml new file mode 100644 index 0000000..e24941f --- /dev/null +++ b/modules/NotifItem.qml @@ -0,0 +1,67 @@ +import QtQuick +import Quickshell.Services.Notifications +import "." as M + +QtObject { + id: root + + property bool popup: false + property string state: "visible" // "visible" | "dismissing" | "dismissed" + + property var notification: null + property var id + property string summary + property string body + property string appName + property string appIcon + property string image + property var hints + property int urgency: NotificationUrgency.Normal + property var actions: [] + property real time: Date.now() + + // Expire timer — owned by this item, not dynamically created + readonly property Timer _expireTimer: Timer { + running: false + onTriggered: { + if (root.state === "visible") + root.popup = false; + } + } + + // Relative time string + property string timeStr: "now" + readonly property Timer _timeStrTimer: Timer { + running: root.state !== "dismissed" + repeat: true + interval: 5000 + onTriggered: root._updateTimeStr() + } + + function _updateTimeStr() { + const diff = Date.now() - time; + const m = Math.floor(diff / 60000); + if (m < 1) { + timeStr = "now"; + return; + } + const h = Math.floor(m / 60); + if (h < 1) { + timeStr = m + "m"; + return; + } + const d = Math.floor(h / 24); + timeStr = d > 0 ? d + "d" : h + "h"; + } + + function beginDismiss() { + if (state === "visible") + state = "dismissing"; + } + + function finishDismiss() { + state = "dismissed"; + _expireTimer.running = false; + notification?.dismiss(); + } +} diff --git a/modules/NotifService.qml b/modules/NotifService.qml index 38a45d6..6f57f45 100644 --- a/modules/NotifService.qml +++ b/modules/NotifService.qml @@ -12,30 +12,37 @@ QtObject { property var list: [] property bool dnd: false - readonly property var popups: list.filter(n => n.popup) - readonly property int count: list.length + readonly property var popups: list.filter(n => n.popup && n.state !== "dismissed") + readonly property int count: list.filter(n => n.state !== "dismissed").length + + // O(1) lookup + property var _byId: ({}) function dismiss(notifId) { - const idx = list.findIndex(n => n.id === notifId); - if (idx >= 0) { - const n = list[idx]; - n.notification?.dismiss(); - list.splice(idx, 1); - _changed(); - } + const item = _byId[notifId]; + if (!item) + return; + item.finishDismiss(); + list = list.filter(n => n !== item); + delete _byId[notifId]; + item.destroy(); + _saveTimer.restart(); } function dismissAll() { - for (const n of list.slice()) - n.notification?.dismiss(); + for (const item of list.slice()) { + item.finishDismiss(); + delete _byId[item.id]; + item.destroy(); + } list = []; - _changed(); + _saveTimer.restart(); } function dismissPopup(notifId) { - const n = list.find(n => n.id === notifId); - if (n) { - n.popup = false; + const item = _byId[notifId]; + if (item) { + item.popup = false; _changed(); } } @@ -46,9 +53,11 @@ QtObject { function _changed() { list = list.slice(); - _saveTimer.restart(); } + // Signal popups to animate out before removal + signal popupExpiring(var notifId) + property NotificationServer _server: NotificationServer { actionsSupported: true bodyMarkupSupported: true @@ -59,13 +68,17 @@ QtObject { onNotification: notif => { notif.tracked = true; - const data = { + const isCritical = notif.urgency === NotificationUrgency.Critical; + + const item = _itemComp.createObject(root, { + notification: notif, id: notif.id, summary: notif.summary, body: notif.body, appName: notif.appName, appIcon: notif.appIcon, image: notif.image, + hints: notif.hints, urgency: notif.urgency, actions: notif.actions ? notif.actions.map(a => ({ identifier: a.identifier, @@ -73,14 +86,13 @@ QtObject { invoke: () => a.invoke() })) : [], time: Date.now(), - popup: !root.dnd, - closed: false, - notification: notif - }; + popup: isCritical || !root.dnd + }); - root.list = [data, ...root.list]; + root._byId[item.id] = item; + root.list = [item, ...root.list]; - // Dismiss excess popups (keep in history, just hide popup) + // Trim excess popups const max = M.Modules.notifications.maxPopups || 4; const currentPopups = root.list.filter(n => n.popup); if (currentPopups.length > max) { @@ -89,38 +101,33 @@ QtObject { root._changed(); } - // Auto-expire popup - if (data.popup) { + // Auto-expire popup (skip for critical) + if (item.popup && !isCritical) { const timeout = notif.expireTimeout > 0 ? notif.expireTimeout : (M.Modules.notifications.timeout || 3000); - Qt.callLater(() => { - _expireTimer.createObject(root, { - _notifId: data.id, - interval: timeout - }); - }); + item._expireTimer.interval = timeout; + item._expireTimer.running = true; + } + + // Trim history + const maxHistory = M.Modules.notifications.maxHistory || 50; + while (root.list.length > maxHistory) { + const old = root.list.pop(); + old.finishDismiss(); + delete root._byId[old.id]; + old.destroy(); } } } - // Signal popups to animate out before removal - signal popupExpiring(var notifId) - - property Component _expireTimer: Component { - Timer { - property var _notifId - running: true - onTriggered: { - root.popupExpiring(_notifId); - destroy(); - } - } + property Component _itemComp: Component { + NotifItem {} } // Persistence property Timer _saveTimer: Timer { interval: 1000 onTriggered: { - const data = root.list.map(n => ({ + const data = root.list.filter(n => n.state !== "dismissed").map(n => ({ id: n.id, summary: n.summary, body: n.body, @@ -139,16 +146,25 @@ QtObject { onLoaded: { try { const data = JSON.parse(text()); - for (let i = 0; i < data.length; i++) { + const maxHistory = M.Modules.notifications.maxHistory || 50; + for (let i = 0; i < Math.min(data.length, maxHistory); i++) { const n = data[i]; - // Prefix persisted IDs to avoid collision with live D-Bus IDs - n.id = "p_" + (n.id ?? i) + "_" + n.time; - n.popup = false; - n.closed = false; - n.notification = null; - n.actions = []; + const item = _itemComp.createObject(root, { + id: "p_" + (n.id ?? i) + "_" + n.time, + summary: n.summary || "", + body: n.body || "", + appName: n.appName || "", + appIcon: n.appIcon || "", + image: n.image || "", + urgency: n.urgency ?? 1, + time: n.time || Date.now(), + popup: false, + actions: [] + }); + root._byId[item.id] = item; + root.list.push(item); } - root.list = data.concat(root.list); + root._changed(); } catch (e) {} } } diff --git a/modules/qmldir b/modules/qmldir index c6ecb8f..848ae10 100644 --- a/modules/qmldir +++ b/modules/qmldir @@ -32,6 +32,7 @@ PowerProfile 1.0 PowerProfile.qml IdleInhibitor 1.0 IdleInhibitor.qml Notifications 1.0 Notifications.qml singleton NotifService 1.0 NotifService.qml +NotifItem 1.0 NotifItem.qml NotifPopup 1.0 NotifPopup.qml NotifCenter 1.0 NotifCenter.qml Power 1.0 Power.qml