feat: expire progress bar on popup notifs
This commit is contained in:
@@ -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<QString, QString> pathCache;
|
||||
static QString cacheSignature;
|
||||
@@ -171,7 +147,6 @@ QString iconFileForName(const QString& name, const IconTheme& theme) {
|
||||
const QString suffix =
|
||||
candidateName + "." + extension;
|
||||
|
||||
// hicolor: <size>/<context>/<ext>/<name>.<ext>
|
||||
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
|
||||
// <size>/<context>/<name>.<ext>
|
||||
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: <context>/<name>.<ext>
|
||||
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<int>(remaining);
|
||||
emit expireRemainingChanged();
|
||||
}
|
||||
|
||||
const qreal progress =
|
||||
m_expireTimeout > 0
|
||||
? (std::min)(1.0, static_cast<qreal>(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<qint64>(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<qint64>(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<const QObject*, NotificationRow*> 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<int>((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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,6 @@ class NotificationRow : public QObject {
|
||||
friend class NotificationGroup;
|
||||
friend NotificationStore;
|
||||
|
||||
[[nodiscard]] bool expired(qint64 now) const;
|
||||
void refreshTimeStr();
|
||||
|
||||
QList<NotificationActionRow*> 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<QObject*> 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<NotificationRow> 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<NotificationRow*> m_active;
|
||||
QList<NotificationRow*> 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<NotificationRow> m_iconSource;
|
||||
QPointer<NotificationRow> m_imageSource;
|
||||
QSet<QObject*> 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);
|
||||
|
||||
Reference in New Issue
Block a user