From 37e04295638f45d0dd9cb27ee7c78925114159e6 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 9 Nov 2026 21:39:21 +0100 Subject: [PATCH] feat: expire progress bar on popup notifs --- Components/Notifs/NotifGroupCard.qml | 4 +- Modules/Notifications/Content.qml | 85 ++++- Modules/Sidebar/Chat/ChatList.qml | 2 +- Plugins/ZShell/Services/notificationstore.cpp | 337 +++++++++++------- Plugins/ZShell/Services/notificationstore.hpp | 49 ++- 5 files changed, 324 insertions(+), 153 deletions(-) diff --git a/Components/Notifs/NotifGroupCard.qml b/Components/Notifs/NotifGroupCard.qml index 4a7225d..a8cae1d 100644 --- a/Components/Notifs/NotifGroupCard.qml +++ b/Components/Notifs/NotifGroupCard.qml @@ -16,9 +16,11 @@ CustomRect { required property NotificationGroup group required property Flickable container required property bool expanded + property bool expandable: true property int baseSurface: 0 property int baseLayer: baseSurface + property int bottomPadding: 0 readonly property list notifs: [...group.activeNotifs] readonly property bool single: notifs.length === 1 @@ -27,7 +29,7 @@ CustomRect { readonly property int nonAnimHeight: { const headerHeight = header.implicitHeight + (expanded ? Tokens.spacing.extraSmall : 0); const columnHeight = headerHeight + notifList.layoutHeight; - return Math.round(Math.max(Config.notifs.sizes.image, columnHeight) + Tokens.padding.medium * 2); + return Math.round(Math.max(Config.notifs.sizes.image, columnHeight) + Tokens.padding.medium * 2 + bottomPadding); } signal requestExpand(expand: bool) diff --git a/Modules/Notifications/Content.qml b/Modules/Notifications/Content.qml index 4fc48a3..44883e2 100644 --- a/Modules/Notifications/Content.qml +++ b/Modules/Notifications/Content.qml @@ -10,6 +10,7 @@ import qs.Components import qs.Components.Notifs import qs.Daemons import qs.Helpers +import qs.Services Item { id: root @@ -109,10 +110,6 @@ Item { } } - component Anim: NumberAnimation { - duration: Tokens.anim.durations.expressiveDefaultSpatial - easing: Tokens.anim.expressiveDefaultSpatial - } component NotifWrapper: Item { id: wrapper @@ -125,8 +122,8 @@ Item { fn(n); } - function holdPopups(): void { - forEachPopup(n => n.holdPopup()); + function holdGroupExpiry(): void { + wrapper.modelData.holdExpiry(); } LazyListView.trackViewport: !expanded && card.nonAnimHeight < card.implicitHeight @@ -148,15 +145,33 @@ Item { onHoldingChanged: { if (holding) - holdPopups(); + holdGroupExpiry(); else - forEachPopup(n => n.releasePopup()); + wrapper.modelData.releaseExpiry(); } Connections { function onNotifsChanged(): void { if (wrapper.holding) - wrapper.holdPopups(); + wrapper.holdGroupExpiry(); + } + + function onExpireTimerRestarted(): void { + if (expireProgress.running) + expireProgress.stop(); + expireProgressReset.start(); + } + + function onExpirePausedChanged(): void { + if (wrapper.modelData.expirePaused) { + if (expireProgress.running) + expireProgress.pause(); + } else { + if (expireProgress.running) + expireProgress.resume(); + else + expireProgress.start(); + } } target: wrapper.modelData @@ -171,6 +186,39 @@ Item { easing: Tokens.anim.emphasized } + Anim { + id: expireProgressReset + + readonly property bool nPaused: wrapper.modelData.expirePaused + + target: progress + property: "implicitWidth" + type: Anim.FastEffects + to: nPaused ? area.width : area.width * (1 - duration / expireProgress.duration) + + onFinished: { + if (nPaused) + expireProgress.duration = wrapper.modelData.expireTimeout; + else + expireProgress.duration = wrapper.modelData.expireTimeout - expireProgressReset.duration; + expireProgress.from = to; + if (!nPaused) + expireProgress.start(); + } + } + + Anim { + id: expireProgress + + running: wrapper.modelData.expireActive + target: progress + property: "implicitWidth" + duration: wrapper.modelData.expireTimeout + easing.type: Easing.Linear + from: area.width + to: 0 + } + Item { anchors.top: parent.top @@ -208,6 +256,25 @@ Item { onRequestExpand: expand => wrapper.expanded = expand onSwiped: notif => notif.dismissPopup() onLinkActivated: notif => notif.dismissPopup() + bottomPadding: Config.notifs.expire ? progress.implicitHeight : 0 + } + + CustomClippingRect { + anchors.fill: parent + + radius: Tokens.rounding.large + + visible: Config.notifs.expire + + CustomRect { + id: progress + + anchors.bottom: parent.bottom + anchors.right: parent.right + implicitWidth: parent.width + implicitHeight: 6 + color: Colors.palette.m3outlineVariant + } } } } diff --git a/Modules/Sidebar/Chat/ChatList.qml b/Modules/Sidebar/Chat/ChatList.qml index a9b7dd3..f83abd1 100644 --- a/Modules/Sidebar/Chat/ChatList.qml +++ b/Modules/Sidebar/Chat/ChatList.qml @@ -296,7 +296,7 @@ Item { CustomText { animate: true text: fabMenuItem.modelData.name - font: Tokens.font.body.builders.small.capitalization(Font.Capitalize).build + font: Tokens.font.body.builders.small.capitalization(Font.Capitalize).build() color: Colors.palette.m3onPrimaryContainer Layout.preferredWidth: implicitWidth diff --git a/Plugins/ZShell/Services/notificationstore.cpp b/Plugins/ZShell/Services/notificationstore.cpp index 0042837..71efe40 100644 --- a/Plugins/ZShell/Services/notificationstore.cpp +++ b/Plugins/ZShell/Services/notificationstore.cpp @@ -38,21 +38,14 @@ namespace { constexpr int MAX_ROWS = 500; constexpr int MAX_PER_GROUP = 50; constexpr qint64 MAX_AGE_MS = 30LL * 24 * 60 * 60 * 1000; -constexpr int TICK_MS = 250; +constexpr int TICK_MS = 100; constexpr int MAX_IMAGE_DIM = 512; -// Memoized icon theme file lookups (hits and misses) per theme configuration. constexpr int MAX_ICON_PATHS = 256; constexpr qint64 PRUNE_INTERVAL_MS = 4LL * 60 * 60 * 1000; constexpr qint64 TIME_STR_REFRESH_MS = 2000; -// Coalesces content updates (live replaces_id edits, popup state) into one -// transaction instead of one UPDATE per changed field. +constexpr qint64 MAX_EXPIRE_MS = 5LL * 60 * 1000; constexpr int FLUSH_MS = 300; -// Quickshell serves inline notification pictures through image://qsimage/ -// (and qspixmap) handles that belong to the live notification, not to the -// shell: once that notification is replaced or dismissed, the handle is gone -// and the url fails to resolve. Such a source is only ever useful as the raw -// url long enough to read the bytes, so the UI must never see it. bool transientImageSource(const QString& source) { if (source.isEmpty()) { return false; @@ -64,8 +57,6 @@ bool transientImageSource(const QString& source) { url.host() == QLatin1String("qspixmap")); } -// The url the UI should use: the cached blob when there is one, and the raw -// source only while that source keeps resolving. QString displayImage(const QString& source, const QString& digest) { if (!digest.isEmpty()) { return QStringLiteral("image://notifimg/") + digest; @@ -74,14 +65,9 @@ QString displayImage(const QString& source, const QString& digest) { return transientImageSource(source) ? QString() : source; } -// Icon theme state, captured on the GUI thread. `QIcon::themeName()`, -// `fallbackThemeName()` and `themeSearchPaths()` touch GUI-thread only state, -// but the strings they return are safe to hand to a worker. struct IconTheme { QStringList searchPaths; QStringList themes; - // Changes when the theme or the search paths change, so the path cache - // below can be dropped after a theme switch. QString signature; }; @@ -104,21 +90,11 @@ IconTheme iconTheme() { return theme; } -// Resolve an icon name to a file the theme actually ships, so notification -// images that only name an icon can still be cached. KF6's KIconLoader is not -// thread-safe (it crashed the shell when the icon provider was called from a -// worker) and QIcon painting is GUI-thread only, so this only builds candidate -// paths and stats them - which makes it worker-thread safe when the theme -// strings come from iconTheme(). Hits and misses are both memoized: a sender -// repeats the same icon name, and a name the theme lacks costs thousands of -// stat calls to discover. QString iconFileForName(const QString& name, const IconTheme& theme) { if (name.isEmpty()) { return {}; } - // Stat results only change when a theme is installed or removed, which the - // signature covers; the cap just keeps a stream of odd names bounded. static std::mutex cacheMutex; static QHash pathCache; static QString cacheSignature; @@ -171,7 +147,6 @@ QString iconFileForName(const QString& name, const IconTheme& theme) { const QString suffix = candidateName + "." + extension; - // hicolor: ///. const QString canonical = themeDir.filePath( size + "/" + context + "/" + extension + "/" + suffix); @@ -179,15 +154,12 @@ QString iconFileForName(const QString& name, const IconTheme& theme) { return canonical; } - // hicolor and Papirus both also ship - // //. const QString sized = themeDir.filePath( size + "/" + context + "/" + suffix); if (QFileInfo::exists(sized)) { return sized; } - // themes ordering size under context const QString nested = themeDir.filePath( context + "/" + size + "/" + suffix); if (QFileInfo::exists(nested)) { @@ -197,7 +169,6 @@ QString iconFileForName(const QString& name, const IconTheme& theme) { } } - // theme without size directories: /. for (const auto& context : contexts) { for (const auto& extension : extensions) { const QString plain = themeDir.filePath( @@ -420,10 +391,6 @@ void NotificationRow::setPopup(bool popup) { } m_popup = popup; - m_popupPaused = false; - m_deadline = - popup ? QDateTime::currentMSecsSinceEpoch() + m_store->popupTimeout() - : 0; emit popupChanged(); if (m_store != nullptr) { @@ -473,9 +440,6 @@ void NotificationRow::lock(QObject* item) { } m_locks.insert(item); - // Goes through unlock() so releasing the last lock on an already-closed - // row finishes the removal the close started. The destroyed signal - // carries the dying object, which is the lock holder. QObject::connect(item, &QObject::destroyed, this, [this](QObject* obj) { unlock(obj); }); @@ -500,15 +464,17 @@ void NotificationRow::unlock(QObject* item) { } void NotificationRow::pausePopup() { - m_popupPaused = true; + if (m_popupGroup != nullptr) { + m_popupGroup->holdExpiry(); + } } void NotificationRow::resumePopup() { - m_popupPaused = false; + if (m_popupGroup != nullptr) { + m_popupGroup->releaseExpiry(); + } } -// The v2 notification UI uses caelestia's naming; these are thin aliases of the -// existing behaviour so both spellings work. void NotificationRow::dismiss() { close(); } @@ -535,8 +501,6 @@ void NotificationRow::unref(QObject* item) { unlock(item); } -// True when path is this row's image, in either its raw or cached form, so the -// UI can tell "the same picture changed url" from "a different picture". bool NotificationRow::isSameImage(const QString& path) const { return path == m_image || path == m_imageSource; } @@ -545,11 +509,6 @@ bool NotificationRow::isSameAppIcon(const QString& path) const { return path == m_appIcon; } -bool NotificationRow::expired(qint64 now) const { - return m_popup && !m_popupPaused && !m_closed && m_deadline > 0 && - now >= m_deadline; -} - void NotificationRow::refreshTimeStr() { const qint64 diff = QDateTime::currentMSecsSinceEpoch() - m_timeMs; const qint64 m = diff / 60000; @@ -595,6 +554,26 @@ int NotificationGroup::count() const { return m_count; } +bool NotificationGroup::expireActive() const { + return m_expireActive; +} + +bool NotificationGroup::expirePaused() const { + return m_expirePaused; +} + +qreal NotificationGroup::expireProgress() const { + return m_expireProgress; +} + +int NotificationGroup::expireRemaining() const { + return m_expireRemaining; +} + +int NotificationGroup::expireTimeout() const { + return m_expireTimeout; +} + QString NotificationGroup::firstSummary() const { return m_firstSummary; } @@ -636,11 +615,6 @@ void NotificationGroup::removeRow(NotificationRow* row) { QObject::disconnect(row, nullptr, this, nullptr); emit notifsChanged(); - // Must not drop the row from m_active here: refreshActive() only emits - // activeNotifsChanged when the list it rebuilds differs from the stored - // one, and a silently pre-edited m_active is equal to the rebuild. The - // popup stack reads activeNotifs, so skipping the emit left a closed row - // occupying its slot - an invisible gap other entries did not move into. refreshActive(); sync(); } @@ -708,8 +682,109 @@ void NotificationGroup::sync() { } } -// Delegates compare their own shown url against the group's current source row, -// so a row swapping raw source <-> cached blob does not re-trigger the fade. +void NotificationGroup::resetExpiry(int timeout) { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + + if (timeout != m_expireTimeout) { + m_expireTimeout = timeout; + emit expireTimeoutChanged(); + } + + if (timeout <= 0) { + m_expireDeadline = 0; + m_expireHeld = 0; + + if (m_expirePaused) { + m_expirePaused = false; + emit expirePausedChanged(); + } + } else if (m_expirePaused) { + m_expireHeld = timeout; + } else { + m_expireDeadline = now + timeout; + } + + refreshExpiry(now); + emit expireTimerRestarted(); +} + +void NotificationGroup::holdExpiry() { + if (m_expirePaused || m_expireDeadline <= 0) { + return; + } + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + m_expireHeld = (std::max)(qint64(0), m_expireDeadline - now); + m_expireDeadline = 0; + m_expirePaused = true; + + emit expirePausedChanged(); + refreshExpiry(now); +} + +void NotificationGroup::releaseExpiry() { + if (!m_expirePaused) { + return; + } + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + m_expireDeadline = now + m_expireHeld; + m_expireHeld = 0; + m_expirePaused = false; + + emit expirePausedChanged(); + refreshExpiry(now); +} + +void NotificationGroup::clearExpiry() { + m_expireDeadline = 0; + m_expireHeld = 0; + + if (m_expirePaused) { + m_expirePaused = false; + emit expirePausedChanged(); + } + + refreshExpiry(QDateTime::currentMSecsSinceEpoch()); +} + +bool NotificationGroup::expired(qint64 now) const { + return m_expireDeadline > 0 && !m_expirePaused && now >= m_expireDeadline; +} + +void NotificationGroup::refreshExpiry(qint64 now) { + qint64 remaining = 0; + if (m_expirePaused) { + remaining = m_expireHeld; + } else if (m_expireDeadline > 0) { + remaining = (std::max)(qint64(0), m_expireDeadline - now); + } + + if (remaining != m_expireRemaining) { + m_expireRemaining = static_cast(remaining); + emit expireRemainingChanged(); + } + + const qreal progress = + m_expireTimeout > 0 + ? (std::min)(1.0, static_cast(remaining) / m_expireTimeout) + : 0; + + if (!qFuzzyCompare(progress, m_expireProgress)) { + m_expireProgress = progress; + emit expireProgressChanged(); + } + + setExpireActive(m_expireDeadline > 0 || m_expirePaused); +} + +void NotificationGroup::setExpireActive(bool active) { + if (active != m_expireActive) { + m_expireActive = active; + emit expireActiveChanged(); + } +} + bool NotificationGroup::isSameImage(const QString& path) const { return m_imageSource != nullptr && m_imageSource->isSameImage(path); } @@ -721,8 +796,6 @@ bool NotificationGroup::isSameAppIcon(const QString& path) const { void NotificationGroup::lock(QObject* item) { if (item != nullptr && !m_locks.contains(item)) { m_locks.insert(item); - // No Qt::UniqueConnection here: it requires a member-function pointer, - // and the contains() check above already keeps this one-shot per item. QObject::connect( item, &QObject::destroyed, this, [this, item](QObject*) { unlock(item); @@ -737,8 +810,6 @@ void NotificationGroup::unlock(QObject* item) { } } -// Called by the store once the group has no rows left. A delegate may still be -// animating it out, so the delete waits for its last unlock. void NotificationGroup::release() { m_releasing = true; if (m_locks.isEmpty()) { @@ -758,9 +829,6 @@ NotificationStore::NotificationStore(QObject* parent) m_flush->setInterval(FLUSH_MS); QObject::connect(m_flush, &QTimer::timeout, this, &NotificationStore::flush); - // Config-backed settings, same pattern as TickingService: read the - // config node directly and update live on its change signals. QML may - // still write these properties; the next config change re-applies. auto* notifs = config::ConfigSingleton::instance()->notifs(); applyNotifConfig(notifs); QObject::connect( @@ -818,6 +886,12 @@ void NotificationStore::setPopupTimeout(int timeout) { m_popupTimeout = timeout; emit popupTimeoutChanged(); + + if (m_autoExpire) { + for (auto* group : std::as_const(m_popupGroups)) { + group->resetExpiry(groupExpireTimeoutMs(group)); + } + } } bool NotificationStore::autoExpire() const { @@ -831,6 +905,14 @@ void NotificationStore::setAutoExpire(bool autoExpire) { m_autoExpire = autoExpire; emit autoExpireChanged(); + + for (auto* group : std::as_const(m_popupGroups)) { + if (autoExpire) { + group->resetExpiry(groupExpireTimeoutMs(group)); + } else { + group->clearExpiry(); + } + } } void NotificationStore::applyNotifConfig(const config::Notifs* notifs) { @@ -881,9 +963,6 @@ NotificationRow* NotificationStore::insert( attachNotification(row, wrapped, notification); row->m_popup = popup; - if (popup) { - row->m_deadline = QDateTime::currentMSecsSinceEpoch() + m_popupTimeout; - } insertDbRow(row); startImageCache(row); @@ -929,8 +1008,6 @@ void NotificationStore::clear() { } } - // The bulk delete above already removed every DB row; the per-row - // cleanup would just re-delete no-ops, so batch it instead. m_clearing = true; const auto rows = m_rows; for (auto* row : rows) { @@ -953,6 +1030,11 @@ void NotificationStore::copyFields(NotificationRow* row, QObject* notification) row->m_resident = notification->property("resident").toBool(); row->m_hasActionIcons = notification->property("hasActionIcons").toBool(); row->m_urgency = notification->property("urgency").toInt(); + + const auto expireTimeout = notification->property("expireTimeout"); + row->m_expireTimeoutMs = expireTimeout.isValid() + ? static_cast(expireTimeout.toDouble()) + : -1; } void NotificationStore::attachNotification( @@ -980,10 +1062,6 @@ void NotificationStore::attachNotification( rowGuard->m_store->handleClosed(rowGuard); }); - // Quickshell applies replaces_id updates to the object we already - // hold; mirror those changes onto the row (in UI and in the DB). - // Connected straight to the slot (not through an intermediate - // signal) so sender() in handleLiveUpdate is the Notification. static const QMetaMethod slot = [] { const QMetaObject& storeMeta = NotificationStore::staticMetaObject; return storeMeta.method( @@ -1000,6 +1078,7 @@ void NotificationStore::attachNotification( "hasActionIconsChanged", "actionsChanged", "hintsChanged", + "expireTimeoutChanged", }; const auto* meta = notification->metaObject(); @@ -1040,6 +1119,11 @@ void NotificationStore::handleLiveUpdate() { const bool hasActionIcons = it.key()->property("hasActionIcons").toBool(); const int urgency = it.key()->property("urgency").toInt(); + const auto expireTimeout = it.key()->property("expireTimeout"); + row->m_expireTimeoutMs = expireTimeout.isValid() + ? static_cast(expireTimeout.toDouble()) + : -1; + const bool imageChanged = imageSource != row->m_imageSource; if (summary != row->m_summary) { @@ -1095,12 +1179,6 @@ void NotificationStore::handleLiveUpdate() { startImageCache(row); } - // New content deserves a full look: restart the popup countdown. - if (row->m_popup) { - row->m_deadline = QDateTime::currentMSecsSinceEpoch() + m_popupTimeout; - } - - // The row may have moved to another app name (replaces_id can change it). if (row->m_group != nullptr && row->m_group->m_appName != row->m_appName) { removeGroupRow(row); addGroup(row); @@ -1111,6 +1189,10 @@ void NotificationStore::handleLiveUpdate() { } } + if (row->m_popupGroup != nullptr) { + touchExpiry(row); + } + markDirty(row); } @@ -1199,7 +1281,6 @@ NotificationRow* NotificationStore::rowForNotificationId(int id) const { } void NotificationStore::handleClosed(NotificationRow* row) { - // During clear() the bulk DELETE already removed every row. if (m_clearing) { removeRow(row); return; @@ -1221,9 +1302,6 @@ void NotificationStore::handleClosed(NotificationRow* row) { row->m_group->sync(); } - // A closed row leaves the store immediately, so its group and the sidebar - // order update at once; a delegate that still holds it only delays the - // delete, never the removal. removeRow(row); } @@ -1236,14 +1314,10 @@ void NotificationStore::removeRow(NotificationRow* row) { removeGroupRow(row); m_dirty.remove(row); - // The row is gone; drop any live-notification mapping to it so - // handleLiveUpdate can never resolve a freed pointer. erase_if(m_liveRows, [row](std::pair e) { return e.second == row; }); - // A delegate animating this row out still holds it; unlock() deletes once - // the last lock is gone. if (row->m_locks.isEmpty()) { row->deleteLater(); } else { @@ -1302,7 +1376,6 @@ void NotificationStore::addGroup(NotificationRow* row) { m_groups.prepend(group); emit groupsChanged(); } else { - // Most-recently-active group first, matching the sidebar order. const qsizetype index = m_groups.indexOf(group); if (index > 0) { m_groups.move(index, 0); @@ -1333,8 +1406,6 @@ void NotificationStore::removeGroupRow(NotificationRow* row) { emit groupsChanged(); } -// Popup groups mirror m_groups over the rows currently showing as popups, so -// the popup stack can render one card per app instead of one per notification. void NotificationStore::addPopupGroup(NotificationRow* row) { if (row->m_popupGroup != nullptr) { return; @@ -1356,9 +1427,19 @@ void NotificationStore::addPopupGroup(NotificationRow* row) { } row->m_popupGroup = group; + + touchExpiry(row); + group->addRow(row); } +void NotificationStore::expireGroup(NotificationGroup* group) { + const auto rows = group->m_notifs; // setPopup() mutates the group's list + for (auto* row : rows) { + row->setPopup(false); + } +} + void NotificationStore::removePopupGroupRow(NotificationRow* row) { auto* group = row->m_popupGroup; if (group == nullptr) { @@ -1378,6 +1459,32 @@ void NotificationStore::removePopupGroupRow(NotificationRow* row) { emit popupGroupsChanged(); } +int NotificationStore::expireTimeoutMs(const NotificationRow* row) const { + if (row->m_expireTimeoutMs == 0) { + return 0; + } + + if (row->m_expireTimeoutMs < 0) { + return m_popupTimeout; + } + + return static_cast((std::min)(row->m_expireTimeoutMs, MAX_EXPIRE_MS)); +} + +int NotificationStore::groupExpireTimeoutMs( + const NotificationGroup* group) const { + const auto* newest = group->m_notifs.value(0); + return newest != nullptr ? expireTimeoutMs(newest) : m_popupTimeout; +} + +void NotificationStore::touchExpiry(NotificationRow* row) { + if (!m_autoExpire || row->m_popupGroup == nullptr) { + return; + } + + row->m_popupGroup->resetExpiry(expireTimeoutMs(row)); +} + void NotificationStore::updatePopupGroup(NotificationRow* row) { if (row->m_popup && !row->m_closed) { addPopupGroup(row); @@ -1450,10 +1557,6 @@ bool NotificationStore::openDb() { QSqlQuery query(db); const QStringList statements = { - // WAL + NORMAL: commits don't fsync per insert (the fsync is what - // made every notification insert 5-8ms). Safe with WAL: an - // application crash still recovers the WAL; only an OS/power - // failure can lose the most recent commits. "PRAGMA journal_mode=WAL", "PRAGMA synchronous=NORMAL", "CREATE TABLE IF NOT EXISTS notifications(" @@ -1479,8 +1582,6 @@ bool NotificationStore::openDb() { } } - // Databases created before progress was stored lack the column; ALTER adds - // it instead of dropping history (caelestia drops and recreates). if (!db.record("notifications").contains("progress")) { QSqlQuery alter(db); if (!alter.exec( @@ -1574,10 +1675,6 @@ void NotificationStore::loadRows() { row->m_urgency = query.value(7).toInt(); row->m_resident = query.value(8).toInt() != 0; row->m_hasActionIcons = query.value(9).toInt() != 0; - // Popup state is deliberately not restored: showing stored - // notifications as toasts again after a shell restart would - // replay an old burst, and with autoExpire off the flag never - // clears itself. The rows stay in the sidebar instead. row->m_popup = false; row->m_imageSource = query.value(12).toString(); row->m_digest = query.value(13).toString(); @@ -1649,7 +1746,6 @@ void NotificationStore::migrateLegacyJson() { row->m_urgency = object.value("urgency").toInt(1); row->m_resident = object.value("resident").toBool(); row->m_hasActionIcons = object.value("hasActionIcons").toBool(); - // Same reasoning as loadRows(): never re-toast migrated rows. row->m_popup = false; const auto time = object.value("time"); @@ -1708,8 +1804,6 @@ void NotificationStore::prune() { << query.lastError(); } - // Total cap, preferring higher urgency so a chatty app cannot push out - // an important notification. if (!query.exec( "DELETE FROM notifications WHERE id NOT IN" " (SELECT id FROM notifications ORDER BY urgency DESC, time DESC" @@ -1720,7 +1814,6 @@ void NotificationStore::prune() { << query.lastError(); } - // Per-app cap: one app flooding must not crowd out the other groups. QStringList appNames; if (query.exec("SELECT DISTINCT app_name FROM notifications")) { while (query.next()) { @@ -1753,8 +1846,6 @@ void NotificationStore::trimRows() { return; } - // Only closed, unlocked rows are droppable (open rows may still be - // acted on by the sender). Weakest first: lowest urgency, then oldest. const auto db = QSqlDatabase::database(m_uuid, false); while (m_rows.size() > MAX_ROWS) { @@ -1802,11 +1893,6 @@ void NotificationStore::startImageCache(NotificationRow* row) { const QUrl url(row->m_imageSource); - // Resolve *what* to read on the main thread (engine provider lookups and - // QIcon's theme state belong here), but do no file scanning and no - // decoding here: providers are designed to serve requestImage from non-GUI - // threads, and SVG rasterization was blocking the shell 3-9ms per - // notification. QString localPath; QQuickImageProvider* provider = nullptr; QString providerId; @@ -1825,20 +1911,9 @@ void NotificationStore::startImageCache(NotificationRow* row) { if (host != QLatin1String("qsimage") && host != QLatin1String("qspixmap")) { - // Provider urls such as image://icon/... are served by KF6's - // KIconLoader, which is not thread-safe (calling it from a - // worker crashed the shell). Only QIcon's theme state is read - // here, because the GUI thread owns it; the candidate scanning it - // feeds is pure filesystem work and runs in the worker below. A - // name the theme does not have means thousands of stat calls, and - // doing those here stalled inserts for milliseconds. If nothing - // matches, the row keeps rendering the url directly. iconName = id; theme = iconTheme(); } else { - // Transient inline notification images (image://qsimage/...). - // Provider objects are engine-owned and outlive this task; - // ids owned by a destroyed notification resolve to nothing. auto* engine = qmlEngine(this); if (engine == nullptr) { return; @@ -1864,10 +1939,6 @@ void NotificationStore::startImageCache(NotificationRow* row) { return; } - // Copy the pixels here instead of in the worker: the handle - // belongs to the sender's notification, and it can be gone by the - // time the task runs, which silently dropped the picture. This is - // a memory copy of an already decoded image, not a rasterization. inlineImage = usePixmap ? provider->requestPixmap(providerId, nullptr, QSize()) @@ -1938,8 +2009,6 @@ void NotificationStore::startImageCache(NotificationRow* row) { normalized = normalized.convertToFormat(QImage::Format_RGBA8888); - // Cache the avatar content already cut to a circle, so the view - // does not have to mask it with a layer effect every frame. const int side = (std::min)(normalized.width(), normalized.height()); const QRect square( @@ -2020,8 +2089,6 @@ void NotificationStore::storeImageBlob( row->m_digest = digest; row->m_image = QStringLiteral("image://notifimg/") + digest; - // The group reads its image through the source row and listens to - // imageChanged, so this emit is enough to keep the card in sync. emit row->imageChanged(); } } @@ -2029,9 +2096,15 @@ void NotificationStore::storeImageBlob( void NotificationStore::tick() { const qint64 now = QDateTime::currentMSecsSinceEpoch(); - for (auto* row : std::as_const(m_rows)) { - if (m_autoExpire && row->expired(now)) { - row->setPopup(false); + if (m_autoExpire) { + const auto groups = m_popupGroups; + + for (auto* group : groups) { + if (group->expired(now)) { + expireGroup(group); + } else { + group->refreshExpiry(now); + } } } diff --git a/Plugins/ZShell/Services/notificationstore.hpp b/Plugins/ZShell/Services/notificationstore.hpp index 6a0eb2e..54b6075 100644 --- a/Plugins/ZShell/Services/notificationstore.hpp +++ b/Plugins/ZShell/Services/notificationstore.hpp @@ -134,7 +134,6 @@ class NotificationRow : public QObject { friend class NotificationGroup; friend NotificationStore; - [[nodiscard]] bool expired(qint64 now) const; void refreshTimeStr(); QList m_actions; @@ -151,7 +150,7 @@ class NotificationRow : public QObject { QString m_summary; QString m_timeStr; - qint64 m_deadline = 0; + qint64 m_expireTimeoutMs = -1; qint64 m_rowId = -1; qint64 m_timeMs = 0; @@ -159,15 +158,12 @@ class NotificationRow : public QObject { QSet m_locks; QJSValue m_jsNotification; - // The store has already dropped this row from its lists, but a delegate is - // still animating it out; the delete waits for the last unlock. bool m_deleteAfterUnlock = false; bool m_closed = false; bool m_hasActionIcons = false; bool m_liveConnected = false; bool m_popup = false; - bool m_popupPaused = false; bool m_resident = false; int m_notificationId = 0; @@ -186,6 +182,13 @@ class NotificationGroup : public QObject { Q_PROPERTY(QString appIcon READ appIcon NOTIFY appIconChanged) Q_PROPERTY(QString appName READ appName CONSTANT) Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY(bool expireActive READ expireActive NOTIFY expireActiveChanged) + Q_PROPERTY(bool expirePaused READ expirePaused NOTIFY expirePausedChanged) + Q_PROPERTY( + qreal expireProgress READ expireProgress NOTIFY expireProgressChanged) + Q_PROPERTY( + int expireRemaining READ expireRemaining NOTIFY expireRemainingChanged) + Q_PROPERTY(int expireTimeout READ expireTimeout NOTIFY expireTimeoutChanged) Q_PROPERTY(QString firstSummary READ firstSummary NOTIFY firstSummaryChanged) Q_PROPERTY(QString image READ image NOTIFY imageChanged) Q_PROPERTY( @@ -201,21 +204,34 @@ class NotificationGroup : public QObject { [[nodiscard]] QString appIcon() const; [[nodiscard]] QString appName() const; [[nodiscard]] int count() const; + [[nodiscard]] bool expireActive() const; + [[nodiscard]] bool expirePaused() const; + [[nodiscard]] qreal expireProgress() const; + [[nodiscard]] int expireRemaining() const; + [[nodiscard]] int expireTimeout() const; [[nodiscard]] QString firstSummary() const; [[nodiscard]] QString image() const; [[nodiscard]] QQmlListProperty notifs(); [[nodiscard]] QString timeStr() const; [[nodiscard]] int urgency() const; + Q_INVOKABLE void holdExpiry(); Q_INVOKABLE bool isSameAppIcon(const QString& path) const; Q_INVOKABLE bool isSameImage(const QString& path) const; Q_INVOKABLE void lock(QObject* item); + Q_INVOKABLE void releaseExpiry(); Q_INVOKABLE void unlock(QObject* item); signals: void activeNotifsChanged(); void appIconChanged(); void countChanged(); + void expireActiveChanged(); + void expirePausedChanged(); + void expireProgressChanged(); + void expireRemainingChanged(); + void expireTimerRestarted(); + void expireTimeoutChanged(); void firstSummaryChanged(); void imageChanged(); void notifsChanged(); @@ -226,17 +242,19 @@ class NotificationGroup : public QObject { friend NotificationStore; void addRow(NotificationRow* row); + void clearExpiry(); + bool expired(qint64 now) const; void refreshActive(); + void refreshExpiry(qint64 now); void release(); void removeRow(NotificationRow* row); + void resetExpiry(int timeout); + void setExpireActive(bool active); void sync(); QList m_active; QList m_notifs; - // The group reads image/appIcon through the row it took them from, so a - // row swapping between its raw source and the cached blob keeps the card - // in sync instead of holding a stale copy. QPointer m_iconSource; QPointer m_imageSource; QSet m_locks; @@ -245,6 +263,15 @@ class NotificationGroup : public QObject { QString m_firstSummary; QString m_timeStr; + qint64 m_expireDeadline = 0; + qint64 m_expireHeld = 0; + qreal m_expireProgress = 0; + int m_expireRemaining = 0; + int m_expireTimeout = 0; + + bool m_expireActive = false; + bool m_expirePaused = false; + int m_count = 0; int m_urgency = 1; bool m_releasing = false; @@ -312,6 +339,8 @@ class NotificationStore : public QObject { void applyNotifConfig(const config::Notifs* notifs); void closeDb(); void copyFields(NotificationRow* row, QObject* notification); + [[nodiscard]] int expireTimeoutMs(const NotificationRow* row) const; + void expireGroup(NotificationGroup* group); void flush(); void handleClosed(NotificationRow* row); void insertDbRow(NotificationRow* row); @@ -323,12 +352,12 @@ class NotificationStore : public QObject { void prune(); void addPopupGroup(NotificationRow* row); void removePopupGroupRow(NotificationRow* row); + void touchExpiry(NotificationRow* row); + [[nodiscard]] int groupExpireTimeoutMs(const NotificationGroup* group) const; void updatePopupGroup(NotificationRow* row); void registerProvider(); void removeGroupRow(NotificationRow* row); void removeRow(NotificationRow* row); - // Coalesced rowsChanged: a burst of inserts/removes triggers one QML - // rebind on the next event loop turn instead of one per notification. void scheduleRowsChanged(); [[nodiscard]] NotificationRow* rowForNotificationId(int id) const; void startImageCache(NotificationRow* row);