pragma Singleton import QtQuick import QtQuick.Window import Quickshell import Quickshell.Io import Quickshell.Services.Notifications import Quickshell.Wayland import "../../Helpers/sha256.js" as Checksum import qs.Commons import qs.Services.Compositor import qs.Services.Media import qs.Services.Power import qs.Services.System import qs.Services.UI Singleton { id: root // Configuration property int maxPopups: 5 property int maxHistory: 100 property string historyFile: Quickshell.env("NOCTALIA_NOTIF_HISTORY_FILE") || (Settings.cacheDir + "notifications.json") // State property real lastSeenTs: 0 // Volatile property that doesn't persist to settings (similar to noctaliaPerformanceMode) property bool doNotDisturb: false // Models property ListModel popupModel: ListModel {} property ListModel historyModel: ListModel {} // Internal state property var popupState: ({}) // Maps internal ID to {notification, watcher, cachedActions, metadata} property var quickshellIdToInternalId: ({}) // Rate limiting for notification sounds (minimum 100ms between sounds) property var lastSoundTime: 0 readonly property int minSoundInterval: 100 // Notification server property var notificationServerLoader: null Component { id: notificationServerComponent NotificationServer { keepOnReload: false imageSupported: true actionsSupported: true onNotification: notification => handleNotification(notification) } } Component { id: notificationWatcherComponent Connections { property var targetNotification property var targetDataId target: targetNotification function onSummaryChanged() { updateNotificationFromObject(targetDataId); } function onBodyChanged() { updateNotificationFromObject(targetDataId); } function onAppNameChanged() { updateNotificationFromObject(targetDataId); } function onUrgencyChanged() { updateNotificationFromObject(targetDataId); } function onAppIconChanged() { updateNotificationFromObject(targetDataId); } function onImageChanged() { updateNotificationFromObject(targetDataId); } function onActionsChanged() { updateNotificationFromObject(targetDataId); } } } function updateNotificationServer() { if (notificationServerLoader) { notificationServerLoader.destroy(); notificationServerLoader = null; } if (Settings.isLoaded && Settings.data.notifications.enabled !== false) { notificationServerLoader = notificationServerComponent.createObject(root); } } Component.onCompleted: { if (Settings.isLoaded) { updateNotificationServer(); } // Load state from ShellState Qt.callLater(() => { if (typeof ShellState !== 'undefined' && ShellState.isLoaded) { loadState(); } }); } Connections { target: typeof ShellState !== 'undefined' ? ShellState : null function onIsLoadedChanged() { if (ShellState.isLoaded) { loadState(); } } } Connections { target: Settings function onSettingsLoaded() { updateNotificationServer(); } function onSettingsSaved() { updateNotificationServer(); } } // Helper function to generate content-based ID for deduplication function getContentId(summary, body, appName) { return Checksum.sha256(JSON.stringify({ "summary": summary || "", "body": body || "", "app": appName || "" })); } // Main handler function handleNotification(notification) { const quickshellId = notification.id; const data = createData(notification); const ruleAction = NotificationRulesService.evaluate(data.appName, data.summary, data.body); if (ruleAction === "block") return; if (ruleAction === "hide") { trySaveToHistory(data, notification); return; } trySaveToHistory(data, notification); if (root.doNotDisturb || PowerProfileService.noctaliaPerformanceMode) return; // Check if this is a replacement notification const existingInternalId = quickshellIdToInternalId[quickshellId]; if (existingInternalId && popupState[existingInternalId]) { updatePopup(existingInternalId, notification, data); return; } // Check for duplicate content const duplicateId = findDuplicateNotification(data); if (duplicateId) { removePopup(duplicateId); } // Add new notification addPopup(quickshellId, notification, data); if (ruleAction !== "mute") playNotificationSound(data.urgency, data.appName); } function playNotificationSound(urgency, appName) { if (!SoundService.multimediaAvailable) { return; } if (!Settings.data.notifications?.sounds?.enabled) { return; } if (AudioService.muted) { return; } if (appName) { const excludedApps = Settings.data.notifications.sounds.excludedApps || ""; if (excludedApps.trim() !== "") { const excludedList = excludedApps.toLowerCase().split(',').map(app => app.trim()); const normalizedName = appName.toLowerCase(); if (excludedList.includes(normalizedName)) { Logger.i("NotificationService", `Skipping sound for excluded app: ${appName}`); return; } } } // Get the sound file for this urgency level const soundFile = getNotificationSoundFile(urgency); if (!soundFile || soundFile.trim() === "") { // No sound file configured for this urgency level Logger.i("NotificationService", `No sound file configured for urgency ${urgency}`); return; } // Rate limiting - prevent sound spam const now = Date.now(); if (now - lastSoundTime < minSoundInterval) { return; } lastSoundTime = now; // Play sound using existing SoundService const volume = Settings.data.notifications?.sounds?.volume ?? 0.5; SoundService.playSound(soundFile, { volume: volume, fallback: false, repeat: false }); } // Get the appropriate sound file path for a given urgency level function getNotificationSoundFile(urgency) { const settings = Settings.data.notifications?.sounds; if (!settings) { return ""; } // Default sound file path const defaultSoundFile = Quickshell.shellDir + "/Assets/Sounds/notification-generic.wav"; // If separate sounds is disabled, always use normal sound for all urgencies if (!settings.separateSounds) { const soundFile = settings.normalSoundFile; if (soundFile && soundFile.trim() !== "") { return soundFile; } // Return default if no sound file configured return defaultSoundFile; } // Map urgency levels to sound file keys (when separate sounds is enabled) let soundKey; switch (urgency) { case 0: soundKey = "lowSoundFile"; break; case 1: soundKey = "normalSoundFile"; break; case 2: soundKey = "criticalSoundFile"; break; default: // Default to normal urgency for invalid values soundKey = "normalSoundFile"; break; } const soundFile = settings[soundKey]; if (soundFile && soundFile.trim() !== "") { return soundFile; } // Return default sound file if none configured for this urgency level return defaultSoundFile; } function updatePopup(internalId, notification, data) { const index = findPopupIndex(internalId); if (index < 0) return; const existing = popupModel.get(index); const oldTimestamp = existing.timestamp; const oldProgress = existing.progress; // Update properties (keeping original timestamp and progress) popupModel.setProperty(index, "summary", data.summary); popupModel.setProperty(index, "summaryMarkdown", data.summaryMarkdown); popupModel.setProperty(index, "body", data.body); popupModel.setProperty(index, "bodyMarkdown", data.bodyMarkdown); popupModel.setProperty(index, "appName", data.appName); popupModel.setProperty(index, "urgency", data.urgency); popupModel.setProperty(index, "expireTimeout", data.expireTimeout); popupModel.setProperty(index, "originalImage", data.originalImage); popupModel.setProperty(index, "cachedImage", data.cachedImage); popupModel.setProperty(index, "actionsJson", data.actionsJson); popupModel.setProperty(index, "timestamp", oldTimestamp); popupModel.setProperty(index, "progress", oldProgress); // Update stored notification object const notifData = popupState[internalId]; notifData.notification = notification; // Deep copy actions to preserve them even if QML object clears list var safeActions = []; if (notification.actions) { for (var i = 0; i < notification.actions.length; i++) { safeActions.push({ "identifier": notification.actions[i].identifier, "actionObject": notification.actions[i] }); } } notifData.cachedActions = safeActions; notifData.metadata.originalId = data.originalId; notification.tracked = true; function onClosed() { dismissPopup(internalId); } notification.closed.connect(onClosed); notifData.onClosed = onClosed; // Update metadata notifData.metadata.urgency = data.urgency; notifData.metadata.duration = calculateDuration(data); } function addPopup(quickshellId, notification, data) { // Map IDs quickshellIdToInternalId[quickshellId] = data.id; // Create watcher const watcher = notificationWatcherComponent.createObject(root, { "targetNotification": notification, "targetDataId": data.id }); // Deep copy actions var safeActions = []; if (notification.actions) { for (var i = 0; i < notification.actions.length; i++) { safeActions.push({ "identifier": notification.actions[i].identifier, "actionObject": notification.actions[i] }); } } // Store notification data popupState[data.id] = { "notification": notification, "watcher": watcher, "cachedActions": safeActions // Cache actions , "metadata": { "originalId": data.originalId // Store original ID , "timestamp": data.timestamp.getTime(), "duration": calculateDuration(data), "urgency": data.urgency, "paused": false, "pauseTime": 0 } }; notification.tracked = true; function onClosed() { dismissPopup(data.id); } notification.closed.connect(onClosed); popupState[data.id].onClosed = onClosed; // Defer list insertion to prevent re-entrant QML incubation crash. // Direct insert triggers Repeater.modelUpdated synchronously, which // incubates delegates whose signal handlers can re-enter the V4 engine // and crash in QV4::Object::insertMember. Qt.callLater(() => { popupModel.insert(0, data); // Remove overflow while (popupModel.count > maxPopups) { const last = popupModel.get(popupModel.count - 1); // Overflow only removes from ACTIVE view, but keeps it for history popupState[last.id]?.notification?.dismiss(); // Visually dismiss popupModel.remove(popupModel.count - 1); // DO NOT call cleanupNotification here, we want to keep it for history actions } }); } function findDuplicateNotification(data) { const contentId = getContentId(data.summary, data.body, data.appName); for (var i = 0; i < popupModel.count; i++) { const existing = popupModel.get(i); const existingContentId = getContentId(existing.summary, existing.body, existing.appName); if (existingContentId === contentId) { return existing.id; } } return null; } function calculateDuration(data) { const durations = [Settings.data.notifications?.lowUrgencyDuration * 1000 || 3000, Settings.data.notifications?.normalUrgencyDuration * 1000 || 8000, Settings.data.notifications?.criticalUrgencyDuration * 1000 || 15000]; if (Settings.data.notifications?.respectExpireTimeout) { if (data.expireTimeout === 0) return -1; // Never expire if (data.expireTimeout > 0) return data.expireTimeout; } return durations[data.urgency]; } function createData(n) { const time = new Date(); const id = Checksum.sha256(JSON.stringify({ "summary": n.summary, "body": n.body, "app": n.appName, "time": time.getTime() })); const image = n.image || getIcon(n.appIcon); const imageId = generateImageId(n, image); queueImage(image, n.appName || "", n.summary || "", id); return { "id": id, "summary": processNotificationText(n.summary || ""), "summaryMarkdown": processNotificationMarkdown(n.summary || ""), "body": processNotificationText(n.body || ""), "bodyMarkdown": processNotificationMarkdown(n.body || ""), "appName": getAppName(n.appName || n.desktopEntry || ""), "urgency": n.urgency < 0 || n.urgency > 2 ? 1 : n.urgency, "expireTimeout": n.expireTimeout, "timestamp": time, "progress": 1.0, "originalImage": image, "cachedImage": image // Start with original, update when cached , "originalId": n.originalId || n.id || 0 // Ensure originalId is passed through , "actionsJson": JSON.stringify((n.actions || []).map(a => ({ "text": (a.text || "").trim() || "Action", "identifier": a.identifier || "" }))) }; } function findPopupIndex(internalId) { for (var i = 0; i < popupModel.count; i++) { if (popupModel.get(i).id === internalId) { return i; } } return -1; } function updateNotificationFromObject(internalId) { const notifData = popupState[internalId]; if (!notifData) return; const index = findPopupIndex(internalId); if (index < 0) return; const data = createData(notifData.notification); const existing = popupModel.get(index); // Update properties (keeping timestamp and progress) popupModel.setProperty(index, "summary", data.summary); popupModel.setProperty(index, "summaryMarkdown", data.summaryMarkdown); popupModel.setProperty(index, "body", data.body); popupModel.setProperty(index, "bodyMarkdown", data.bodyMarkdown); popupModel.setProperty(index, "appName", data.appName); popupModel.setProperty(index, "urgency", data.urgency); popupModel.setProperty(index, "expireTimeout", data.expireTimeout); popupModel.setProperty(index, "originalImage", data.originalImage); popupModel.setProperty(index, "cachedImage", data.cachedImage); popupModel.setProperty(index, "actionsJson", data.actionsJson); // Update metadata notifData.metadata.urgency = data.urgency; notifData.metadata.duration = calculateDuration(data); } function removePopup(id) { const index = findPopupIndex(id); if (index >= 0) { popupModel.remove(index); } cleanupNotification(id); } function cleanupNotification(id) { const notifData = popupState[id]; if (notifData) { notifData.watcher?.destroy(); delete popupState[id]; } // Clean up quickshell ID mapping for (const qsId in quickshellIdToInternalId) { if (quickshellIdToInternalId[qsId] === id) { delete quickshellIdToInternalId[qsId]; break; } } } // Progress updates Timer { interval: 50 repeat: true running: popupModel.count > 0 onTriggered: updateAllProgress() } function updateAllProgress() { const now = Date.now(); const toRemove = []; for (var i = 0; i < popupModel.count; i++) { const notif = popupModel.get(i); const notifData = popupState[notif.id]; if (!notifData) continue; const meta = notifData.metadata; if (meta.duration === -1 || meta.paused) continue; const elapsed = now - meta.timestamp; const progress = Math.max(1.0 - (elapsed / meta.duration), 0.0); if (progress <= 0) { toRemove.push(notif.id); } else if (Math.abs(notif.progress - progress) > 0.005) { popupModel.setProperty(i, "progress", progress); } } if (toRemove.length > 0) { animateAndRemove(toRemove[0]); } } // Image handling function queueImage(path, appName, summary, notificationId) { if (!path || !notificationId) return; // Cache image:// URIs and temporary file paths (e.g. /tmp/ from Chromium) const filePath = path.startsWith("file://") ? path.substring(7) : path; const isImageUri = path.startsWith("image://"); const isTempFile = (path.startsWith("/") || path.startsWith("file://")) && filePath.startsWith("/tmp/"); if (!isImageUri && !isTempFile) return; ImageCacheService.getNotificationIcon(path, appName, summary, function (cachedPath, success) { if (success && cachedPath) { updateImagePath(notificationId, "file://" + cachedPath); } }); } function updateImagePath(notificationId, path) { updateModel(popupModel, notificationId, "cachedImage", path); updateModel(historyModel, notificationId, "cachedImage", path); saveHistory(); } function updateModel(model, notificationId, prop, value) { for (var i = 0; i < model.count; i++) { if (model.get(i).id === notificationId) { model.setProperty(i, prop, value); break; } } } // History management function trySaveToHistory(data, notification) { if (notification.transient) return; const s = Settings.data.notifications?.saveToHistory; if (s) { let ok = true; if (data.urgency === 0) ok = s.low !== false; else if (data.urgency === 1) ok = s.normal !== false; else if (data.urgency === 2) ok = s.critical !== false; if (ok) addToHistory(data); } else { addToHistory(data); } } function addToHistory(data) { // Defer list insertion to prevent re-entrant QML incubation crash. // See addPopup for full explanation. Qt.callLater(() => { historyModel.insert(0, data); while (historyModel.count > maxHistory) { const old = historyModel.get(historyModel.count - 1); // Only delete cached images that are in our cache directory const cachedPath = old.cachedImage ? old.cachedImage.replace(/^file:\/\//, "") : ""; if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) { Quickshell.execDetached(["rm", "-f", cachedPath]); } historyModel.remove(historyModel.count - 1); } saveHistory(); }); } // Persistence - History FileView { id: historyFileView path: historyFile printErrors: false onLoaded: loadHistory() onLoadFailed: error => { if (error === 2) writeAdapter(); } JsonAdapter { id: adapter property var notifications: [] } } Timer { id: saveTimer interval: 200 onTriggered: performSaveHistory() } function saveHistory() { saveTimer.restart(); } function performSaveHistory() { try { const items = []; for (var i = 0; i < historyModel.count; i++) { const n = historyModel.get(i); const copy = Object.assign({}, n); copy.timestamp = n.timestamp.getTime(); items.push(copy); } adapter.notifications = items; historyFileView.writeAdapter(); } catch (e) { Logger.e("Notifications", "Save history failed:", e); } } function loadHistory() { try { historyModel.clear(); for (const item of adapter.notifications || []) { const time = new Date(item.timestamp); // Use the cached image if it exists and starts with file://, otherwise use originalImage let cachedImage = item.cachedImage || ""; if (!cachedImage || (!cachedImage.startsWith("file://") && !cachedImage.startsWith("/"))) { cachedImage = item.originalImage || ""; } historyModel.append({ "id": item.id || "", "summary": item.summary || "", "summaryMarkdown": processNotificationMarkdown(item.summary || ""), "body": item.body || "", "bodyMarkdown": processNotificationMarkdown(item.body || ""), "appName": item.appName || "", "urgency": item.urgency < 0 || item.urgency > 2 ? 1 : item.urgency, "timestamp": time, "originalImage": item.originalImage || "", "cachedImage": cachedImage, "actionsJson": item.actionsJson || "[]", "originalId": item.originalId || 0 }); } } catch (e) { Logger.e("Notifications", "Load failed:", e); } } function loadState() { try { const notifState = ShellState.getNotificationsState(); root.lastSeenTs = notifState.lastSeenTs || 0; // Migration is now handled in Settings.qml Logger.d("Notifications", "Loaded state from ShellState"); } catch (e) { Logger.e("Notifications", "Load state failed:", e); } } function saveState() { try { ShellState.setNotificationsState({ lastSeenTs: root.lastSeenTs }); Logger.d("Notifications", "Saved state to ShellState"); } catch (e) { Logger.e("Notifications", "Save state failed:", e); } } function updateLastSeenTs() { root.lastSeenTs = Time.timestamp * 1000; saveState(); } // Utility functions function getAppName(name) { if (!name || name.trim() === "") return "Unknown"; name = name.trim(); if (name.includes(".") && (name.startsWith("com.") || name.startsWith("org.") || name.startsWith("io.") || name.startsWith("net."))) { const parts = name.split("."); let appPart = parts[parts.length - 1]; if (!appPart || appPart === "app" || appPart === "desktop") { appPart = parts[parts.length - 2] || parts[0]; } if (appPart) name = appPart; } if (name.includes(".")) { const parts = name.split("."); let displayName = parts[parts.length - 1]; if (!displayName || /^\d+$/.test(displayName)) { displayName = parts[parts.length - 2] || parts[0]; } if (displayName) { displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1); displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2'); displayName = displayName.replace(/app$/i, '').trim(); displayName = displayName.replace(/desktop$/i, '').trim(); displayName = displayName.replace(/flatpak$/i, '').trim(); if (!displayName) { displayName = parts[parts.length - 1].charAt(0).toUpperCase() + parts[parts.length - 1].slice(1); } } return displayName || name; } let displayName = name.charAt(0).toUpperCase() + name.slice(1); displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2'); displayName = displayName.replace(/app$/i, '').trim(); displayName = displayName.replace(/desktop$/i, '').trim(); return displayName || name; } function getIcon(icon) { if (!icon) return ""; if (icon.startsWith("/") || icon.startsWith("file://")) return icon; if (!ThemeIcons.iconExists(icon)) return ""; return ThemeIcons.iconFromName(icon); } function escapeHtml(text) { if (!text) return ""; return text.replace(/&/g, "&").replace(//g, ">"); } function sanitizeMarkdownUrl(url) { if (!url) return ""; const trimmed = url.trim(); if (trimmed === "") return ""; const lower = trimmed.toLowerCase(); if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("mailto:")) { return encodeURI(trimmed); } return ""; } function sanitizeMarkdown(text) { if (!text) return ""; let input = String(text); // Strip images entirely input = input.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (match, alt) { return alt ? alt : ""; }); // Extract links into placeholders const links = []; input = input.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (match, label, urlAndTitle) { const urlPart = (urlAndTitle || "").trim().split(/\s+/)[0] || ""; const safeUrl = sanitizeMarkdownUrl(urlPart); const safeLabel = escapeHtml(label); if (!safeUrl) return safeLabel; const token = "__MDLINK_" + links.length + "__"; links.push({ "label": safeLabel, "url": safeUrl }); return token; }); // Escape any remaining HTML input = escapeHtml(input); // Restore sanitized links for (let i = 0; i < links.length; i++) { const token = "__MDLINK_" + i + "__"; const link = links[i]; input = input.split(token).join("[" + link.label + "](" + link.url + ")"); } return input; } function processNotificationText(text) { if (!text) return ""; // Split by tags to process segments separately const parts = text.split(/(<[^>]+>)/); let result = ""; const allowedTags = ["b", "i", "u", "a", "br"]; for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (part.startsWith("<") && part.endsWith(">")) { const content = part.substring(1, part.length - 1); const firstWord = content.split(/[\s/]/).filter(s => s.length > 0)[0]?.toLowerCase(); if (allowedTags.includes(firstWord)) { // Preserve valid HTML tag result += part; } else { // Unknown tag: drop tag without leaking attributes result += ""; } } else { // Normal text: escape everything result += escapeHtml(part); } } return result; } function processNotificationMarkdown(text) { return sanitizeMarkdown(text); } function generateImageId(notification, image) { if (image && image.startsWith("image://")) { if (image.startsWith("image://qsimage/")) { const key = (notification.appName || "") + "|" + (notification.summary || ""); return Checksum.sha256(key); } return Checksum.sha256(image); } return ""; } function pauseTimeout(id) { const notifData = popupState[id]; if (notifData && !notifData.metadata.paused) { notifData.metadata.paused = true; notifData.metadata.pauseTime = Date.now(); } } function resumeTimeout(id) { const notifData = popupState[id]; if (notifData && notifData.metadata.paused) { notifData.metadata.timestamp += Date.now() - notifData.metadata.pauseTime; notifData.metadata.paused = false; } } // Dismiss a popup notification (e.g. clicked close, swipe, or overflow). // Removes from popup list but KEEPS data for history. function dismissPopup(id) { const index = findPopupIndex(id); if (index >= 0) { popupModel.remove(index); } } function dismissOldestPopup() { if (popupModel.count > 0) { const lastNotif = popupModel.get(popupModel.count - 1); dismissPopup(lastNotif.id); } } function dismissAllPopups() { for (const id in popupState) { popupState[id].notification?.dismiss(); popupState[id].watcher?.destroy(); } popupModel.clear(); popupState = {}; quickshellIdToInternalId = {}; } function invokeActionAndSuppressClose(id, actionId) { const notifData = popupState[id]; const notification = notifData?.notification; const onClosed = notifData?.onClosed; let restoreClosedHandler = false; if (notification && onClosed) { try { // A successful action may synchronously close the notification. Disconnect // our close handler first so the popup is only dismissed by the action path. notification.closed.disconnect(onClosed); restoreClosedHandler = true; } catch (e) {} } const invoked = invokeAction(id, actionId); if (!invoked && restoreClosedHandler && notification && onClosed) { try { // If invoking the action failed, restore normal close handling for this popup. notification.closed.connect(onClosed); } catch (e) {} } return invoked; } function invokeAction(id, actionId) { let invoked = false; const notifData = popupState[id]; if (notifData && notifData.notification) { const actionsToUse = (notifData.notification.actions && notifData.notification.actions.length > 0) ? notifData.notification.actions : (notifData.cachedActions || []); if (actionsToUse && actionsToUse.length > 0) { for (const item of actionsToUse) { const itemId = item.identifier; const actionObj = item.actionObject ? item.actionObject : item; if (itemId === actionId) { if (actionObj.invoke) { try { actionObj.invoke(); invoked = true; } catch (e) { Logger.w("NotificationService", "invoke() failed, trying manual fallback: " + e); if (manualInvoke(notifData.metadata.originalId, itemId)) { invoked = true; } } } else { if (manualInvoke(notifData.metadata.originalId, itemId)) { invoked = true; } } break; } } } if (!invoked && notifData.metadata.originalId) { Logger.w("NotificationService", "Action objects exhausted, trying manual invoke for id=" + id + " action=" + actionId); invoked = manualInvoke(notifData.metadata.originalId, actionId); } } else if (!notifData) { Logger.w("NotificationService", "No active notification data for id=" + id + ", searching history for manual invoke"); for (var i = 0; i < historyModel.count; i++) { if (historyModel.get(i).id === id) { const histEntry = historyModel.get(i); if (histEntry.originalId) { invoked = manualInvoke(histEntry.originalId, actionId); } break; } } } if (!invoked) { Logger.w("NotificationService", "Failed to invoke action '" + actionId + "' for notification " + id); return false; } // Clear actions after use updateModel(popupModel, id, "actionsJson", "[]"); updateModel(historyModel, id, "actionsJson", "[]"); saveHistory(); return true; } function manualInvoke(originalId, actionId) { if (!originalId) { return false; } try { // Construct the signal emission using dbus-send // dbus-send --session --type=signal /org/freedesktop/Notifications org.freedesktop.Notifications.ActionInvoked uint32:ID string:"KEY" const args = ["dbus-send", "--session", "--type=signal", "/org/freedesktop/Notifications", "org.freedesktop.Notifications.ActionInvoked", "uint32:" + originalId, "string:" + actionId]; Quickshell.execDetached(args); return true; } catch (e) { Logger.e("NotificationService", "Manual invoke failed: " + e); return false; } } function focusSenderWindow(appName) { if (!appName || appName === "" || appName === "Unknown") return false; const normalizedName = appName.toLowerCase().replace(/\s+/g, ""); for (var i = 0; i < CompositorService.windows.count; i++) { const win = CompositorService.windows.get(i); const winAppId = (win.appId || "").toLowerCase(); const segments = winAppId.split("."); const lastSegment = segments[segments.length - 1] || ""; if (winAppId === normalizedName || lastSegment === normalizedName || winAppId.includes(normalizedName) || normalizedName.includes(lastSegment)) { CompositorService.focusWindow(win); return true; } } Logger.d("NotificationService", "No window found for app: " + appName); return false; } function removeFromHistory(notificationId) { for (var i = 0; i < historyModel.count; i++) { const notif = historyModel.get(i); if (notif.id === notificationId) { // Only delete cached images that are in our cache directory const cachedPath = notif.cachedImage ? notif.cachedImage.replace(/^file:\/\//, "") : ""; if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) { Quickshell.execDetached(["rm", "-f", cachedPath]); } historyModel.remove(i); saveHistory(); return true; } } return false; } function removeOldestHistory() { if (historyModel.count > 0) { const oldest = historyModel.get(historyModel.count - 1); // Only delete cached images that are in our cache directory const cachedPath = oldest.cachedImage ? oldest.cachedImage.replace(/^file:\/\//, "") : ""; if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) { Quickshell.execDetached(["rm", "-f", cachedPath]); } historyModel.remove(historyModel.count - 1); saveHistory(); return true; } return false; } function clearHistory() { try { Quickshell.execDetached(["sh", "-c", `rm -rf "${ImageCacheService.notificationsDir}"*`]); } catch (e) { Logger.e("Notifications", "Failed to clear cache directory:", e); } historyModel.clear(); saveHistory(); } function getHistorySnapshot() { const items = []; for (var i = 0; i < historyModel.count; i++) { const entry = historyModel.get(i); items.push({ "id": entry.id, "summary": entry.summary, "body": entry.body, "appName": entry.appName, "urgency": entry.urgency, "timestamp": entry.timestamp instanceof Date ? entry.timestamp.getTime() : entry.timestamp, "originalImage": entry.originalImage, "cachedImage": entry.cachedImage }); } return items; } // Signals signal animateAndRemove(string notificationId) onDoNotDisturbChanged: { ToastService.showNotice(doNotDisturb ? I18n.tr("toast.do-not-disturb.enabled") : I18n.tr("toast.do-not-disturb.disabled"), doNotDisturb ? I18n.tr("toast.do-not-disturb.enabled-desc") : I18n.tr("toast.do-not-disturb.disabled-desc"), doNotDisturb ? "bell-off" : "bell"); } // Media toast functionality property string previousMediaTitle: "" property string previousMediaArtist: "" property bool previousMediaIsPlaying: false property bool mediaToastInitialized: false Timer { id: mediaToastInitTimer interval: 3000 // Wait 3 seconds after startup to avoid initial toast running: true onTriggered: { root.mediaToastInitialized = true; root.previousMediaTitle = MediaService.trackTitle; root.previousMediaArtist = MediaService.trackArtist; root.previousMediaIsPlaying = MediaService.isPlaying; } } Timer { id: mediaToastDebounce interval: 250 // Dynamic interval based on player onTriggered: { checkMediaToast(); } } function checkMediaToast() { if (!Settings.data.notifications.enableMediaToast || !mediaToastInitialized) return; if (doNotDisturb || PowerProfileService.noctaliaPerformanceMode) return; // Re-evaluate player identity here to handle race conditions where // the identity wasn't updated yet when the timer started. const player = (MediaService.playerIdentity || "").toLowerCase(); const browsers = ["firefox", "chromium", "chrome", "brave", "edge", "opera", "vivaldi", "zen"]; const isBrowser = browsers.some(b => player.includes(b)); // Safety check: If it's a browser, ensure we waited long enough. // If we started with a short interval (e.g. 250ms because we thought it was Spotify), // correct it now and wait the full duration. if (isBrowser && mediaToastDebounce.interval < 1500) { mediaToastDebounce.interval = 1500; mediaToastDebounce.restart(); return; } const title = MediaService.trackTitle || ""; const artist = MediaService.trackArtist || ""; const isPlaying = MediaService.isPlaying; // Only show toast if something meaningful changed const titleChanged = title !== previousMediaTitle && title !== ""; const playStateChanged = isPlaying !== previousMediaIsPlaying; const hasMedia = title !== "" || artist !== ""; // Browser Specific Logic: // If a browser reports a new title but is PAUSED, ignore it. if (isBrowser && !isPlaying && titleChanged) { previousMediaTitle = title; previousMediaArtist = artist; previousMediaIsPlaying = isPlaying; return; } if (hasMedia && (titleChanged || playStateChanged)) { const icon = isPlaying ? "media-play" : "media-pause"; let message = ""; if (artist && title) { message = artist + " — " + title; } else if (title) { message = title; } else if (artist) { message = artist; } if (message !== "") { const toastTitle = isPlaying ? I18n.tr("common.play") : I18n.tr("common.pause"); ToastService.showNotice(toastTitle, message, icon, 3000); } } previousMediaTitle = title; previousMediaArtist = artist; previousMediaIsPlaying = isPlaying; } Connections { target: MediaService function onTrackTitleChanged() { restartDebounce(); } function onTrackArtistChanged() { restartDebounce(); } function onIsPlayingChanged() { restartDebounce(); } function onPlayerIdentityChanged() { restartDebounce(); } } function restartDebounce() { const player = (MediaService.playerIdentity || "").toLowerCase(); const browsers = ["firefox", "chromium", "chrome", "brave", "edge", "opera", "vivaldi"]; const isBrowser = browsers.some(b => player.includes(b)); // Use long delay for browsers to filter hover previews, short for music apps mediaToastDebounce.interval = isBrowser ? 1500 : 250; mediaToastDebounce.restart(); } }