Plasma-workspace

abstractnotificationsmodel.cpp
1/*
2 SPDX-FileCopyrightText: 2018-2019 Kai Uwe Broulik <kde@privat.broulik.de>
3
4 SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
5*/
6
7#include "abstractnotificationsmodel.h"
8#include "abstractnotificationsmodel_p.h"
9#include "debug.h"
10
11#include "utils_p.h"
12
13#include "notification_p.h"
14
15#include <QDBusConnection>
16#include <QDebug>
17#include <QProcess>
18#include <QTextDocumentFragment>
19
20#include <KLocalizedString>
21#include <KShell>
22
23#include <algorithm>
24#include <chrono>
25#include <functional>
26
27using namespace std::chrono_literals;
28
29static constexpr int s_notificationsLimit = 1000;
30
31using namespace NotificationManager;
32
33AbstractNotificationsModel::Private::Private(AbstractNotificationsModel *q)
34 : q(q)
35 , lastRead(QDateTime::currentDateTimeUtc())
36{
37 pendingRemovalTimer.setSingleShot(true);
38 pendingRemovalTimer.setInterval(50ms);
39 connect(&pendingRemovalTimer, &QTimer::timeout, q, [this, q] {
40 QList<int> rowsToBeRemoved;
41 rowsToBeRemoved.reserve(pendingRemovals.count());
42 for (uint id : std::as_const(pendingRemovals)) {
43 Notification::Private::s_imageCache.remove(id);
44 int row = q->rowOfNotification(id); // oh the complexity...
45 if (row == -1) {
46 continue;
47 }
48 rowsToBeRemoved.append(row);
49 }
50
51 removeRows(rowsToBeRemoved);
52 });
53
54 notificationWatcher.setConnection(QDBusConnection::sessionBus());
55 notificationWatcher.setWatchMode(QDBusServiceWatcher::WatchForUnregistration);
56 // Forcibly expire the notification once the owning application exits, in order to
57 // remove the interactive buttons from the notification in the history and/or make the
58 // popup disappear.
59 connect(&notificationWatcher, &QDBusServiceWatcher::serviceUnregistered, q, [this, q](const QString &serviceName) {
60 for (const Notification &notification : std::as_const(notifications)) {
61 if (notification.dBusService() == serviceName) {
62 q->expire(notification.id());
63 }
64 }
65
66 notificationWatcher.removeWatchedService(serviceName);
67 });
68}
69
70AbstractNotificationsModel::Private::~Private()
71{
72 qDeleteAll(notificationTimeouts);
73 notificationTimeouts.clear();
74}
75
76void AbstractNotificationsModel::Private::onNotificationAdded(const Notification &notification)
77{
78 // Once we reach a certain insane number of notifications discard some old ones
79 // as we keep pixmaps around etc
80 if (notifications.count() >= s_notificationsLimit) {
81 const int cleanupCount = s_notificationsLimit / 2;
82 qCDebug(NOTIFICATIONMANAGER) << "Reached the notification limit of" << s_notificationsLimit << ", discarding the oldest" << cleanupCount
83 << "notifications";
84 q->beginRemoveRows(QModelIndex(), 0, cleanupCount - 1);
85 for (int i = 0; i < cleanupCount; ++i) {
86 Notification::Private::s_imageCache.remove(notifications.at(0).id());
87 q->stopTimeout(notifications.first().id());
88 notifications.removeAt(0);
89 // TODO close gracefully?
90 }
91 q->endRemoveRows();
92 }
93
94 setupNotificationTimeout(notification);
95 // Only set up watchers for notifications with actions, since some apps (e.g. `notify-send`) may just
96 // dispatch a notification and then immediately exit
97 if (notification.hasDefaultAction() || notification.hasReplyAction() || !notification.actionNames().empty()) {
98 notificationWatcher.addWatchedService(notification.dBusService());
99 }
100
101 q->beginInsertRows(QModelIndex(), notifications.count(), notifications.count());
102 notifications.append(std::move(notification));
103 q->endInsertRows();
104}
105
106void AbstractNotificationsModel::Private::onNotificationReplaced(uint replacedId, const Notification &notification)
107{
108 const int row = q->rowOfNotification(replacedId);
109
110 if (row == -1) {
111 qCWarning(NOTIFICATIONMANAGER) << "Trying to replace notification with id" << replacedId
112 << "which doesn't exist, creating a new one. This is an application bug!";
113 onNotificationAdded(notification);
114 return;
115 }
116
117 setupNotificationTimeout(notification);
118
119 Notification newNotification(notification);
120
121 const Notification &oldNotification = notifications.at(row);
122 // As per spec a notification must be replaced atomically with no visual cues.
123 // Transfer over properties that might cause this, such as unread showing the bell again,
124 // or created() which should indicate the original date, whereas updated() is when it was last updated
125 newNotification.setCreated(oldNotification.created());
126 newNotification.setExpired(oldNotification.expired());
127 newNotification.setDismissed(oldNotification.dismissed());
128 newNotification.setRead(oldNotification.read());
129 newNotification.setWasAddedDuringInhibition(Server::self().inhibited());
130
131 notifications[row] = newNotification;
132 const QModelIndex idx = q->index(row, 0);
133 Q_EMIT q->dataChanged(idx, idx);
134}
135
136void AbstractNotificationsModel::Private::onNotificationRemoved(uint removedId, Server::CloseReason reason)
137{
138 const int row = q->rowOfNotification(removedId);
139 if (row == -1) {
140 return;
141 }
142
143 q->stopTimeout(removedId);
144
145 // When a notification expired, keep it around in the history and mark it as such
146 if (reason == Server::CloseReason::Expired) {
147 const QModelIndex idx = q->index(row, 0);
148
149 Notification &notification = notifications[row];
150 notification.setExpired(true);
151
152 // Since the notification is "closed" it cannot have any actions
153 // unless it is "resident" which we don't support
154 notification.setActions(QStringList());
155
156 // clang-format off
157 Q_EMIT q->dataChanged(idx, idx, {
159 // TODO only Q_EMIT those if actually changed?
165 });
166 // clang-format on
167
168 return;
169 }
170
171 // Otherwise if explicitly closed by either user or app, mark it for removal
172 // some apps are notorious for closing a bunch of notifications at once
173 // causing newer notifications to move up and have a dialogs created for them
174 // just to then be discarded causing excess CPU usage
175 if (!pendingRemovals.contains(removedId)) {
176 pendingRemovals.append(removedId);
177 }
178
179 if (!pendingRemovalTimer.isActive()) {
180 pendingRemovalTimer.start();
181 }
182}
183
184void AbstractNotificationsModel::Private::setupNotificationTimeout(const Notification &notification)
185{
186 if (notification.timeout() == 0) {
187 // In case it got replaced by a persistent notification
188 q->stopTimeout(notification.id());
189 return;
190 }
191
192 QTimer *timer = notificationTimeouts.value(notification.id());
193 if (!timer) {
194 timer = new QTimer();
195 timer->setSingleShot(true);
196
197 connect(timer, &QTimer::timeout, q, [this, timer] {
198 const uint id = timer->property("notificationId").toUInt();
199 q->expire(id);
200 });
201 notificationTimeouts.insert(notification.id(), timer);
202 }
203
204 timer->stop();
205 timer->setProperty("notificationId", notification.id());
206 timer->setInterval(60000 /*1min*/ + (notification.timeout() == -1 ? 120000 /*2min, max configurable default timeout*/ : notification.timeout()));
207 timer->start();
208}
209
210void AbstractNotificationsModel::Private::removeRows(const QList<int> &rows)
211{
212 if (rows.isEmpty()) {
213 return;
214 }
215
216 QList<int> rowsToBeRemoved(rows);
217 std::sort(rowsToBeRemoved.begin(), rowsToBeRemoved.end());
218
219 QList<QPair<int, int>> clearQueue;
220
221 QPair<int, int> clearRange{rowsToBeRemoved.first(), rowsToBeRemoved.first()};
222
223 for (int row : rowsToBeRemoved) {
224 if (row > clearRange.second + 1) {
225 clearQueue.append(clearRange);
226 clearRange.first = row;
227 }
228
229 clearRange.second = row;
230 }
231
232 if (clearQueue.isEmpty() || clearQueue.last() != clearRange) {
233 clearQueue.append(clearRange);
234 }
235
236 int rowsRemoved = 0;
237
238 for (int i = clearQueue.count() - 1; i >= 0; --i) {
239 const auto &range = clearQueue.at(i);
240
241 q->beginRemoveRows(QModelIndex(), range.first, range.second);
242 for (int j = range.second; j >= range.first; --j) {
243 notifications.removeAt(j);
244 ++rowsRemoved;
245 }
246 q->endRemoveRows();
247 }
248
249 Q_ASSERT(rowsRemoved == rowsToBeRemoved.count());
250
251 pendingRemovals.clear();
252}
253
254int AbstractNotificationsModel::rowOfNotification(uint id) const
255{
256 auto it = std::find_if(d->notifications.constBegin(), d->notifications.constEnd(), [id](const Notification &item) {
257 return item.id() == id;
258 });
259
260 if (it == d->notifications.constEnd()) {
261 return -1;
262 }
263
264 return std::distance(d->notifications.constBegin(), it);
265}
266
267AbstractNotificationsModel::AbstractNotificationsModel()
268 : QAbstractListModel(nullptr)
269 , d(new Private(this))
270{
271}
272
273AbstractNotificationsModel::~AbstractNotificationsModel() = default;
274
275QDateTime AbstractNotificationsModel::lastRead() const
276{
277 return d->lastRead;
278}
279
280void AbstractNotificationsModel::setLastRead(const QDateTime &lastRead)
281{
282 if (d->lastRead != lastRead) {
283 d->lastRead = lastRead;
284 Q_EMIT lastReadChanged();
285 }
286}
287
288QWindow *AbstractNotificationsModel::window() const
289{
290 return d->window;
291}
292
293void AbstractNotificationsModel::setWindow(QWindow *window)
294{
295 if (d->window == window) {
296 return;
297 }
298 if (d->window) {
299 disconnect(d->window, &QObject::destroyed, this, nullptr);
300 }
301 d->window = window;
302 if (d->window) {
303 connect(d->window, &QObject::destroyed, this, [this] {
304 setWindow(nullptr);
305 });
306 }
307 Q_EMIT windowChanged(window);
308}
309
310QVariant AbstractNotificationsModel::data(const QModelIndex &index, int role) const
311{
312 if (!checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)) {
313 return QVariant();
314 }
315
316 const Notification &notification = d->notifications.at(index.row());
317
318 switch (role) {
320 return notification.id();
323
325 if (notification.created().isValid()) {
326 return notification.created();
327 }
328 break;
330 if (notification.updated().isValid()) {
331 return notification.updated();
332 }
333 break;
335 return notification.summary();
337 return notification.body();
339 return i18nc("@info %1 notification body %2 application name",
340 "%1 from %2",
341 QTextDocumentFragment::fromHtml(notification.body()).toPlainText(),
342 notification.applicationName());
344 if (notification.image().isNull()) {
345 return notification.icon();
346 }
347 break;
349 if (!notification.image().isNull()) {
350 return notification.image();
351 }
352 break;
354 return notification.desktopEntry();
356 return notification.notifyRcName();
357
359 return notification.applicationName();
361 return notification.applicationIconName();
363 return notification.originName();
364
366 return notification.actionNames();
368 return notification.actionLabels();
370 return notification.hasDefaultAction();
372 return notification.defaultActionLabel();
373
375 return QVariant::fromValue(notification.urls());
376
378 return static_cast<int>(notification.urgency());
380 return notification.userActionFeedback();
381
383 return notification.timeout();
384
386 return true;
388 return notification.configurable();
390 return notification.configureActionLabel();
391
393 return notification.category();
394
396 return notification.expired();
398 return notification.read();
400 return notification.resident();
402 return notification.transient();
403
405 return notification.wasAddedDuringInhibition();
406
408 return notification.hasReplyAction();
410 return notification.replyActionLabel();
412 return notification.replyPlaceholderText();
414 return notification.replySubmitButtonText();
416 return notification.replySubmitButtonIconName();
418 return notification.hints();
419 }
420
421 return QVariant();
422}
423
424bool AbstractNotificationsModel::setData(const QModelIndex &index, const QVariant &value, int role)
425{
426 if (!checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)) {
427 return false;
428 }
429
430 Notification &notification = d->notifications[index.row()];
431 bool dirty = false;
432
433 switch (role) {
435 if (value.toBool() != notification.read()) {
436 notification.setRead(value.toBool());
437 dirty = true;
438 }
439 break;
440 // Allows to mark a notification as expired without actually sending that out through expire() for persistency
442 if (value.toBool() != notification.expired()) {
443 notification.setExpired(value.toBool());
444 dirty = true;
445 }
446 break;
448 if (bool v = value.toBool(); v != notification.wasAddedDuringInhibition()) {
449 notification.setWasAddedDuringInhibition(v);
450 dirty = true;
451 }
452 break;
453 }
454
455 if (dirty) {
456 Q_EMIT dataChanged(index, index, {role});
457 }
458
459 return dirty;
460}
461
462int AbstractNotificationsModel::rowCount(const QModelIndex &parent) const
463{
464 if (parent.isValid()) {
465 return 0;
466 }
467
468 return d->notifications.count();
469}
470
471QHash<int, QByteArray> AbstractNotificationsModel::roleNames() const
472{
473 return Utils::roleNames();
474}
475
476void AbstractNotificationsModel::startTimeout(uint notificationId)
477{
478 const int row = rowOfNotification(notificationId);
479 if (row == -1) {
480 return;
481 }
482
483 const Notification &notification = d->notifications.at(row);
484
485 if (!notification.timeout() || notification.expired()) {
486 return;
487 }
488
489 d->setupNotificationTimeout(notification);
490}
491
492void AbstractNotificationsModel::stopTimeout(uint notificationId)
493{
494 delete d->notificationTimeouts.take(notificationId);
495}
496
497void AbstractNotificationsModel::clear(Notifications::ClearFlags flags)
498{
499 if (d->notifications.isEmpty()) {
500 return;
501 }
502
503 QList<int> rowsToRemove;
504
505 for (int i = 0; i < d->notifications.count(); ++i) {
506 const Notification &notification = d->notifications.at(i);
507
508 if (flags.testFlag(Notifications::ClearExpired) && (notification.expired() || notification.wasAddedDuringInhibition())) {
509 close(notification.id());
510 }
511 }
512}
513
514void AbstractNotificationsModel::onNotificationAdded(const Notification &notification)
515{
516 d->onNotificationAdded(notification);
517}
518
519void AbstractNotificationsModel::onNotificationReplaced(uint replacedId, const Notification &notification)
520{
521 d->onNotificationReplaced(replacedId, notification);
522}
523
524void AbstractNotificationsModel::onNotificationRemoved(uint notificationId, Server::CloseReason reason)
525{
526 d->onNotificationRemoved(notificationId, reason);
527}
528
529void AbstractNotificationsModel::setupNotificationTimeout(const Notification &notification)
530{
531 d->setupNotificationTimeout(notification);
532}
533
534const QList<Notification> &AbstractNotificationsModel::notifications()
535{
536 return d->notifications;
537}
538
539#include "moc_abstractnotificationsmodel.cpp"
Represents a single notification.
@ NotificationType
This item represents a notification.
@ ApplicationNameRole
The user-visible name of the application (e.g. Spectacle)
@ ConfigurableRole
Whether the notification can be configured because a desktopEntry or notifyRcName is known,...
@ SummaryRole
The notification summary.
@ HasReplyActionRole
Whether the notification has a reply action.
@ UpdatedRole
When the notification was last updated, invalid when it hasn't been updated.
@ NotifyRcNameRole
The notifyrc name (e.g. spectaclerc) of the application that sent the notification.
@ CategoryRole
The (optional) category of the notification.
@ ReadRole
Whether the notification got read by the user.
@ BodyRole
The notification body text.
@ ResidentRole
Whether the notification should keep its actions even when they were invoked.
@ OriginNameRole
The name of the device or account the notification originally came from, e.g.
@ DefaultActionLabelRole
The user-visible label of the default action, typically not shown as the popup itself becomes clickab...
@ ActionLabelsRole
The user-visible labels of the actions, excluding the default and settings action,...
@ IconNameRole
The notification main icon name, which is not the application icon.
@ ReplySubmitButtonTextRole
A custom text for the reply submit button, e.g. "Submit Comment".
@ HasDefaultActionRole
Whether the notification has a default action, which is one that is invoked when the popup itself is ...
@ IdRole
A notification identifier. This can be uint notification ID or string application job source.
@ DesktopEntryRole
The desktop entry (without .desktop suffix, e.g. org.kde.spectacle) of the application that sent the ...
@ ApplicationIconNameRole
The icon name of the application.
@ ConfigureActionLabelRole
The user-visible label for the settings action.
@ ReplySubmitButtonIconNameRole
A custom icon name for the reply submit button.
@ ActionNamesRole
The IDs of the actions, excluding the default and settings action, e.g. [action1, action2].
@ WasAddedDuringInhibitionRole
Whether the notification was added while inhibition was active.
@ ExpiredRole
The notification timed out and closed. Actions on it cannot be invoked anymore.
@ HintsRole
To provide extra data to a notification server that the server may be able to make use of.
@ CreatedRole
When the notification was first created.
@ UserActionFeedbackRole
Whether this notification is a response/confirmation to an explicit user action.
@ ReplyPlaceholderTextRole
A custom placeholder text for the reply action, e.g. "Reply to Max...".
@ TimeoutRole
The timeout for the notification in milliseconds.
@ ClosableRole
Whether the item can be closed. Notifications are always closable, jobs are only when in JobStateStop...
@ UrgencyRole
The notification urgency, either LowUrgency, NormalUrgency, or CriticalUrgency. Jobs do not have an u...
@ ImageRole
The notification main image, which is not the application icon. Only valid for pixmap icons.
@ ReplyActionLabelRole
The user-visible label for the reply action.
@ UrlsRole
A list of URLs associated with the notification, e.g. a path to a screenshot that was just taken or i...
@ TypeRole
The type of model entry, either NotificationType or JobType.
@ TransientRole
Whether the notification is transient and should not be kept in history.
CloseReason
The reason a notification was closed.
Definition server.h:69
@ Expired
The notification timed out.
Definition server.h:70
QString i18nc(const char *context, const char *text, const TYPE &arg...)
void beginInsertRows(const QModelIndex &parent, int first, int last)
void beginRemoveRows(const QModelIndex &parent, int first, int last)
bool checkIndex(const QModelIndex &index, CheckIndexOptions options) const const
void dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QList< int > &roles)
virtual Qt::ItemFlags flags(const QModelIndex &index) const const
virtual QModelIndex index(int row, int column, const QModelIndex &parent) const const=0
virtual QModelIndex parent(const QModelIndex &index) const const=0
virtual QModelIndex index(int row, int column, const QModelIndex &parent) const const override
QDBusConnection sessionBus()
void serviceUnregistered(const QString &serviceName)
void append(QList< T > &&value)
const_reference at(qsizetype i) const const
iterator begin()
qsizetype count() const const
iterator end()
T & first()
bool isEmpty() const const
T & last()
void reserve(qsizetype size)
Q_EMITQ_EMIT
QMetaObject::Connection connect(const QObject *sender, PointerToMemberFunction signal, Functor functor)
void destroyed(QObject *obj)
bool disconnect(const QMetaObject::Connection &connection)
QVariant property(const char *name) const const
bool setProperty(const char *name, QVariant &&value)
AccessibleDescriptionRole
QTextDocumentFragment fromHtml(const QString &text, const QTextDocument *resourceProvider)
QString toPlainText() const const
QFuture< ArgsType< Signal > > connect(Sender *sender, Signal signal)
void setInterval(int msec)
void setSingleShot(bool singleShot)
void start()
void stop()
void timeout()
QVariant fromValue(T &&value)
bool toBool() const const
uint toUInt(bool *ok) const const
This file is part of the KDE documentation.
Documentation copyright © 1996-2025 The KDE developers.
Generated on Fri Mar 28 2025 11:53:53 by doxygen 1.13.2 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.