pragma ComponentBehavior: Bound import QtQuick import Quickshell import Quickshell.Services.Notifications Item { id: root property int attentionCount: 0 property bool dnd: false property bool popupInhibition: false property int unreadCount: 0 property var notifications: [] property var nativeNotifications: ({}) property var expandedGroups: [] property var activeToasts: [] property var toastById: ({}) property var replyDrafts: ({}) property var replyFailures: ({}) readonly property int defaultToastTimeoutMs: 5000 property var lastAction: ({}) readonly property bool nativeActionRoute: true readonly property bool usesNativeNotificationServer: true readonly property int groupPreviewCount: 1 readonly property bool removalLifecycleLock: true function activeRole() { return "ui.focus"; } function attentionRole() { return "warning"; } function borderRole(attention, active) { if (attention) return attentionRole(); if (active) return activeRole(); return "ui.accent"; } function fillRole(active) { return active ? activeRole() : "transparent"; } function widgetState(attention, active) { return JSON.stringify({ "attention": attention, "active": active, "attentionRole": attentionRole(), "activeRole": activeRole(), "borderRole": borderRole(attention, active), "fillRole": fillRole(active) }); } function urgencyName(value) { if (value === NotificationUrgency.Critical) return "critical"; if (value === NotificationUrgency.Low) return "low"; if (String(value).toLowerCase().indexOf("critical") >= 0) return "critical"; if (String(value).toLowerCase().indexOf("low") >= 0) return "low"; return "normal"; } function actionList(record) { const source = record && record.actions ? record.actions : []; const rows = []; for (let i = 0; i < source.length; i++) { const action = source[i]; rows.push({ "id": action.id || action.identifier || `action-${i}`, "label": action.label || action.text || "action" }); } return rows; } function normalize(input) { const record = input || ({}); const id = String(record.id || record.notificationId || `notif-${notifications.length + 1}`); const actions = actionList(record); return { "id": id, "nativeId": record.nativeId || record.notificationId || "", "appName": record.appName || "unknown", "appIcon": record.appIcon || "", "image": record.image || "", "summary": record.summary || "", "body": record.body || "", "time": Number(record.time || Date.now()), "urgency": urgencyName(record.urgency || "normal"), "expireTimeout": Number(record.expireTimeout !== undefined ? record.expireTimeout : -1), "transient": !!record.transient, "actions": actions, // KDE/Plasma de-facto progress hint (int:value 0-100); -1 = absent. "value": Number(record.value !== undefined ? record.value : -1), // resident: keep the notification after an action is invoked. "resident": !!record.resident, // action-icons: action identifiers double as freedesktop icon names. "hasActionIcons": !!record.hasActionIcons, // inline reply (chat apps): a quick-reply field + sendInlineReply(). "hasInlineReply": !!record.hasInlineReply, "inlineReplyPlaceholder": String(record.inlineReplyPlaceholder || ""), "popup": !(dnd || popupInhibition), "closed": false, "read": false, "removalLifecycleLock": false }; } function toastEligible(row, dndOn) { if (row.urgency === "critical") return true; return !dndOn; } // KDE/Plasma progress hint: hints.value (0-100), -1 when absent. Read live // off the native object so replace-in-place progress updates flow through. function hintValue(notification) { const h = notification && notification.hints ? notification.hints : null; return h && h.value !== undefined && h.value !== null ? Number(h.value) : -1; } // freedesktop sound hints. sound-file (path) plays via the PipeWire CLI; // sound-name (theme id) needs canberra + a sound theme. suppress-sound mutes. function playSound(notification) { const h = notification && notification.hints ? notification.hints : null; if (!h || h["suppress-sound"]) return; const file = h["sound-file"]; const name = h["sound-name"]; if (file) Quickshell.execDetached(["pw-play", String(file)]); else if (name) Quickshell.execDetached(["canberra-gtk-play", "-i", String(name)]); } function toastTimeoutMs(row) { if (row.urgency === "critical" || Number(row.expireTimeout) === 0) return 0; const et = Number(row.expireTimeout); return et > 0 ? et : defaultToastTimeoutMs; } function mergeRow(list, row) { const next = (list || []).slice(); for (let i = 0; i < next.length; i++) { if (next[i].id === row.id) { next[i] = Object.assign({}, next[i], row, { "read": next[i].read }); return next; } } next.push(row); return next; } function setReplyDraft(notificationId, text) { const id = String(notificationId); const drafts = Object.assign({}, replyDrafts); const failures = Object.assign({}, replyFailures); drafts[id] = String(text || ""); delete failures[id]; replyDrafts = drafts; replyFailures = failures; return stateJson(); } function clearReplyState(notificationId) { const id = String(notificationId); const drafts = Object.assign({}, replyDrafts); const failures = Object.assign({}, replyFailures); delete drafts[id]; delete failures[id]; replyDrafts = drafts; replyFailures = failures; } function failReply(notificationId, reason) { const failures = Object.assign({}, replyFailures); failures[String(notificationId)] = String(reason); replyFailures = failures; } function ingest(row) { notifications = mergeRow(notifications, row); let unread = 0; for (let i = 0; i < notifications.length; i++) if (!notifications[i].closed && !notifications[i].read) unread += 1; unreadCount = unread; } function selfTest() { const failures = []; function expect(cond, msg) { if (!cond) failures.push(msg); } // replace-by-id: same id updates in place, no duplicate let list = []; list = mergeRow(list, { "id": "7", "summary": "10%", "closed": false, "read": false }); list = mergeRow(list, { "id": "7", "summary": "50%", "closed": false, "read": false }); expect(list.length === 1, "replace-by-id should not duplicate"); expect(list[0].summary === "50%", "replace-by-id should update content"); // distinct ids stay separate list = mergeRow(list, { "id": "8", "summary": "other", "closed": false, "read": false }); expect(list.length === 2, "distinct ids should be separate rows"); // read flag preserved on replace let rlist = []; rlist = mergeRow(rlist, { "id": "9", "summary": "x", "closed": false, "read": true }); rlist = mergeRow(rlist, { "id": "9", "summary": "y", "closed": false, "read": false }); expect(rlist[0].read === true, "replace-by-id should preserve original read flag"); expect(toastEligible({ "urgency": "normal" }, false) === true, "normal toasts when DND off"); expect(toastEligible({ "urgency": "normal" }, true) === false, "normal suppressed under DND"); expect(toastEligible({ "urgency": "critical" }, true) === true, "critical bypasses DND"); expect(toastTimeoutMs({ "urgency": "critical", "expireTimeout": 5 }) === 0, "critical is sticky"); expect(toastTimeoutMs({ "urgency": "normal", "expireTimeout": 0 }) === 0, "expireTimeout 0 is sticky"); expect(toastTimeoutMs({ "urgency": "normal", "expireTimeout": -1 }) === defaultToastTimeoutMs, "default timeout"); // Stateful behavior is exercised through IPC, but always restore the // complete state so the test is safe even when called by a live client. const snapshot = { "dnd": dnd, "notifications": notifications, "nativeNotifications": nativeNotifications, "activeToasts": activeToasts, "toastById": toastById, "unreadCount": unreadCount, "replyDrafts": replyDrafts, "replyFailures": replyFailures, "lastAction": lastAction }; try { dnd = false; notifications = [ { "id": "history", "nativeId": "native-history", "urgency": "normal", "closed": false, "read": false } ]; nativeNotifications = { "native-normal": ({ "summary": "normal" }), "native-critical": ({ "summary": "critical" }), "native-history": ({ "summary": "history" }) }; activeToasts = ["normal-alias", "critical"]; toastById = { "normal-alias": ({ "id": "normal-alias", "nativeId": "native-normal", "urgency": "normal", "transient": true }), "critical": ({ "id": "critical", "nativeId": "native-critical", "urgency": "critical", "transient": true }) }; toggleDnd(); expect(activeToasts.length === 1 && activeToasts[0] === "critical", "DND prunes only normal toasts"); expect(!!nativeNotifications["native-critical"], "DND retains critical native references"); expect(!nativeNotifications["native-normal"], "DND releases transient aliases by native id"); expect(!!nativeNotifications["native-history"] && notifications.length === 1, "DND retains history and native lifecycle"); expect(toastEligible({ "urgency": "normal" }, dnd) === false, "DND suppresses later normal toasts"); dismissToasts(); expect(activeToasts.length === 0 && Object.keys(toastById).length === 0, "dismissToasts clears critical toasts too"); expect(notifications.length === 1 && !!nativeNotifications["native-history"], "dismissToasts preserves history"); setReplyDraft("history", "keep this"); sendReply("history", "keep this"); expect(replyDrafts.history === "keep this", "failed reply preserves draft"); expect(replyFailures.history === "reply not sent", "failed reply exposes sanitized failure"); sendReply("missing", "still here"); expect(replyDrafts.missing === "still here", "missing reply preserves draft"); expect(replyFailures.missing === "notification no longer available", "missing reply exposes sanitized failure"); closeNotification(notifications[0], true); expect(replyDrafts.history === undefined && replyFailures.history === undefined, "notification removal clears reply state"); } finally { dnd = snapshot.dnd; notifications = snapshot.notifications; nativeNotifications = snapshot.nativeNotifications; activeToasts = snapshot.activeToasts; toastById = snapshot.toastById; unreadCount = snapshot.unreadCount; replyDrafts = snapshot.replyDrafts; replyFailures = snapshot.replyFailures; lastAction = snapshot.lastAction; } return { "pass": failures.length === 0, "failures": failures }; } function acceptNativeNotification(notification) { if (!notification) return; notification.tracked = true; const id = String(notification.id); const row = normalize({ "id": id, "nativeId": id, "appName": notification.appName, "appIcon": notification.appIcon, "image": notification.image, "summary": notification.summary, "body": notification.body, "time": Date.now(), "urgency": notification.urgency, "expireTimeout": notification.expireTimeout, "transient": notification.transient, "actions": notification.actions || [], "value": hintValue(notification), "resident": notification.resident, "hasActionIcons": notification.hasActionIcons, "hasInlineReply": notification.hasInlineReply, "inlineReplyPlaceholder": notification.inlineReplyPlaceholder }); const eligible = toastEligible(row, dnd); // only retain the live native object if something will render it: // history (non-transient) or an active toast. A transient that DND // suppresses references neither — storing it would leak forever. if (!row.transient || eligible) { const map = Object.assign({}, nativeNotifications); map[id] = notification; nativeNotifications = map; } if (!row.transient) ingest(row); if (eligible) { const tms = toastTimeoutMs(row); const map = Object.assign({}, toastById); // Stamp absolute expiry ONCE. The toast Repeater rebuilds every // delegate whenever the toast set changes; deriving the timer/drain // from a stored expiresAt lets a rebuilt delegate resume at the // right position instead of restarting from full. map[row.id] = Object.assign({}, row, { "toastTimeoutMs": tms, "expiresAt": tms > 0 ? Date.now() + tms : 0 }); toastById = map; activeToasts = activeToasts.filter(x => x !== row.id).concat([row.id]); playSound(notification); } lastAction = { "kind": "native-notification", "id": id, "nativeActionRoute": nativeActionRoute }; } function actionRows(notification) { const rows = [ { "id": "close", "kind": "close", "label": "close", "route": "Notifications.qml" } ]; const serverActions = notification.actions || []; for (let i = 0; i < serverActions.length; i++) { const aid = serverActions[i].id || serverActions[i].identifier || `action-${i}`; rows.push({ "id": aid, "kind": "server-action", "label": serverActions[i].label || serverActions[i].text || "action", // with action-icons, the identifier IS a freedesktop icon name. "icon": notification.hasActionIcons ? aid : "", "route": "Notifications.qml" }); } rows.push({ "id": "copy-body", "kind": "copy-body", "label": "copy body", "route": "Notifications.qml" }); return rows; } function highestUrgency(rows) { for (let i = 0; i < rows.length; i++) if (rows[i].urgency === "critical") { return "critical"; } for (let j = 0; j < rows.length; j++) if (rows[j].urgency === "normal") { return "normal"; } return "low"; } function liveRow(row) { const n = nativeNotifications[String(row.nativeId || row.id)]; if (!n) return row; return Object.assign({}, row, { "summary": n.summary !== undefined ? String(n.summary) : row.summary, "body": n.body !== undefined ? String(n.body) : row.body, "appName": n.appName || row.appName, "appIcon": n.appIcon || row.appIcon, "image": n.image || row.image, "value": hintValue(n), "urgency": urgencyName(n.urgency) }); } function activeToastRows() { const rows = []; for (let t = 0; t < activeToasts.length; t++) { const stored = toastById[activeToasts[t]]; if (!stored) continue; const row = liveRow(stored); if (!row.closed) rows.push(Object.assign({}, row, { "actionRows": actionRows(row), "timeoutMs": row.toastTimeoutMs !== undefined ? row.toastTimeoutMs : toastTimeoutMs(row), "expiresAt": row.expiresAt || 0 })); } return rows; } function pruneToasts(keep) { const oldMap = toastById; const nextMap = ({}); const nextIds = []; const retainedNative = ({}); const removed = []; for (let i = 0; i < activeToasts.length; i++) { const tid = activeToasts[i]; const toast = oldMap[tid]; if (toast && keep(toast)) { nextIds.push(tid); nextMap[tid] = toast; retainedNative[String(toast.nativeId || toast.id)] = true; } else if (toast) { removed.push(toast); } } // Historical notifications own their native reference independently // from a toast, including replacement ids that alias nativeId. for (let h = 0; h < notifications.length; h++) retainedNative[String(notifications[h].nativeId || notifications[h].id)] = true; const nmap = Object.assign({}, nativeNotifications); for (let r = 0; r < removed.length; r++) { const row = removed[r]; const nativeKey = String(row.nativeId || row.id); if (row.transient && !retainedNative[nativeKey]) delete nmap[nativeKey]; } activeToasts = nextIds; toastById = nextMap; nativeNotifications = nmap; } function dismissToast(id) { const sid = String(id); pruneToasts(toast => String(toast.id) !== sid); return stateJson(); } function dismissToasts() { pruneToasts(() => false); return stateJson(); } function groups() { const byApp = ({}); for (let raw = 0; raw < notifications.length; raw++) { if (notifications[raw].closed) continue; const n = liveRow(notifications[raw]); if (!byApp[n.appName]) byApp[n.appName] = { "id": n.appName, "appName": n.appName, "latestTime": 0, "notifications": [], "expanded": expandedGroups.indexOf(n.appName) >= 0 }; byApp[n.appName].notifications.push(Object.assign({}, n, { "actionRows": actionRows(n), "replyDraft": String(replyDrafts[n.id] || ""), "replyError": String(replyFailures[n.id] || "") })); byApp[n.appName].latestTime = Math.max(byApp[n.appName].latestTime, n.time); } const result = []; for (const app in byApp) { byApp[app].notifications.sort((a, b) => { return b.time - a.time; }); byApp[app].count = byApp[app].notifications.length; byApp[app].urgency = highestUrgency(byApp[app].notifications); byApp[app].previewCount = groupPreviewCount; byApp[app].visibleNotifications = byApp[app].expanded ? byApp[app].notifications : byApp[app].notifications.slice(0, groupPreviewCount); byApp[app].expandCollapseState = byApp[app].expanded ? "expanded" : "collapsed"; byApp[app].animatedExpandHeight = true; result.push(byApp[app]); } result.sort((a, b) => { return b.latestTime - a.latestTime; }); return result; } function stateObject() { const active = notifications.filter(n => { return !n.closed; }); const grouped = groups(); return { "backend": "Notifications.qml", "usesNativeNotificationServer": usesNativeNotificationServer, "nativeActionRoute": nativeActionRoute, "syntheticNotifications": false, "trackedNativeCount": Object.keys(nativeNotifications).length, "expandedGroups": expandedGroups.slice(0), "groupPreviewCount": groupPreviewCount, "dnd": dnd, "popupInhibition": dnd || popupInhibition, "toastAnchor": { "barId": "notifications", "edge": "top", "source": "NotificationServer", "suppressedWhenDnd": true, "popupVisible": !(dnd || popupInhibition) && active.length > 0 }, "unreadCount": unreadCount, "activeCount": active.length, "groups": grouped, "actionKinds": ["close", "server-action", "copy-body"], "discardAll": true, "removalLifecycleLock": removalLifecycleLock, "lastAction": lastAction, "activeToasts": activeToastRows() }; } function stateJson() { return JSON.stringify(stateObject()); } function toggleDnd() { dnd = !dnd; if (dnd) pruneToasts(toast => toast.urgency === "critical"); lastAction = { "kind": "toggle-dnd", "enabled": dnd }; return stateJson(); } function closeNotification(row, skipNativeDismiss) { if (!row || row.closed) return; row.closed = true; row.removalLifecycleLock = removalLifecycleLock; clearReplyState(row.id); unreadCount = Math.max(0, unreadCount - 1); const nativeNotification = nativeNotifications[String(row.nativeId || row.id)]; // sendInlineReply() already destroys the native object — dismissing it // again throws "Cannot close destroyed notification". Callers that know // the native is gone pass skipNativeDismiss. if (!skipNativeDismiss && nativeActionRoute && nativeNotification && nativeNotification.dismiss) nativeNotification.dismiss(); activeToasts = activeToasts.filter(x => x !== String(row.id)); const tmap = Object.assign({}, toastById); delete tmap[String(row.id)]; toastById = tmap; // native object is dismissed; drop it so the map doesn't grow for the // life of the process (groups() skips closed rows before liveRow()). const nmap = Object.assign({}, nativeNotifications); delete nmap[String(row.nativeId || row.id)]; nativeNotifications = nmap; } function runAction(notificationId, actionId) { const id = String(notificationId); const action = String(actionId); for (let i = 0; i < notifications.length; i++) { const n = notifications[i]; if (n.id !== id) continue; if (action === "close") { closeNotification(n); lastAction = { "kind": "close", "id": id, "route": "Notifications.qml", "nativeActionRoute": nativeActionRoute }; } else if (action === "copy-body") { if (n.body.length > 0) Quickshell.execDetached(["wl-copy", n.body]); lastAction = { "kind": "copy-body", "id": id, "bodyCopied": n.body.length > 0, "route": "Notifications.qml", "clipboardRoute": "wl-copy" }; } else { let invoked = false; if (nativeActionRoute) { const nativeNotification = nativeNotifications[String(n.nativeId || n.id)]; const nativeActions = nativeNotification && nativeNotification.actions ? nativeNotification.actions : []; for (let j = 0; j < nativeActions.length; j++) { if (nativeActions[j].identifier === action && nativeActions[j].invoke) { nativeActions[j].invoke(); invoked = true; } } } lastAction = { "kind": "server-action", "id": id, "actionId": action, "invoked": invoked, "resident": !!n.resident, "route": "Notifications.qml", "nativeActionRoute": nativeActionRoute }; // freedesktop: invoking an action dismisses the notification // unless it declared itself resident. if (!n.resident) closeNotification(n); } notifications = notifications.slice(0); return stateJson(); } lastAction = { "kind": "missing", "id": id, "actionId": action, "route": "Notifications.qml" }; return stateJson(); } // inline reply: route the text to the native object's sendInlineReply(). // freedesktop convention closes the notification after a reply unless it // declared itself resident. function sendReply(notificationId, text) { const id = String(notificationId); const body = String(text || ""); setReplyDraft(id, body); for (let i = 0; i < notifications.length; i++) { const n = notifications[i]; if (n.id !== id) continue; let sent = false; const native = nativeNotifications[String(n.nativeId || n.id)]; if (body.length > 0 && native && native.hasInlineReply && native.sendInlineReply) { native.sendInlineReply(body); sent = true; } lastAction = { "kind": "inline-reply", "id": id, "sent": sent, "resident": !!n.resident, "route": "Notifications.qml" }; if (sent) { clearReplyState(id); if (!n.resident) closeNotification(n, true); } else { failReply(id, "reply not sent"); } notifications = notifications.slice(0); return stateJson(); } failReply(id, "notification no longer available"); lastAction = { "kind": "missing", "id": id, "sent": false, "route": "Notifications.qml" }; return stateJson(); } function toggleGroup(appName) { const app = String(appName); const expanded = expandedGroups.indexOf(app) < 0; expandedGroups = expanded ? expandedGroups.concat([app]) : expandedGroups.filter(row => { return row !== app; }); lastAction = { "kind": "toggleGroup", "appName": app, "expanded": expanded }; return stateJson(); } function discardAllNotifications() { notifications = []; unreadCount = 0; // "Clear all" means everything: drop showing toasts (incl. sticky // criticals) and release every tracked native object. activeToasts = []; toastById = ({}); nativeNotifications = ({}); replyDrafts = ({}); replyFailures = ({}); lastAction = { "kind": "discardAll", "route": "Notifications.qml" }; return stateJson(); } Loader { active: true sourceComponent: NotificationServer { keepOnReload: false persistenceSupported: true bodySupported: true bodyMarkupSupported: true bodyHyperlinksSupported: true bodyImagesSupported: true actionsSupported: true actionIconsSupported: true imageSupported: true inlineReplySupported: true onNotification: notification => { return root.acceptNativeNotification(notification); } } } }