mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-16 08:14:03 -04:00
* fix(notifications): edit rules from the menu instead of deleting them
A notification rule can set an action and Bypass DND together, but
Unmute and Block in Do Not Disturb spliced the whole rule, dropping the
other setting. They now reset only their own field, and a rule is
removed once it no longer does anything (action and urgency default,
no bypass). Mute and Allow in Do Not Disturb set their field on the
app's existing rule instead of appending one that first-match policy
would shadow.
The menu now only reads and edits enabled, exact rules on the app name
or desktop entry. It used to match any rule whose pattern equaled the
app name, including summary, body, contains and regex rules, and could
delete disabled ones.
Also adds a Copy item to the notification menu on popups and center
cards, copying the summary and body as plain text.
* fix(notifications): let mute apply alongside other rule actions
Rule actions are first-match, so a mute rule behind an app's existing
rule (e.g. No history) never applied while the menu showed Unmute.
Mute is now evaluated like Bypass DND: any matching enabled mute rule
disables the popup, and the other actions stay first-match, so No
history and Mute both apply.
* fix(notifications): skip the sound for dropped and muted notifications
The notification sound played before the check that dismisses a
notification with neither a popup nor a center entry, so dropped
notifications (transient while muted, Popup only plus Mute) still made
a sound. Play it only for notifications that are kept, and not when a
mute rule applies, like Do Not Disturb.
stable-1.6: ported the rule edits and the sound/mute policy only, the Copy menu item depends on NotificationActions which stable does not have
(cherry picked from commit 51f115f11e)
1470 lines
48 KiB
QML
1470 lines
48 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 seenNotifications: []
|
|
readonly property int unreadCount: {
|
|
const seen = root.seenNotifications;
|
|
if (seen.length === 0)
|
|
return root.notifications.length;
|
|
let count = 0;
|
|
for (const wrapper of root.notifications) {
|
|
if (seen.indexOf(wrapper) === -1)
|
|
count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function markNotificationsSeen() {
|
|
root.seenNotifications = root.notifications.slice();
|
|
}
|
|
|
|
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
|
|
readonly property int notificationDedupBurstMs: 5000
|
|
property var _recentDedupKeys: []
|
|
|
|
signal notificationDeduplicated(var wrapper)
|
|
|
|
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 _nowSec() {
|
|
return Date.now() / 1000.0;
|
|
}
|
|
|
|
function _normalizeDedupText(text) {
|
|
if (!text)
|
|
return "";
|
|
let normalized = text.toString();
|
|
normalized = normalized.replace(/<img\b[^>]*>/gi, "");
|
|
normalized = normalized.replace(/<[^>]+>/g, "");
|
|
normalized = normalized.replace(/\s+/g, " ").trim();
|
|
return normalized.toLowerCase();
|
|
}
|
|
|
|
function _dedupAppId(source) {
|
|
if (!source)
|
|
return "";
|
|
const desktopEntry = (source.desktopEntry || "").toString().trim().toLowerCase();
|
|
if (desktopEntry)
|
|
return desktopEntry;
|
|
return (source.appName || "").toString().trim().toLowerCase();
|
|
}
|
|
|
|
function _notificationDedupKey(source) {
|
|
if (!source)
|
|
return "";
|
|
const app = _dedupAppId(source);
|
|
const summary = _normalizeDedupText(source.summary);
|
|
const body = _normalizeDedupText(source.body);
|
|
const urgency = typeof source.urgency === "number" ? source.urgency : NotificationUrgency.Normal;
|
|
if (!app && !summary && !body)
|
|
return "";
|
|
const sep = "";
|
|
return app + sep + summary + sep + body + sep + urgency;
|
|
}
|
|
|
|
function _pruneRecentDedupKeys() {
|
|
const cutoff = Date.now() - notificationDedupBurstMs;
|
|
_recentDedupKeys = _recentDedupKeys.filter(entry => entry && entry.atMs >= cutoff);
|
|
}
|
|
|
|
function _hasRecentDuplicate(key) {
|
|
if (!key)
|
|
return false;
|
|
_pruneRecentDedupKeys();
|
|
return _recentDedupKeys.some(entry => entry && entry.key === key);
|
|
}
|
|
|
|
function _recordDedupKey(key) {
|
|
if (!key)
|
|
return;
|
|
_pruneRecentDedupKeys();
|
|
_recentDedupKeys.push({
|
|
"key": key,
|
|
"atMs": Date.now()
|
|
});
|
|
}
|
|
|
|
function _findActiveDuplicate(notif) {
|
|
const key = _notificationDedupKey(notif);
|
|
if (!key)
|
|
return null;
|
|
|
|
for (const w of allWrappers) {
|
|
if (!w || !w.notification || !w.popup)
|
|
continue;
|
|
if (_notificationDedupKey(w.notification) !== key)
|
|
continue;
|
|
if (visibleNotifications.indexOf(w) !== -1 || notificationQueue.indexOf(w) !== -1)
|
|
return w;
|
|
if (w.timer && w.timer.running)
|
|
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 _allowedInDnd(urgency, bypassDnd) {
|
|
return bypassDnd || (SettingsData.notificationDndAllowCritical && urgency === NotificationUrgency.Critical);
|
|
}
|
|
|
|
function _evaluateNotificationPolicy(notif) {
|
|
const baseUrgency = typeof notif.urgency === "number" ? notif.urgency : NotificationUrgency.Normal;
|
|
const policy = {
|
|
"drop": false,
|
|
"disablePopup": false,
|
|
"hideFromCenter": false,
|
|
"disableHistory": false,
|
|
"bypassDnd": 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 || ""
|
|
};
|
|
|
|
policy.bypassDnd = rules.some(rule => rule.bypassDnd === true && _matchesNotificationRule(rule, info));
|
|
policy.disablePopup = rules.some(rule => (rule.action || "default").toString().toLowerCase() === "mute" && _matchesNotificationRule(rule, info));
|
|
|
|
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 "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;
|
|
markNotificationsSeen();
|
|
addGate.stop();
|
|
addGateBusy = false;
|
|
|
|
notificationQueue = [];
|
|
for (const w of visibleNotifications) {
|
|
if (w) {
|
|
w.popup = false;
|
|
}
|
|
}
|
|
visibleNotifications = [];
|
|
_recomputeGroupsLater();
|
|
pruneHistory();
|
|
}
|
|
|
|
function onOverlayClose() {
|
|
popupsDisabled = false;
|
|
markNotificationsSeen();
|
|
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;
|
|
}
|
|
|
|
if (SettingsData.notificationDedupeEnabled) {
|
|
const dedupKey = _notificationDedupKey(notif);
|
|
const duplicate = _findActiveDuplicate(notif);
|
|
if (duplicate || _hasRecentDuplicate(dedupKey)) {
|
|
if (duplicate) {
|
|
if (duplicate.timer && duplicate.timer.running)
|
|
duplicate.timer.restart();
|
|
root.notificationDeduplicated(duplicate);
|
|
}
|
|
try {
|
|
notif.dismiss();
|
|
} catch (e) {}
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!_ingressAllowed(policy.urgency)) {
|
|
if (policy.urgency !== NotificationUrgency.Critical) {
|
|
try {
|
|
notif.dismiss();
|
|
} catch (e) {}
|
|
return;
|
|
}
|
|
}
|
|
|
|
const dndBlocked = SessionData.doNotDisturb && !_allowedInDnd(policy.urgency, policy.bypassDnd);
|
|
const shouldShowPopup = !root.popupsDisabled && !dndBlocked && !policy.disablePopup;
|
|
const isTransient = notif.transient;
|
|
const shouldKeepInCenter = !isTransient && !policy.hideFromCenter;
|
|
|
|
if (!shouldShowPopup && !shouldKeepInCenter) {
|
|
try {
|
|
notif.dismiss();
|
|
} catch (e) {}
|
|
return;
|
|
}
|
|
|
|
// Honor the freedesktop "suppress-sound" hint: the sender
|
|
// plays its own audio for this notification and asks the
|
|
// server not to double up. "sound-name" is the opposite — an
|
|
// explicit request for audio — so it plays even when the
|
|
// global new-notification sound is off.
|
|
const soundHints = notif.hints || {};
|
|
const suppressSound = !!soundHints["suppress-sound"];
|
|
const requestsSound = !!soundHints["sound-name"];
|
|
if (!dndBlocked && !policy.disablePopup && SettingsData.soundsEnabled && (SettingsData.soundNewNotification || requestsSound) && !suppressSound) {
|
|
if (policy.urgency === NotificationUrgency.Critical) {
|
|
AudioService.playCriticalNotificationSound();
|
|
} else {
|
|
AudioService.playNormalNotificationSound();
|
|
}
|
|
}
|
|
|
|
const wrapper = notifComponent.createObject(root, {
|
|
"popup": shouldShowPopup,
|
|
"notification": notif,
|
|
"urgencyOverride": policy.urgency,
|
|
"bypassDnd": policy.bypassDnd
|
|
});
|
|
|
|
if (wrapper) {
|
|
if (SettingsData.notificationDedupeEnabled)
|
|
_recordDedupKey(_notificationDedupKey(notif));
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
function isFocusedScreen(screen) {
|
|
if (!SettingsData.notificationFocusedMonitor)
|
|
return true;
|
|
const focused = CompositorService.getFocusedScreen();
|
|
return !!focused && !!screen && focused.name === screen.name;
|
|
}
|
|
|
|
function notificationIconFromImage(image) {
|
|
image = image || "";
|
|
return image.startsWith("image://icon/") ? image.substring(13) : "";
|
|
}
|
|
|
|
function notificationImageHasSpecialPrefix(image) {
|
|
return /^(material|svg|unicode|image):/.test(notificationIconFromImage(image));
|
|
}
|
|
|
|
function notificationHasImage(image) {
|
|
image = image || "";
|
|
return image !== "" && (!image.startsWith("image://icon/") || notificationIconFromImage(image).startsWith("/"));
|
|
}
|
|
|
|
function notificationCleanImage(image) {
|
|
image = image || "";
|
|
if (!image)
|
|
return "";
|
|
if (image.startsWith("image://icon/")) {
|
|
const payload = image.substring(13);
|
|
if (payload.startsWith("/"))
|
|
return "file://" + payload;
|
|
}
|
|
return Paths.strip(image);
|
|
}
|
|
|
|
// Apps like kitty send app_icon as their bundled logo path; the themed icon wins when the basename resolves (#2746).
|
|
function notificationThemedAppIcon(appIcon) {
|
|
if (/^https?:\/\//.test(appIcon))
|
|
return "";
|
|
const path = appIcon.startsWith("file://") ? appIcon.substring(7) : appIcon;
|
|
if (!path.startsWith("/"))
|
|
return "";
|
|
const file = path.substring(path.lastIndexOf("/") + 1);
|
|
const dot = file.lastIndexOf(".");
|
|
const base = dot > 0 ? file.substring(0, dot) : file;
|
|
if (!base)
|
|
return "";
|
|
return Paths.themedIconPath(base) ? base : "";
|
|
}
|
|
|
|
function notificationImageSource(image, appIcon) {
|
|
image = image || "";
|
|
appIcon = appIcon || "";
|
|
if (notificationHasImage(image))
|
|
return notificationCleanImage(image);
|
|
if (notificationImageHasSpecialPrefix(image))
|
|
return "";
|
|
if (!appIcon)
|
|
return "";
|
|
if (notificationThemedAppIcon(appIcon))
|
|
return "";
|
|
return /^(file|https?):\/\//.test(appIcon) || appIcon.includes("/") ? appIcon : "";
|
|
}
|
|
|
|
function notificationFallbackIcon(image, appIcon) {
|
|
image = image || "";
|
|
appIcon = appIcon || "";
|
|
const fromImage = notificationIconFromImage(image);
|
|
if (notificationImageHasSpecialPrefix(image))
|
|
return fromImage;
|
|
const themed = notificationThemedAppIcon(appIcon);
|
|
if (themed)
|
|
return themed;
|
|
return appIcon || fromImage || "";
|
|
}
|
|
|
|
component NotifWrapper: QtObject {
|
|
id: wrapper
|
|
|
|
property bool popup: false
|
|
property bool removedByLimit: false
|
|
property bool isPersistent: true
|
|
property bool bypassDnd: false
|
|
property int seq: 0
|
|
property string persistedImagePath: ""
|
|
|
|
onPopupChanged: {
|
|
if (!popup) {
|
|
removeFromVisibleNotifications(wrapper);
|
|
}
|
|
}
|
|
|
|
readonly property Timer timer: Timer {
|
|
interval: {
|
|
if (!wrapper.notification)
|
|
return 5000;
|
|
// expireTimeout is in milliseconds; -1 defers to our settings.
|
|
const appTimeout = wrapper.notification.expireTimeout;
|
|
if (appTimeout >= 0 && !SettingsData.notificationIgnoreAppTimeout)
|
|
return Math.round(appTimeout);
|
|
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: root.notificationCleanImage(image)
|
|
readonly property bool hasDisplayImage: root.notificationHasImage(image)
|
|
readonly property string displayImage: root.notificationImageSource(image, appIcon)
|
|
readonly property string fallbackIconName: root.notificationFallbackIcon(image, appIcon)
|
|
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;
|
|
}
|
|
|
|
// Dismiss visible popup when sender closes the notification
|
|
// (e.g. YubiKey sends CloseNotification after touch).
|
|
// Without this, expireTimeout=0 popups stay visible forever.
|
|
wrapper.popup = false;
|
|
|
|
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 dismissLastNotification() {
|
|
const w = visibleNotifications[visibleNotifications.length - 1];
|
|
if (w)
|
|
dismissNotification(w);
|
|
}
|
|
|
|
function dismissAllPopups() {
|
|
for (const w of visibleNotifications) {
|
|
if (w) {
|
|
w.popup = false;
|
|
}
|
|
}
|
|
visibleNotifications = [];
|
|
notificationQueue = [];
|
|
}
|
|
|
|
Connections {
|
|
target: SettingsData
|
|
|
|
function onNotificationPopupsInvalidated() {
|
|
root.dismissAllPopups();
|
|
}
|
|
}
|
|
|
|
function sendTestNotifications() {
|
|
dismissAllPopups();
|
|
sendTestNotification(0);
|
|
testNotifTimer1.start();
|
|
testNotifTimer2.start();
|
|
}
|
|
|
|
function sendTestNotification(index) {
|
|
const notifications = [["Notification Position Test", "DMS test notification 1 of 3 ~ Hi there!", "preferences-system"], ["Second Test", "DMS Notification 2 of 3 ~ Check it out!", "applications-graphics"], ["Third Test", "DMS notification 3 of 3 ~ Enjoy!", "face-smile"]];
|
|
|
|
if (index < 0 || index >= notifications.length) {
|
|
return;
|
|
}
|
|
|
|
const notif = notifications[index];
|
|
testNotificationProcess.command = ["notify-send", "-h", "int:transient:1", "-a", "DMS", "-i", notif[2], notif[0], notif[1]];
|
|
testNotificationProcess.running = true;
|
|
}
|
|
|
|
property Process testNotificationProcess
|
|
|
|
testNotificationProcess: Process {
|
|
command: []
|
|
running: false
|
|
}
|
|
|
|
property Timer testNotifTimer1
|
|
|
|
testNotifTimer1: Timer {
|
|
interval: 400
|
|
repeat: false
|
|
onTriggered: root.sendTestNotification(1)
|
|
}
|
|
|
|
property Timer testNotifTimer2
|
|
|
|
testNotifTimer2: Timer {
|
|
interval: 800
|
|
repeat: false
|
|
onTriggered: root.sendTestNotification(2)
|
|
}
|
|
|
|
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;
|
|
const nextIndex = notificationQueue.findIndex(w => w && (!SessionData.doNotDisturb || _allowedInDnd(w.urgency, w.bypassDnd)));
|
|
if (nextIndex === -1)
|
|
return;
|
|
|
|
_processingQueue = true;
|
|
|
|
const next = notificationQueue.splice(nextIndex, 1)[0];
|
|
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) {
|
|
notificationQueue = notificationQueue.filter(w => w && _allowedInDnd(w.urgency, w.bypassDnd));
|
|
for (const notif of visibleNotifications) {
|
|
if (!_allowedInDnd(notif.urgency, notif.bypassDnd))
|
|
notif.popup = false;
|
|
}
|
|
}
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|