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 <QDebug>
16#include <QProcess>
17#include <QTextDocumentFragment>
18
19#include <KLocalizedString>
20#include <KShell>
21
22#include <algorithm>
23#include <chrono>
24#include <functional>
25
26using namespace std::chrono_literals;
27
28static constexpr int s_notificationsLimit = 1000;
29
30using namespace NotificationManager;
31
32AbstractNotificationsModel::Private::Private(AbstractNotificationsModel *q)
33 : q(q)
34 , lastRead(QDateTime::currentDateTimeUtc())
35{
36 pendingRemovalTimer.setSingleShot(true);
37 pendingRemovalTimer.setInterval(50ms);
38 connect(&pendingRemovalTimer, &QTimer::timeout, q, [this, q] {
39 QList<int> rowsToBeRemoved;
40 rowsToBeRemoved.reserve(pendingRemovals.count());
41 for (uint id : std::as_const(pendingRemovals)) {
42 Notification::Private::s_imageCache.remove(id);
43 int row = q->rowOfNotification(id); // oh the complexity...
44 if (row == -1) {
45 continue;
46 }
47 rowsToBeRemoved.append(row);
48 }
49
50 removeRows(rowsToBeRemoved);
51 });
52}
53
54AbstractNotificationsModel::Private::~Private()
55{
56 qDeleteAll(notificationTimeouts);
57 notificationTimeouts.clear();
58}
59
60void AbstractNotificationsModel::Private::onNotificationAdded(const Notification &notification)
61{
62 // Once we reach a certain insane number of notifications discard some old ones
63 // as we keep pixmaps around etc
64 if (notifications.count() >= s_notificationsLimit) {
65 const int cleanupCount = s_notificationsLimit / 2;
66 qCDebug(NOTIFICATIONMANAGER) << "Reached the notification limit of" << s_notificationsLimit << ", discarding the oldest" << cleanupCount
67 << "notifications";
68 q->beginRemoveRows(QModelIndex(), 0, cleanupCount - 1);
69 for (int i = 0; i < cleanupCount; ++i) {
70 Notification::Private::s_imageCache.remove(notifications.at(0).id());
71 notifications.removeAt(0);
72 // TODO close gracefully?
73 }
74 q->endRemoveRows();
75 }
76
77 setupNotificationTimeout(notification);
78
79 q->beginInsertRows(QModelIndex(), notifications.count(), notifications.count());
80 notifications.append(std::move(notification));
81 q->endInsertRows();
82}
83
84void AbstractNotificationsModel::Private::onNotificationReplaced(uint replacedId, const Notification &notification)
85{
86 const int row = q->rowOfNotification(replacedId);
87
88 if (row == -1) {
89 qCWarning(NOTIFICATIONMANAGER) << "Trying to replace notification with id" << replacedId
90 << "which doesn't exist, creating a new one. This is an application bug!";
91 onNotificationAdded(notification);
92 return;
93 }
94
95 setupNotificationTimeout(notification);
96
97 Notification newNotification(notification);
98
99 const Notification &oldNotification = notifications.at(row);
100 // As per spec a notification must be replaced atomically with no visual cues.
101 // Transfer over properties that might cause this, such as unread showing the bell again,
102 // or created() which should indicate the original date, whereas updated() is when it was last updated
103 newNotification.setCreated(oldNotification.created());
104 newNotification.setExpired(oldNotification.expired());
105 newNotification.setDismissed(oldNotification.dismissed());
106 newNotification.setRead(oldNotification.read());
107 newNotification.setWasAddedDuringInhibition(Server::self().inhibited());
108
109 notifications[row] = newNotification;
110 const QModelIndex idx = q->index(row, 0);
111 Q_EMIT q->dataChanged(idx, idx);
112}
113
114void AbstractNotificationsModel::Private::onNotificationRemoved(uint removedId, Server::CloseReason reason)
115{
116 const int row = q->rowOfNotification(removedId);
117 if (row == -1) {
118 return;
119 }
120
121 q->stopTimeout(removedId);
122
123 // When a notification expired, keep it around in the history and mark it as such
124 if (reason == Server::CloseReason::Expired) {
125 const QModelIndex idx = q->index(row, 0);
126
127 Notification &notification = notifications[row];
128 notification.setExpired(true);
129
130 // Since the notification is "closed" it cannot have any actions
131 // unless it is "resident" which we don't support
132 notification.setActions(QStringList());
133
134 // clang-format off
135 Q_EMIT q->dataChanged(idx, idx, {
137 // TODO only Q_EMIT those if actually changed?
143 });
144 // clang-format on
145
146 return;
147 }
148
149 // Otherwise if explicitly closed by either user or app, mark it for removal
150 // some apps are notorious for closing a bunch of notifications at once
151 // causing newer notifications to move up and have a dialogs created for them
152 // just to then be discarded causing excess CPU usage
153 if (!pendingRemovals.contains(removedId)) {
154 pendingRemovals.append(removedId);
155 }
156
157 if (!pendingRemovalTimer.isActive()) {
158 pendingRemovalTimer.start();
159 }
160}
161
162void AbstractNotificationsModel::Private::setupNotificationTimeout(const Notification &notification)
163{
164 if (notification.timeout() == 0) {
165 // In case it got replaced by a persistent notification
166 q->stopTimeout(notification.id());
167 return;
168 }
169
170 QTimer *timer = notificationTimeouts.value(notification.id());
171 if (!timer) {
172 timer = new QTimer();
173 timer->setSingleShot(true);
174
175 connect(timer, &QTimer::timeout, q, [this, timer] {
176 const uint id = timer->property("notificationId").toUInt();
177 q->expire(id);
178 });
179 notificationTimeouts.insert(notification.id(), timer);
180 }
181
182 timer->stop();
183 timer->setProperty("notificationId", notification.id());
184 timer->setInterval(60000 /*1min*/ + (notification.timeout() == -1 ? 120000 /*2min, max configurable default timeout*/ : notification.timeout()));
185 timer->start();
186}
187
188void AbstractNotificationsModel::Private::removeRows(const QList<int> &rows)
189{
190 if (rows.isEmpty()) {
191 return;
192 }
193
194 QList<int> rowsToBeRemoved(rows);
195 std::sort(rowsToBeRemoved.begin(), rowsToBeRemoved.end());
196
197 QList<QPair<int, int>> clearQueue;
198
199 QPair<int, int> clearRange{rowsToBeRemoved.first(), rowsToBeRemoved.first()};
200
201 for (int row : rowsToBeRemoved) {
202 if (row > clearRange.second + 1) {
203 clearQueue.append(clearRange);
204 clearRange.first = row;
205 }
206
207 clearRange.second = row;
208 }
209
210 if (clearQueue.isEmpty() || clearQueue.last() != clearRange) {
211 clearQueue.append(clearRange);
212 }
213
214 int rowsRemoved = 0;
215
216 for (int i = clearQueue.count() - 1; i >= 0; --i) {
217 const auto &range = clearQueue.at(i);
218
219 q->beginRemoveRows(QModelIndex(), range.first, range.second);
220 for (int j = range.second; j >= range.first; --j) {
221 notifications.removeAt(j);
222 ++rowsRemoved;
223 }
224 q->endRemoveRows();
225 }
226
227 Q_ASSERT(rowsRemoved == rowsToBeRemoved.count());
228
229 pendingRemovals.clear();
230}
231
232int AbstractNotificationsModel::rowOfNotification(uint id) const
233{
234 auto it = std::find_if(d->notifications.constBegin(), d->notifications.constEnd(), [id](const Notification &item) {
235 return item.id() == id;
236 });
237
238 if (it == d->notifications.constEnd()) {
239 return -1;
240 }
241
242 return std::distance(d->notifications.constBegin(), it);
243}
244
245AbstractNotificationsModel::AbstractNotificationsModel()
246 : QAbstractListModel(nullptr)
247 , d(new Private(this))
248{
249}
250
251AbstractNotificationsModel::~AbstractNotificationsModel() = default;
252
253QDateTime AbstractNotificationsModel::lastRead() const
254{
255 return d->lastRead;
256}
257
258void AbstractNotificationsModel::setLastRead(const QDateTime &lastRead)
259{
260 if (d->lastRead != lastRead) {
261 d->lastRead = lastRead;
262 Q_EMIT lastReadChanged();
263 }
264}
265
266QWindow *AbstractNotificationsModel::window() const
267{
268 return d->window;
269}
270
271void AbstractNotificationsModel::setWindow(QWindow *window)
272{
273 if (d->window == window) {
274 return;
275 }
276 if (d->window) {
277 disconnect(d->window, &QObject::destroyed, this, nullptr);
278 }
279 d->window = window;
280 if (d->window) {
281 connect(d->window, &QObject::destroyed, this, [this] {
282 setWindow(nullptr);
283 });
284 }
285 Q_EMIT windowChanged(window);
286}
287
288QVariant AbstractNotificationsModel::data(const QModelIndex &index, int role) const
289{
290 if (!checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)) {
291 return QVariant();
292 }
293
294 const Notification &notification = d->notifications.at(index.row());
295
296 switch (role) {
298 return notification.id();
301
303 if (notification.created().isValid()) {
304 return notification.created();
305 }
306 break;
308 if (notification.updated().isValid()) {
309 return notification.updated();
310 }
311 break;
313 return notification.summary();
315 return notification.body();
317 return i18nc("@info %1 notification body %2 application name",
318 "%1 from %2",
319 QTextDocumentFragment::fromHtml(notification.body()).toPlainText(),
320 notification.applicationName());
322 if (notification.image().isNull()) {
323 return notification.icon();
324 }
325 break;
327 if (!notification.image().isNull()) {
328 return notification.image();
329 }
330 break;
332 return notification.desktopEntry();
334 return notification.notifyRcName();
335
337 return notification.applicationName();
339 return notification.applicationIconName();
341 return notification.originName();
342
344 return notification.actionNames();
346 return notification.actionLabels();
348 return notification.hasDefaultAction();
350 return notification.defaultActionLabel();
351
353 return QVariant::fromValue(notification.urls());
354
356 return static_cast<int>(notification.urgency());
358 return notification.userActionFeedback();
359
361 return notification.timeout();
362
364 return true;
366 return notification.configurable();
368 return notification.configureActionLabel();
369
371 return notification.category();
372
374 return notification.expired();
376 return notification.read();
378 return notification.resident();
380 return notification.transient();
381
383 return notification.wasAddedDuringInhibition();
384
386 return notification.hasReplyAction();
388 return notification.replyActionLabel();
390 return notification.replyPlaceholderText();
392 return notification.replySubmitButtonText();
394 return notification.replySubmitButtonIconName();
395 }
396
397 return QVariant();
398}
399
400bool AbstractNotificationsModel::setData(const QModelIndex &index, const QVariant &value, int role)
401{
402 if (!checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)) {
403 return false;
404 }
405
406 Notification &notification = d->notifications[index.row()];
407 bool dirty = false;
408
409 switch (role) {
411 if (value.toBool() != notification.read()) {
412 notification.setRead(value.toBool());
413 dirty = true;
414 }
415 break;
416 // Allows to mark a notification as expired without actually sending that out through expire() for persistency
418 if (value.toBool() != notification.expired()) {
419 notification.setExpired(value.toBool());
420 dirty = true;
421 }
422 break;
424 if (bool v = value.toBool(); v != notification.wasAddedDuringInhibition()) {
425 notification.setWasAddedDuringInhibition(v);
426 dirty = true;
427 }
428 break;
429 }
430
431 if (dirty) {
432 Q_EMIT dataChanged(index, index, {role});
433 }
434
435 return dirty;
436}
437
438int AbstractNotificationsModel::rowCount(const QModelIndex &parent) const
439{
440 if (parent.isValid()) {
441 return 0;
442 }
443
444 return d->notifications.count();
445}
446
447QHash<int, QByteArray> AbstractNotificationsModel::roleNames() const
448{
449 return Utils::roleNames();
450}
451
452void AbstractNotificationsModel::startTimeout(uint notificationId)
453{
454 const int row = rowOfNotification(notificationId);
455 if (row == -1) {
456 return;
457 }
458
459 const Notification &notification = d->notifications.at(row);
460
461 if (!notification.timeout() || notification.expired()) {
462 return;
463 }
464
465 d->setupNotificationTimeout(notification);
466}
467
468void AbstractNotificationsModel::stopTimeout(uint notificationId)
469{
470 delete d->notificationTimeouts.take(notificationId);
471}
472
473void AbstractNotificationsModel::clear(Notifications::ClearFlags flags)
474{
475 if (d->notifications.isEmpty()) {
476 return;
477 }
478
479 QList<int> rowsToRemove;
480
481 for (int i = 0; i < d->notifications.count(); ++i) {
482 const Notification &notification = d->notifications.at(i);
483
484 if (flags.testFlag(Notifications::ClearExpired) && (notification.expired() || notification.wasAddedDuringInhibition())) {
485 close(notification.id());
486 }
487 }
488}
489
490void AbstractNotificationsModel::onNotificationAdded(const Notification &notification)
491{
492 d->onNotificationAdded(notification);
493}
494
495void AbstractNotificationsModel::onNotificationReplaced(uint replacedId, const Notification &notification)
496{
497 d->onNotificationReplaced(replacedId, notification);
498}
499
500void AbstractNotificationsModel::onNotificationRemoved(uint notificationId, Server::CloseReason reason)
501{
502 d->onNotificationRemoved(notificationId, reason);
503}
504
505void AbstractNotificationsModel::setupNotificationTimeout(const Notification &notification)
506{
507 d->setupNotificationTimeout(notification);
508}
509
510const QList<Notification> &AbstractNotificationsModel::notifications()
511{
512 return d->notifications;
513}
514
515#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.
@ 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
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 Jan 31 2025 12:05:31 by doxygen 1.13.2 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.