C1: NotifItem QtObject with owned timer, refactor service to stable identities

This commit is contained in:
Damocles 2026-04-13 16:56:37 +02:00
parent 45704cb102
commit 88d8842064
3 changed files with 136 additions and 52 deletions

67
modules/NotifItem.qml Normal file
View file

@ -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();
}
}

View file

@ -12,30 +12,37 @@ QtObject {
property var list: [] property var list: []
property bool dnd: false property bool dnd: false
readonly property var popups: list.filter(n => n.popup) readonly property var popups: list.filter(n => n.popup && n.state !== "dismissed")
readonly property int count: list.length readonly property int count: list.filter(n => n.state !== "dismissed").length
// O(1) lookup
property var _byId: ({})
function dismiss(notifId) { function dismiss(notifId) {
const idx = list.findIndex(n => n.id === notifId); const item = _byId[notifId];
if (idx >= 0) { if (!item)
const n = list[idx]; return;
n.notification?.dismiss(); item.finishDismiss();
list.splice(idx, 1); list = list.filter(n => n !== item);
_changed(); delete _byId[notifId];
} item.destroy();
_saveTimer.restart();
} }
function dismissAll() { function dismissAll() {
for (const n of list.slice()) for (const item of list.slice()) {
n.notification?.dismiss(); item.finishDismiss();
delete _byId[item.id];
item.destroy();
}
list = []; list = [];
_changed(); _saveTimer.restart();
} }
function dismissPopup(notifId) { function dismissPopup(notifId) {
const n = list.find(n => n.id === notifId); const item = _byId[notifId];
if (n) { if (item) {
n.popup = false; item.popup = false;
_changed(); _changed();
} }
} }
@ -46,9 +53,11 @@ QtObject {
function _changed() { function _changed() {
list = list.slice(); list = list.slice();
_saveTimer.restart();
} }
// Signal popups to animate out before removal
signal popupExpiring(var notifId)
property NotificationServer _server: NotificationServer { property NotificationServer _server: NotificationServer {
actionsSupported: true actionsSupported: true
bodyMarkupSupported: true bodyMarkupSupported: true
@ -59,13 +68,17 @@ QtObject {
onNotification: notif => { onNotification: notif => {
notif.tracked = true; notif.tracked = true;
const data = { const isCritical = notif.urgency === NotificationUrgency.Critical;
const item = _itemComp.createObject(root, {
notification: notif,
id: notif.id, id: notif.id,
summary: notif.summary, summary: notif.summary,
body: notif.body, body: notif.body,
appName: notif.appName, appName: notif.appName,
appIcon: notif.appIcon, appIcon: notif.appIcon,
image: notif.image, image: notif.image,
hints: notif.hints,
urgency: notif.urgency, urgency: notif.urgency,
actions: notif.actions ? notif.actions.map(a => ({ actions: notif.actions ? notif.actions.map(a => ({
identifier: a.identifier, identifier: a.identifier,
@ -73,14 +86,13 @@ QtObject {
invoke: () => a.invoke() invoke: () => a.invoke()
})) : [], })) : [],
time: Date.now(), time: Date.now(),
popup: !root.dnd, popup: isCritical || !root.dnd
closed: false, });
notification: notif
};
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 max = M.Modules.notifications.maxPopups || 4;
const currentPopups = root.list.filter(n => n.popup); const currentPopups = root.list.filter(n => n.popup);
if (currentPopups.length > max) { if (currentPopups.length > max) {
@ -89,38 +101,33 @@ QtObject {
root._changed(); root._changed();
} }
// Auto-expire popup // Auto-expire popup (skip for critical)
if (data.popup) { if (item.popup && !isCritical) {
const timeout = notif.expireTimeout > 0 ? notif.expireTimeout : (M.Modules.notifications.timeout || 3000); const timeout = notif.expireTimeout > 0 ? notif.expireTimeout : (M.Modules.notifications.timeout || 3000);
Qt.callLater(() => { item._expireTimer.interval = timeout;
_expireTimer.createObject(root, { item._expireTimer.running = true;
_notifId: data.id, }
interval: timeout
}); // 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 property Component _itemComp: Component {
signal popupExpiring(var notifId) NotifItem {}
property Component _expireTimer: Component {
Timer {
property var _notifId
running: true
onTriggered: {
root.popupExpiring(_notifId);
destroy();
}
}
} }
// Persistence // Persistence
property Timer _saveTimer: Timer { property Timer _saveTimer: Timer {
interval: 1000 interval: 1000
onTriggered: { onTriggered: {
const data = root.list.map(n => ({ const data = root.list.filter(n => n.state !== "dismissed").map(n => ({
id: n.id, id: n.id,
summary: n.summary, summary: n.summary,
body: n.body, body: n.body,
@ -139,16 +146,25 @@ QtObject {
onLoaded: { onLoaded: {
try { try {
const data = JSON.parse(text()); 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]; const n = data[i];
// Prefix persisted IDs to avoid collision with live D-Bus IDs const item = _itemComp.createObject(root, {
n.id = "p_" + (n.id ?? i) + "_" + n.time; id: "p_" + (n.id ?? i) + "_" + n.time,
n.popup = false; summary: n.summary || "",
n.closed = false; body: n.body || "",
n.notification = null; appName: n.appName || "",
n.actions = []; 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) {} } catch (e) {}
} }
} }

View file

@ -32,6 +32,7 @@ PowerProfile 1.0 PowerProfile.qml
IdleInhibitor 1.0 IdleInhibitor.qml IdleInhibitor 1.0 IdleInhibitor.qml
Notifications 1.0 Notifications.qml Notifications 1.0 Notifications.qml
singleton NotifService 1.0 NotifService.qml singleton NotifService 1.0 NotifService.qml
NotifItem 1.0 NotifItem.qml
NotifPopup 1.0 NotifPopup.qml NotifPopup 1.0 NotifPopup.qml
NotifCenter 1.0 NotifCenter.qml NotifCenter 1.0 NotifCenter.qml
Power 1.0 Power.qml Power 1.0 Power.qml