repositories / dotfiles
dotfiles
bugabingas dorkfiles
owned by admin
quickshell/nuguland/Notifications.qml
Rawpragma 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);
}
}
}
}