mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-04 19:42:08 -04:00
1286 lines
41 KiB
QML
1286 lines
41 KiB
QML
pragma Singleton
|
||
pragma ComponentBehavior: Bound
|
||
|
||
import QtQuick
|
||
import Quickshell
|
||
import Quickshell.Io
|
||
import Quickshell.Services.Notifications
|
||
import qs.Common
|
||
import qs.Services
|
||
import "../Common/markdown2html.js" as Markdown2Html
|
||
|
||
Singleton {
|
||
id: root
|
||
readonly property var log: Log.scoped("NotificationService")
|
||
|
||
readonly property list<NotifWrapper> notifications: []
|
||
readonly property list<NotifWrapper> allWrappers: []
|
||
readonly property list<NotifWrapper> popups: allWrappers.filter(n => n && n.popup)
|
||
|
||
property var historyList: []
|
||
readonly property string historyFile: Paths.strip(Paths.cache) + "/notification_history.json"
|
||
readonly property string imageCacheDir: Paths.strip(Paths.cache) + "/notification_images"
|
||
property bool historyLoaded: false
|
||
property int historyEntryCounter: 0
|
||
|
||
property list<NotifWrapper> notificationQueue: []
|
||
property list<NotifWrapper> visibleNotifications: []
|
||
property int maxVisibleNotifications: 4
|
||
property bool addGateBusy: false
|
||
property int enterAnimMs: 400
|
||
property int seqCounter: 0
|
||
property bool bulkDismissing: false
|
||
|
||
property int maxQueueSize: 32
|
||
property int maxIngressPerSecond: 20
|
||
property double _lastIngressSec: 0
|
||
property int _ingressCountThisSec: 0
|
||
|
||
property var _dismissQueue: []
|
||
property int _dismissBatchSize: 8
|
||
property int _dismissTickMs: 8
|
||
property bool _suspendGrouping: false
|
||
property var _groupCache: ({
|
||
"notifications": [],
|
||
"popups": []
|
||
})
|
||
property bool _groupsDirty: false
|
||
|
||
Component.onCompleted: {
|
||
_recomputeGroups();
|
||
Quickshell.execDetached(["mkdir", "-p", Paths.strip(Paths.cache)]);
|
||
Quickshell.execDetached(["mkdir", "-p", imageCacheDir]);
|
||
}
|
||
|
||
FileView {
|
||
id: historyFileView
|
||
path: root.historyFile
|
||
printErrors: false
|
||
onLoaded: root.loadHistory()
|
||
onLoadFailed: error => {
|
||
if (error === 2) {
|
||
root.historyLoaded = true;
|
||
historyFileView.writeAdapter();
|
||
}
|
||
}
|
||
|
||
JsonAdapter {
|
||
id: historyAdapter
|
||
property var notifications: []
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
id: historySaveTimer
|
||
interval: 200
|
||
onTriggered: root.performSaveHistory()
|
||
}
|
||
|
||
function _makeHistoryEntryId(sourceId, timestamp) {
|
||
historyEntryCounter += 1;
|
||
const safeSource = sourceId && sourceId !== "" ? sourceId : "notification";
|
||
return safeSource + "_" + (timestamp || Date.now()) + "_" + historyEntryCounter;
|
||
}
|
||
|
||
function getImageCachePath(wrapper) {
|
||
const ts = wrapper.time ? wrapper.time.getTime() : Date.now();
|
||
const id = wrapper.notification?.id?.toString() || "0";
|
||
return imageCacheDir + "/notif_" + ts + "_" + id + ".png";
|
||
}
|
||
|
||
function updateHistoryImage(wrapperId, imagePath) {
|
||
const idx = historyList.findIndex(n => n.sourceNotificationId === wrapperId || n.id === wrapperId);
|
||
if (idx < 0)
|
||
return;
|
||
const item = historyList[idx];
|
||
const updated = {
|
||
id: item.id,
|
||
sourceNotificationId: item.sourceNotificationId || item.id,
|
||
summary: item.summary,
|
||
body: item.body,
|
||
htmlBody: item.htmlBody,
|
||
appName: item.appName,
|
||
appIcon: item.appIcon,
|
||
image: "file://" + imagePath,
|
||
urgency: item.urgency,
|
||
timestamp: item.timestamp,
|
||
desktopEntry: item.desktopEntry
|
||
};
|
||
const newList = historyList.slice();
|
||
newList[idx] = updated;
|
||
historyList = newList;
|
||
saveHistory();
|
||
}
|
||
|
||
function addToHistory(wrapper) {
|
||
if (!wrapper)
|
||
return;
|
||
const urg = typeof wrapper.urgency === "number" ? wrapper.urgency : 1;
|
||
const imageUrl = wrapper.image || "";
|
||
let persistableImage = "";
|
||
if (wrapper.persistedImagePath) {
|
||
persistableImage = "file://" + wrapper.persistedImagePath;
|
||
} else if (imageUrl && !imageUrl.startsWith("image://qsimage/")) {
|
||
persistableImage = imageUrl;
|
||
}
|
||
const sourceNotificationId = wrapper.notification?.id?.toString() || "";
|
||
const timestamp = wrapper.time.getTime();
|
||
const data = {
|
||
id: _makeHistoryEntryId(sourceNotificationId, timestamp),
|
||
sourceNotificationId: sourceNotificationId,
|
||
summary: wrapper.summary || "",
|
||
body: wrapper.body || "",
|
||
htmlBody: wrapper.htmlBody || wrapper.body || "",
|
||
appName: wrapper.appName || "",
|
||
appIcon: wrapper.appIcon || "",
|
||
image: persistableImage,
|
||
urgency: urg,
|
||
timestamp: timestamp,
|
||
desktopEntry: wrapper.desktopEntry || ""
|
||
};
|
||
let newList = [data, ...historyList];
|
||
if (newList.length > SettingsData.notificationHistoryMaxCount) {
|
||
newList = newList.slice(0, SettingsData.notificationHistoryMaxCount);
|
||
}
|
||
historyList = newList;
|
||
saveHistory();
|
||
}
|
||
|
||
function saveHistory() {
|
||
historySaveTimer.restart();
|
||
}
|
||
|
||
function performSaveHistory() {
|
||
try {
|
||
historyAdapter.notifications = historyList;
|
||
historyFileView.writeAdapter();
|
||
} catch (e) {
|
||
log.warn("save history failed:", e);
|
||
}
|
||
}
|
||
|
||
function loadHistory() {
|
||
try {
|
||
const maxAgeDays = SettingsData.notificationHistoryMaxAgeDays;
|
||
const now = Date.now();
|
||
const maxAgeMs = maxAgeDays > 0 ? maxAgeDays * 24 * 60 * 60 * 1000 : 0;
|
||
const loaded = [];
|
||
const seenIds = {};
|
||
let needsRewrite = false;
|
||
|
||
for (const item of historyAdapter.notifications || []) {
|
||
if (maxAgeMs > 0 && (now - item.timestamp) > maxAgeMs)
|
||
continue;
|
||
const urg = typeof item.urgency === "number" ? item.urgency : 1;
|
||
const body = item.body || "";
|
||
let htmlBody = item.htmlBody || _resolveHtmlBody(body);
|
||
if (htmlBody) {
|
||
htmlBody = htmlBody.replace(/<img\b[^>]*>/gi, "");
|
||
}
|
||
const sourceNotificationId = (item.sourceNotificationId || item.id || "").toString();
|
||
let historyId = (item.id || "").toString();
|
||
if (!historyId || seenIds[historyId]) {
|
||
historyId = _makeHistoryEntryId(sourceNotificationId, item.timestamp || now);
|
||
needsRewrite = true;
|
||
}
|
||
if (!item.sourceNotificationId)
|
||
needsRewrite = true;
|
||
seenIds[historyId] = true;
|
||
loaded.push({
|
||
id: historyId,
|
||
sourceNotificationId: sourceNotificationId,
|
||
summary: item.summary || "",
|
||
body: body,
|
||
htmlBody: htmlBody,
|
||
appName: item.appName || "",
|
||
appIcon: item.appIcon || "",
|
||
image: item.image || "",
|
||
urgency: urg,
|
||
timestamp: item.timestamp || 0,
|
||
desktopEntry: item.desktopEntry || ""
|
||
});
|
||
}
|
||
historyList = loaded;
|
||
historyLoaded = true;
|
||
if ((maxAgeMs > 0 && loaded.length !== (historyAdapter.notifications || []).length) || needsRewrite)
|
||
saveHistory();
|
||
} catch (e) {
|
||
log.warn("load history failed:", e);
|
||
historyLoaded = true;
|
||
}
|
||
}
|
||
|
||
function _deleteCachedImage(imagePath) {
|
||
if (!imagePath || !imagePath.startsWith("file://"))
|
||
return;
|
||
const filePath = imagePath.replace("file://", "");
|
||
if (filePath.startsWith(imageCacheDir)) {
|
||
Quickshell.execDetached(["rm", "-f", filePath]);
|
||
}
|
||
}
|
||
|
||
function removeFromHistory(notificationId) {
|
||
const idx = historyList.findIndex(n => n.id === notificationId);
|
||
if (idx >= 0) {
|
||
_deleteCachedImage(historyList[idx].image);
|
||
historyList = historyList.filter((_, i) => i !== idx);
|
||
saveHistory();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function clearHistory() {
|
||
for (const item of historyList) {
|
||
_deleteCachedImage(item.image);
|
||
}
|
||
historyList = [];
|
||
saveHistory();
|
||
}
|
||
|
||
function getHistoryTimeRange(timestamp) {
|
||
const now = new Date();
|
||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||
const itemDate = new Date(timestamp);
|
||
const itemDay = new Date(itemDate.getFullYear(), itemDate.getMonth(), itemDate.getDate());
|
||
const diffDays = Math.floor((today - itemDay) / (1000 * 60 * 60 * 24));
|
||
if (diffDays === 0)
|
||
return 0;
|
||
if (diffDays === 1)
|
||
return 1;
|
||
return 2;
|
||
}
|
||
|
||
function getHistoryCountForRange(range) {
|
||
if (range === -1)
|
||
return historyList.length;
|
||
return historyList.filter(n => getHistoryTimeRange(n.timestamp) === range).length;
|
||
}
|
||
|
||
function formatHistoryTime(timestamp) {
|
||
root.timeUpdateTick;
|
||
root.clockFormatChanged;
|
||
const now = new Date();
|
||
const date = new Date(timestamp);
|
||
const diff = now.getTime() - timestamp;
|
||
const minutes = Math.floor(diff / 60000);
|
||
const hours = Math.floor(minutes / 60);
|
||
if (hours < 1) {
|
||
if (minutes < 1)
|
||
return I18n.tr("now");
|
||
return I18n.tr("%1m ago").arg(minutes);
|
||
}
|
||
const nowDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||
const itemDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||
const daysDiff = Math.floor((nowDate - itemDate) / (1000 * 60 * 60 * 24));
|
||
const timeStr = SettingsData.use24HourClock ? date.toLocaleTimeString(Qt.locale(), "HH:mm") : date.toLocaleTimeString(Qt.locale(), "h:mm AP");
|
||
if (daysDiff === 0)
|
||
return timeStr;
|
||
try {
|
||
const localeName = (typeof I18n !== "undefined" && I18n.locale) ? I18n.locale().name : "en-US";
|
||
const weekday = date.toLocaleDateString(localeName, {
|
||
weekday: "long"
|
||
});
|
||
return weekday + ", " + timeStr;
|
||
} catch (e) {
|
||
return timeStr;
|
||
}
|
||
}
|
||
|
||
function _nowSec() {
|
||
return Date.now() / 1000.0;
|
||
}
|
||
|
||
function _notificationDedupKey(source) {
|
||
if (!source)
|
||
return "";
|
||
const app = (source.appName || source.desktopEntry || "").toString();
|
||
const summary = (source.summary || "").toString();
|
||
const body = (source.body || "").toString();
|
||
const urgency = typeof source.urgency === "number" ? source.urgency : NotificationUrgency.Normal;
|
||
const icon = (source.appIcon || "").toString();
|
||
if (!app && !summary && !body)
|
||
return "";
|
||
const sep = "";
|
||
return app + sep + summary + sep + body + sep + urgency + sep + icon;
|
||
}
|
||
|
||
function _findActiveDuplicate(notif) {
|
||
const key = _notificationDedupKey(notif);
|
||
if (!key)
|
||
return null;
|
||
|
||
for (const w of visibleNotifications) {
|
||
if (!w || !w.notification || !w.popup)
|
||
continue;
|
||
if (_notificationDedupKey(w.notification) === key)
|
||
return w;
|
||
}
|
||
|
||
for (const w of notificationQueue) {
|
||
if (!w || !w.notification)
|
||
continue;
|
||
if (_notificationDedupKey(w.notification) === key)
|
||
return w;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function _ingressAllowed(urgency) {
|
||
const t = _nowSec();
|
||
if (t - _lastIngressSec >= 1.0) {
|
||
_lastIngressSec = t;
|
||
_ingressCountThisSec = 0;
|
||
}
|
||
_ingressCountThisSec += 1;
|
||
if (urgency === NotificationUrgency.Critical) {
|
||
return true;
|
||
}
|
||
return _ingressCountThisSec <= maxIngressPerSecond;
|
||
}
|
||
|
||
function _enqueuePopup(wrapper) {
|
||
if (notificationQueue.length >= maxQueueSize) {
|
||
const gk = getGroupKey(wrapper);
|
||
let idx = notificationQueue.findIndex(w => w && getGroupKey(w) === gk && w.urgency !== NotificationUrgency.Critical);
|
||
if (idx === -1) {
|
||
idx = notificationQueue.findIndex(w => w && w.urgency !== NotificationUrgency.Critical);
|
||
}
|
||
if (idx === -1) {
|
||
idx = 0;
|
||
}
|
||
const victim = notificationQueue[idx];
|
||
if (victim) {
|
||
victim.popup = false;
|
||
}
|
||
notificationQueue.splice(idx, 1);
|
||
}
|
||
notificationQueue = [...notificationQueue, wrapper];
|
||
}
|
||
|
||
function _initWrapperPersistence(wrapper) {
|
||
const timeoutMs = wrapper.timer ? wrapper.timer.interval : 5000;
|
||
const isCritical = wrapper && wrapper.urgency === NotificationUrgency.Critical;
|
||
wrapper.isPersistent = isCritical || (timeoutMs === 0);
|
||
}
|
||
|
||
function _shouldSaveToHistory(urgency, forceDisable) {
|
||
if (forceDisable === true)
|
||
return false;
|
||
if (!SettingsData.notificationHistoryEnabled)
|
||
return false;
|
||
switch (urgency) {
|
||
case NotificationUrgency.Low:
|
||
return SettingsData.notificationHistorySaveLow;
|
||
case NotificationUrgency.Critical:
|
||
return SettingsData.notificationHistorySaveCritical;
|
||
default:
|
||
return SettingsData.notificationHistorySaveNormal;
|
||
}
|
||
}
|
||
|
||
function _resolveAppNameForRule(notif) {
|
||
if (!notif)
|
||
return "";
|
||
if (notif.appName && notif.appName !== "")
|
||
return notif.appName;
|
||
const entry = DesktopEntries.heuristicLookup(notif.desktopEntry);
|
||
if (entry && entry.name)
|
||
return entry.name;
|
||
return "";
|
||
}
|
||
|
||
function _ruleFieldValue(field, info) {
|
||
switch ((field || "").toString()) {
|
||
case "desktopEntry":
|
||
return info.desktopEntry;
|
||
case "summary":
|
||
return info.summary;
|
||
case "body":
|
||
return info.body;
|
||
case "appName":
|
||
default:
|
||
return info.appName;
|
||
}
|
||
}
|
||
|
||
function _coerceRuleUrgency(value, fallbackUrgency) {
|
||
if (typeof value === "number" && value >= NotificationUrgency.Low && value <= NotificationUrgency.Critical)
|
||
return value;
|
||
|
||
const mapped = (value || "default").toString().toLowerCase();
|
||
switch (mapped) {
|
||
case "low":
|
||
return NotificationUrgency.Low;
|
||
case "normal":
|
||
return NotificationUrgency.Normal;
|
||
case "critical":
|
||
return NotificationUrgency.Critical;
|
||
default:
|
||
return fallbackUrgency;
|
||
}
|
||
}
|
||
|
||
function _matchesNotificationRule(rule, info) {
|
||
if (!rule)
|
||
return false;
|
||
if (rule.enabled === false)
|
||
return false;
|
||
|
||
const pattern = (rule.pattern || "").toString();
|
||
if (!pattern.trim())
|
||
return false;
|
||
|
||
const value = (_ruleFieldValue(rule.field, info) || "").toString();
|
||
const matchType = (rule.matchType || "contains").toString().toLowerCase();
|
||
|
||
if (matchType === "exact")
|
||
return value.toLowerCase() === pattern.toLowerCase();
|
||
if (matchType === "regex") {
|
||
try {
|
||
return new RegExp(pattern, "i").test(value);
|
||
} catch (e) {
|
||
log.warn("invalid notification rule regex:", pattern);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return value.toLowerCase().includes(pattern.toLowerCase());
|
||
}
|
||
|
||
function _evaluateNotificationPolicy(notif) {
|
||
const baseUrgency = typeof notif.urgency === "number" ? notif.urgency : NotificationUrgency.Normal;
|
||
const policy = {
|
||
"drop": false,
|
||
"disablePopup": false,
|
||
"hideFromCenter": false,
|
||
"disableHistory": false,
|
||
"urgency": baseUrgency
|
||
};
|
||
|
||
const rules = SettingsData.notificationRules || [];
|
||
if (!rules.length)
|
||
return policy;
|
||
|
||
const info = {
|
||
"appName": _resolveAppNameForRule(notif),
|
||
"desktopEntry": notif.desktopEntry || "",
|
||
"summary": notif.summary || "",
|
||
"body": notif.body || ""
|
||
};
|
||
|
||
for (const rule of rules) {
|
||
if (!_matchesNotificationRule(rule, info))
|
||
continue;
|
||
|
||
const action = (rule.action || "default").toString().toLowerCase();
|
||
switch (action) {
|
||
case "ignore":
|
||
policy.drop = true;
|
||
break;
|
||
case "mute":
|
||
policy.disablePopup = true;
|
||
break;
|
||
case "popup_only":
|
||
policy.hideFromCenter = true;
|
||
policy.disableHistory = true;
|
||
break;
|
||
case "no_history":
|
||
policy.disableHistory = true;
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
policy.urgency = _coerceRuleUrgency(rule.urgency, policy.urgency);
|
||
return policy;
|
||
}
|
||
|
||
return policy;
|
||
}
|
||
|
||
function pruneHistory() {
|
||
const maxAgeDays = SettingsData.notificationHistoryMaxAgeDays;
|
||
if (maxAgeDays <= 0)
|
||
return;
|
||
|
||
const now = Date.now();
|
||
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
||
const toRemove = historyList.filter(item => (now - item.timestamp) > maxAgeMs);
|
||
const pruned = historyList.filter(item => (now - item.timestamp) <= maxAgeMs);
|
||
|
||
if (pruned.length !== historyList.length) {
|
||
for (const item of toRemove) {
|
||
_deleteCachedImage(item.image);
|
||
}
|
||
historyList = pruned;
|
||
saveHistory();
|
||
}
|
||
}
|
||
|
||
function deleteHistory() {
|
||
for (const item of historyList) {
|
||
_deleteCachedImage(item.image);
|
||
}
|
||
historyList = [];
|
||
historyAdapter.notifications = [];
|
||
historyFileView.writeAdapter();
|
||
}
|
||
|
||
function onOverlayOpen() {
|
||
popupsDisabled = true;
|
||
addGate.stop();
|
||
addGateBusy = false;
|
||
|
||
notificationQueue = [];
|
||
for (const w of visibleNotifications) {
|
||
if (w) {
|
||
w.popup = false;
|
||
}
|
||
}
|
||
visibleNotifications = [];
|
||
_recomputeGroupsLater();
|
||
pruneHistory();
|
||
}
|
||
|
||
function onOverlayClose() {
|
||
popupsDisabled = false;
|
||
processQueue();
|
||
}
|
||
|
||
Timer {
|
||
id: addGate
|
||
interval: 80
|
||
running: false
|
||
repeat: false
|
||
onTriggered: {
|
||
addGateBusy = false;
|
||
processQueue();
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
id: timeUpdateTimer
|
||
interval: 30000
|
||
repeat: true
|
||
running: root.allWrappers.length > 0 || visibleNotifications.length > 0
|
||
triggeredOnStart: false
|
||
onTriggered: {
|
||
root.timeUpdateTick = !root.timeUpdateTick;
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
id: dismissPump
|
||
interval: _dismissTickMs
|
||
repeat: true
|
||
running: false
|
||
onTriggered: {
|
||
let n = Math.min(_dismissBatchSize, _dismissQueue.length);
|
||
for (var i = 0; i < n; ++i) {
|
||
const w = _dismissQueue.pop();
|
||
try {
|
||
if (w && w.notification) {
|
||
w.notification.dismiss();
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
if (_dismissQueue.length === 0) {
|
||
dismissPump.stop();
|
||
_suspendGrouping = false;
|
||
bulkDismissing = false;
|
||
popupsDisabled = false;
|
||
_recomputeGroupsLater();
|
||
}
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
id: groupsDebounce
|
||
interval: 16
|
||
repeat: false
|
||
onTriggered: _recomputeGroups()
|
||
}
|
||
|
||
property bool timeUpdateTick: false
|
||
property bool clockFormatChanged: false
|
||
|
||
readonly property var groupedNotifications: _groupCache.notifications
|
||
readonly property var groupedPopups: _groupCache.popups
|
||
|
||
property var expandedGroups: ({})
|
||
property var expandedMessages: ({})
|
||
property bool popupsDisabled: false
|
||
|
||
NotificationServer {
|
||
id: server
|
||
|
||
keepOnReload: false
|
||
actionsSupported: true
|
||
actionIconsSupported: true
|
||
bodyHyperlinksSupported: true
|
||
bodyImagesSupported: true
|
||
bodyMarkupSupported: true
|
||
imageSupported: true
|
||
inlineReplySupported: true
|
||
persistenceSupported: true
|
||
|
||
onNotification: notif => {
|
||
notif.tracked = true;
|
||
|
||
const policy = _evaluateNotificationPolicy(notif);
|
||
if (policy.drop) {
|
||
try {
|
||
notif.dismiss();
|
||
} catch (e) {}
|
||
return;
|
||
}
|
||
|
||
const duplicate = _findActiveDuplicate(notif);
|
||
if (duplicate) {
|
||
if (duplicate.timer && duplicate.timer.running)
|
||
duplicate.timer.restart();
|
||
try {
|
||
notif.dismiss();
|
||
} catch (e) {}
|
||
return;
|
||
}
|
||
|
||
if (!_ingressAllowed(policy.urgency)) {
|
||
if (policy.urgency !== NotificationUrgency.Critical) {
|
||
try {
|
||
notif.dismiss();
|
||
} catch (e) {}
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (SettingsData.soundsEnabled && SettingsData.soundNewNotification) {
|
||
if (policy.urgency === NotificationUrgency.Critical) {
|
||
AudioService.playCriticalNotificationSound();
|
||
} else {
|
||
AudioService.playNormalNotificationSound();
|
||
}
|
||
}
|
||
|
||
const shouldShowPopup = !root.popupsDisabled && !SessionData.doNotDisturb && !policy.disablePopup;
|
||
const isTransient = notif.transient;
|
||
const shouldKeepInCenter = !isTransient && !policy.hideFromCenter;
|
||
|
||
if (!shouldShowPopup && !shouldKeepInCenter) {
|
||
try {
|
||
notif.dismiss();
|
||
} catch (e) {}
|
||
return;
|
||
}
|
||
|
||
const wrapper = notifComponent.createObject(root, {
|
||
"popup": shouldShowPopup,
|
||
"notification": notif,
|
||
"urgencyOverride": policy.urgency
|
||
});
|
||
|
||
if (wrapper) {
|
||
root.allWrappers.push(wrapper);
|
||
if (shouldKeepInCenter) {
|
||
root.notifications.push(wrapper);
|
||
if (_shouldSaveToHistory(wrapper.urgency, policy.disableHistory)) {
|
||
root.addToHistory(wrapper);
|
||
}
|
||
}
|
||
Qt.callLater(() => {
|
||
_initWrapperPersistence(wrapper);
|
||
});
|
||
|
||
if (shouldShowPopup) {
|
||
_enqueuePopup(wrapper);
|
||
processQueue();
|
||
}
|
||
}
|
||
|
||
_recomputeGroupsLater();
|
||
}
|
||
}
|
||
|
||
component NotifWrapper: QtObject {
|
||
id: wrapper
|
||
|
||
property bool popup: false
|
||
property bool removedByLimit: false
|
||
property bool isPersistent: true
|
||
property int seq: 0
|
||
property string persistedImagePath: ""
|
||
|
||
onPopupChanged: {
|
||
if (!popup) {
|
||
removeFromVisibleNotifications(wrapper);
|
||
}
|
||
}
|
||
|
||
readonly property Timer timer: Timer {
|
||
interval: {
|
||
if (!wrapper.notification)
|
||
return 5000;
|
||
switch (wrapper.urgency) {
|
||
case NotificationUrgency.Low:
|
||
return SettingsData.notificationTimeoutLow;
|
||
case NotificationUrgency.Critical:
|
||
return SettingsData.notificationTimeoutCritical;
|
||
default:
|
||
return SettingsData.notificationTimeoutNormal;
|
||
}
|
||
}
|
||
repeat: false
|
||
running: false
|
||
onTriggered: {
|
||
if (interval > 0) {
|
||
wrapper.popup = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
readonly property date time: new Date()
|
||
readonly property string timeStr: {
|
||
root.timeUpdateTick;
|
||
root.clockFormatChanged;
|
||
|
||
const now = new Date();
|
||
const diff = now.getTime() - time.getTime();
|
||
const minutes = Math.floor(diff / 60000);
|
||
const hours = Math.floor(minutes / 60);
|
||
|
||
if (hours < 1) {
|
||
if (minutes < 1) {
|
||
return "now";
|
||
}
|
||
return `${minutes}m ago`;
|
||
}
|
||
|
||
const nowDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||
const timeDate = new Date(time.getFullYear(), time.getMonth(), time.getDate());
|
||
const daysDiff = Math.floor((nowDate - timeDate) / (1000 * 60 * 60 * 24));
|
||
|
||
if (daysDiff === 0) {
|
||
return formatTime(time);
|
||
}
|
||
|
||
try {
|
||
const localeName = (typeof I18n !== "undefined" && I18n.locale) ? I18n.locale().name : "en-US";
|
||
const weekday = time.toLocaleDateString(localeName, {
|
||
weekday: "long"
|
||
});
|
||
return `${weekday}, ${formatTime(time)}`;
|
||
} catch (e) {
|
||
return formatTime(time);
|
||
}
|
||
}
|
||
|
||
function formatTime(date) {
|
||
let use24Hour = true;
|
||
try {
|
||
if (typeof SettingsData !== "undefined" && SettingsData.use24HourClock !== undefined) {
|
||
use24Hour = SettingsData.use24HourClock;
|
||
}
|
||
} catch (e) {
|
||
use24Hour = true;
|
||
}
|
||
|
||
if (use24Hour) {
|
||
return date.toLocaleTimeString(Qt.locale(), "HH:mm");
|
||
} else {
|
||
return date.toLocaleTimeString(Qt.locale(), "h:mm AP");
|
||
}
|
||
}
|
||
|
||
required property Notification notification
|
||
readonly property string summary: (notification?.summary ?? "").replace(/<img\b[^>]*>/gi, "")
|
||
readonly property string body: (notification?.body ?? "").replace(/<img\b[^>]*>/gi, "")
|
||
readonly property string htmlBody: root._resolveHtmlBody(body)
|
||
readonly property string appIcon: notification?.appIcon ?? ""
|
||
readonly property string appName: {
|
||
if (!notification)
|
||
return "app";
|
||
if (notification.appName == "") {
|
||
const entry = DesktopEntries.heuristicLookup(notification.desktopEntry);
|
||
if (entry && entry.name)
|
||
return entry.name.toLowerCase();
|
||
}
|
||
return notification.appName || "app";
|
||
}
|
||
readonly property string desktopEntry: notification?.desktopEntry ?? ""
|
||
readonly property string image: notification?.image ?? ""
|
||
readonly property string cleanImage: {
|
||
if (!image)
|
||
return "";
|
||
return Paths.strip(image);
|
||
}
|
||
property int urgencyOverride: notification?.urgency ?? NotificationUrgency.Normal
|
||
readonly property int urgency: urgencyOverride
|
||
readonly property list<NotificationAction> actions: notification?.actions ?? []
|
||
|
||
readonly property Connections conn: Connections {
|
||
target: wrapper.notification?.Retainable ?? null
|
||
|
||
function onDropped(): void {
|
||
root.allWrappers = root.allWrappers.filter(w => w !== wrapper);
|
||
root.notifications = root.notifications.filter(w => w !== wrapper);
|
||
|
||
if (root.bulkDismissing) {
|
||
return;
|
||
}
|
||
|
||
const groupKey = getGroupKey(wrapper);
|
||
const remainingInGroup = root.notifications.filter(n => getGroupKey(n) === groupKey);
|
||
|
||
if (remainingInGroup.length <= 1) {
|
||
clearGroupExpansionState(groupKey);
|
||
}
|
||
|
||
cleanupExpansionStates();
|
||
root._recomputeGroupsLater();
|
||
}
|
||
|
||
function onAboutToDestroy(): void {
|
||
wrapper.destroy();
|
||
}
|
||
}
|
||
}
|
||
|
||
Component {
|
||
id: notifComponent
|
||
NotifWrapper {}
|
||
}
|
||
|
||
function dismissAllPopups() {
|
||
for (const w of visibleNotifications) {
|
||
if (w) {
|
||
w.popup = false;
|
||
}
|
||
}
|
||
visibleNotifications = [];
|
||
notificationQueue = [];
|
||
}
|
||
|
||
function clearAllNotifications() {
|
||
if (!notifications.length) {
|
||
return;
|
||
}
|
||
bulkDismissing = true;
|
||
popupsDisabled = true;
|
||
addGate.stop();
|
||
addGateBusy = false;
|
||
notificationQueue = [];
|
||
|
||
for (const w of allWrappers) {
|
||
if (w) {
|
||
w.popup = false;
|
||
}
|
||
}
|
||
visibleNotifications = [];
|
||
|
||
_dismissQueue = notifications.slice();
|
||
if (notifications.length) {
|
||
notifications = [];
|
||
}
|
||
expandedGroups = {};
|
||
expandedMessages = {};
|
||
|
||
_suspendGrouping = true;
|
||
|
||
if (!dismissPump.running && _dismissQueue.length) {
|
||
dismissPump.start();
|
||
}
|
||
}
|
||
|
||
function dismissNotification(wrapper) {
|
||
if (!wrapper || !wrapper.notification) {
|
||
return;
|
||
}
|
||
wrapper.popup = false;
|
||
wrapper.notification.dismiss();
|
||
}
|
||
|
||
function disablePopups(disable) {
|
||
popupsDisabled = disable;
|
||
if (disable) {
|
||
notificationQueue = [];
|
||
for (const notif of visibleNotifications) {
|
||
notif.popup = false;
|
||
}
|
||
visibleNotifications = [];
|
||
}
|
||
}
|
||
|
||
property bool _processingQueue: false
|
||
|
||
function processQueue() {
|
||
if (addGateBusy || _processingQueue)
|
||
return;
|
||
if (popupsDisabled)
|
||
return;
|
||
if (SessionData.doNotDisturb)
|
||
return;
|
||
if (notificationQueue.length === 0)
|
||
return;
|
||
|
||
_processingQueue = true;
|
||
|
||
const next = notificationQueue.shift();
|
||
if (!next) {
|
||
_processingQueue = false;
|
||
return;
|
||
}
|
||
|
||
next.seq = ++seqCounter;
|
||
|
||
const activePopups = visibleNotifications.filter(n => n && n.popup);
|
||
let evicted = null;
|
||
if (activePopups.length >= maxVisibleNotifications) {
|
||
const unhovered = activePopups.filter(n => n.timer?.running);
|
||
const pool = unhovered.length > 0 ? unhovered : activePopups;
|
||
evicted = pool.reduce((min, n) => (n.seq < min.seq) ? n : min, pool[0]);
|
||
if (evicted)
|
||
evicted.removedByLimit = true;
|
||
}
|
||
|
||
if (evicted) {
|
||
visibleNotifications = [...visibleNotifications.filter(n => n !== evicted), next];
|
||
} else {
|
||
visibleNotifications = [...visibleNotifications, next];
|
||
}
|
||
|
||
if (evicted)
|
||
evicted.popup = false;
|
||
next.popup = true;
|
||
|
||
if (next.timer.interval > 0)
|
||
next.timer.start();
|
||
|
||
addGateBusy = true;
|
||
addGate.restart();
|
||
_processingQueue = false;
|
||
}
|
||
|
||
function removeFromVisibleNotifications(wrapper) {
|
||
visibleNotifications = visibleNotifications.filter(n => n !== wrapper);
|
||
processQueue();
|
||
}
|
||
|
||
function releaseWrapper(w) {
|
||
visibleNotifications = visibleNotifications.filter(n => n !== w);
|
||
notificationQueue = notificationQueue.filter(n => n !== w);
|
||
|
||
if (w && w.destroy && !w.isPersistent && notifications.indexOf(w) === -1) {
|
||
Qt.callLater(() => {
|
||
try {
|
||
w.destroy();
|
||
} catch (e) {}
|
||
});
|
||
}
|
||
}
|
||
|
||
function _decodeEntities(s) {
|
||
s = s.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10)));
|
||
s = s.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16)));
|
||
return s.replace(/&([a-zA-Z][a-zA-Z0-9]*);/g, (match, name) => {
|
||
switch (name) {
|
||
case "amp":
|
||
return "&";
|
||
case "lt":
|
||
return "<";
|
||
case "gt":
|
||
return ">";
|
||
case "quot":
|
||
return "\"";
|
||
case "apos":
|
||
return "'";
|
||
case "nbsp":
|
||
return "\u00A0";
|
||
case "ndash":
|
||
return "\u2013";
|
||
case "mdash":
|
||
return "\u2014";
|
||
case "lsquo":
|
||
return "\u2018";
|
||
case "rsquo":
|
||
return "\u2019";
|
||
case "ldquo":
|
||
return "\u201C";
|
||
case "rdquo":
|
||
return "\u201D";
|
||
case "bull":
|
||
return "\u2022";
|
||
case "hellip":
|
||
return "\u2026";
|
||
case "trade":
|
||
return "\u2122";
|
||
case "copy":
|
||
return "\u00A9";
|
||
case "reg":
|
||
return "\u00AE";
|
||
case "deg":
|
||
return "\u00B0";
|
||
case "plusmn":
|
||
return "\u00B1";
|
||
case "times":
|
||
return "\u00D7";
|
||
case "divide":
|
||
return "\u00F7";
|
||
case "micro":
|
||
return "\u00B5";
|
||
case "middot":
|
||
return "\u00B7";
|
||
case "laquo":
|
||
return "\u00AB";
|
||
case "raquo":
|
||
return "\u00BB";
|
||
case "larr":
|
||
return "\u2190";
|
||
case "rarr":
|
||
return "\u2192";
|
||
case "uarr":
|
||
return "\u2191";
|
||
case "darr":
|
||
return "\u2193";
|
||
default:
|
||
return match;
|
||
}
|
||
});
|
||
}
|
||
|
||
function _resolveHtmlBody(body) {
|
||
if (!body)
|
||
return "";
|
||
|
||
let result = body;
|
||
|
||
if (/<\/?[a-z][\s\S]*>/i.test(body)) {
|
||
result = body;
|
||
} else {
|
||
// Decode percent-encoded URLs (e.g. https%3A%2F%2F → https://)
|
||
let processed = body.replace(/\bhttps?%3A%2F%2F[^\s]+/gi, match => {
|
||
try {
|
||
return decodeURIComponent(match);
|
||
} catch (e) {
|
||
return match;
|
||
}
|
||
});
|
||
|
||
if (/&(#\d+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/.test(processed)) {
|
||
const decoded = _decodeEntities(processed);
|
||
if (/<\/?[a-z][\s\S]*>/i.test(decoded))
|
||
result = decoded;
|
||
else
|
||
result = Markdown2Html.markdownToHtml(decoded);
|
||
} else {
|
||
result = Markdown2Html.markdownToHtml(processed);
|
||
}
|
||
}
|
||
|
||
// Strip out image tags to prevent IP tracking
|
||
return result.replace(/<img\b[^>]*>/gi, "");
|
||
}
|
||
|
||
function getGroupKey(wrapper) {
|
||
if (wrapper.desktopEntry && wrapper.desktopEntry !== "") {
|
||
return wrapper.desktopEntry.toLowerCase();
|
||
}
|
||
|
||
return wrapper.appName.toLowerCase();
|
||
}
|
||
|
||
function _recomputeGroups() {
|
||
if (_suspendGrouping) {
|
||
_groupsDirty = true;
|
||
return;
|
||
}
|
||
_groupCache = {
|
||
"notifications": _calcGroupedNotifications(),
|
||
"popups": _calcGroupedPopups()
|
||
};
|
||
_groupsDirty = false;
|
||
}
|
||
|
||
function _recomputeGroupsLater() {
|
||
_groupsDirty = true;
|
||
if (!groupsDebounce.running) {
|
||
groupsDebounce.start();
|
||
}
|
||
}
|
||
|
||
function _calcGroupedNotifications() {
|
||
const groups = {};
|
||
|
||
for (const notif of notifications) {
|
||
if (!notif || !notif.notification)
|
||
continue;
|
||
const groupKey = getGroupKey(notif);
|
||
if (!groups[groupKey]) {
|
||
groups[groupKey] = {
|
||
"key": groupKey,
|
||
"appName": notif.appName,
|
||
"notifications": [],
|
||
"latestNotification": null,
|
||
"count": 0,
|
||
"hasInlineReply": false
|
||
};
|
||
}
|
||
|
||
groups[groupKey].notifications.unshift(notif);
|
||
groups[groupKey].latestNotification = groups[groupKey].notifications[0];
|
||
groups[groupKey].count = groups[groupKey].notifications.length;
|
||
|
||
if (notif.notification?.hasInlineReply)
|
||
groups[groupKey].hasInlineReply = true;
|
||
}
|
||
|
||
return Object.values(groups).sort((a, b) => {
|
||
if (!a.latestNotification || !b.latestNotification)
|
||
return 0;
|
||
const aUrgency = a.latestNotification.urgency ?? NotificationUrgency.Low;
|
||
const bUrgency = b.latestNotification.urgency ?? NotificationUrgency.Low;
|
||
if (aUrgency !== bUrgency) {
|
||
return bUrgency - aUrgency;
|
||
}
|
||
return b.latestNotification.time.getTime() - a.latestNotification.time.getTime();
|
||
});
|
||
}
|
||
|
||
function _calcGroupedPopups() {
|
||
const groups = {};
|
||
|
||
for (const notif of popups) {
|
||
if (!notif || !notif.notification)
|
||
continue;
|
||
const groupKey = getGroupKey(notif);
|
||
if (!groups[groupKey]) {
|
||
groups[groupKey] = {
|
||
"key": groupKey,
|
||
"appName": notif.appName,
|
||
"notifications": [],
|
||
"latestNotification": null,
|
||
"count": 0,
|
||
"hasInlineReply": false
|
||
};
|
||
}
|
||
|
||
groups[groupKey].notifications.unshift(notif);
|
||
groups[groupKey].latestNotification = groups[groupKey].notifications[0];
|
||
groups[groupKey].count = groups[groupKey].notifications.length;
|
||
|
||
if (notif.notification?.hasInlineReply)
|
||
groups[groupKey].hasInlineReply = true;
|
||
}
|
||
|
||
return Object.values(groups).sort((a, b) => {
|
||
if (!a.latestNotification || !b.latestNotification)
|
||
return 0;
|
||
return b.latestNotification.time.getTime() - a.latestNotification.time.getTime();
|
||
});
|
||
}
|
||
|
||
function toggleGroupExpansion(groupKey) {
|
||
let newExpandedGroups = {};
|
||
for (const key in expandedGroups) {
|
||
newExpandedGroups[key] = expandedGroups[key];
|
||
}
|
||
newExpandedGroups[groupKey] = !newExpandedGroups[groupKey];
|
||
expandedGroups = newExpandedGroups;
|
||
}
|
||
|
||
function dismissGroup(groupKey) {
|
||
const group = groupedNotifications.find(g => g.key === groupKey);
|
||
if (group) {
|
||
for (const notif of group.notifications) {
|
||
if (notif && notif.notification) {
|
||
notif.notification.dismiss();
|
||
}
|
||
}
|
||
} else {
|
||
for (const notif of allWrappers) {
|
||
if (notif && notif.notification && getGroupKey(notif) === groupKey) {
|
||
notif.notification.dismiss();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function clearGroupExpansionState(groupKey) {
|
||
let newExpandedGroups = {};
|
||
for (const key in expandedGroups) {
|
||
if (key !== groupKey && expandedGroups[key]) {
|
||
newExpandedGroups[key] = true;
|
||
}
|
||
}
|
||
expandedGroups = newExpandedGroups;
|
||
}
|
||
|
||
function cleanupExpansionStates() {
|
||
const currentGroupKeys = new Set(groupedNotifications.map(g => g.key));
|
||
const currentMessageIds = new Set();
|
||
for (const group of groupedNotifications) {
|
||
for (const notif of group.notifications) {
|
||
if (notif && notif.notification) {
|
||
currentMessageIds.add(notif.notification.id);
|
||
}
|
||
}
|
||
}
|
||
let newExpandedGroups = {};
|
||
for (const key in expandedGroups) {
|
||
if (currentGroupKeys.has(key) && expandedGroups[key]) {
|
||
newExpandedGroups[key] = true;
|
||
}
|
||
}
|
||
expandedGroups = newExpandedGroups;
|
||
let newExpandedMessages = {};
|
||
for (const messageId in expandedMessages) {
|
||
if (currentMessageIds.has(messageId) && expandedMessages[messageId]) {
|
||
newExpandedMessages[messageId] = true;
|
||
}
|
||
}
|
||
expandedMessages = newExpandedMessages;
|
||
}
|
||
|
||
function toggleMessageExpansion(messageId) {
|
||
let newExpandedMessages = {};
|
||
for (const key in expandedMessages) {
|
||
newExpandedMessages[key] = expandedMessages[key];
|
||
}
|
||
newExpandedMessages[messageId] = !newExpandedMessages[messageId];
|
||
expandedMessages = newExpandedMessages;
|
||
}
|
||
|
||
Connections {
|
||
target: SessionData
|
||
function onDoNotDisturbChanged() {
|
||
if (SessionData.doNotDisturb) {
|
||
// Hide all current popups when DND is enabled
|
||
for (const notif of visibleNotifications) {
|
||
notif.popup = false;
|
||
}
|
||
visibleNotifications = [];
|
||
notificationQueue = [];
|
||
} else {
|
||
// Re-enable popup processing when DND is disabled
|
||
processQueue();
|
||
}
|
||
}
|
||
}
|
||
|
||
Connections {
|
||
target: typeof SettingsData !== "undefined" ? SettingsData : null
|
||
function onUse24HourClockChanged() {
|
||
root.clockFormatChanged = !root.clockFormatChanged;
|
||
}
|
||
function onNotificationHistoryMaxAgeDaysChanged() {
|
||
root.pruneHistory();
|
||
}
|
||
function onNotificationHistoryEnabledChanged() {
|
||
if (!SettingsData.notificationHistoryEnabled) {
|
||
root.deleteHistory();
|
||
}
|
||
}
|
||
}
|
||
}
|