Files
2026-04-10 19:02:07 +07:00

1242 lines
39 KiB
QML

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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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();
}
}