Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

quickshell/nuguland/Notifications.qml

Raw
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);
            }
        }
    }
}