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 q->stopTimeout(notifications.first().id());
72 notifications.removeAt(0);
73 // TODO close gracefully?
74 }
75 q->endRemoveRows();
76 }
77
78 setupNotificationTimeout(notification);
79
80 q->beginInsertRows(QModelIndex(), notifications.count(), notifications.count());
81 notifications.append(std::move(notification));
82 q->endInsertRows();
83}
84
85void AbstractNotificationsModel::Private::onNotificationReplaced(uint replacedId, const Notification &notification)
86{
87 const int row = q->rowOfNotification(replacedId);
88
89 if (row == -1) {
90 qCWarning(NOTIFICATIONMANAGER) << "Trying to replace notification with id" << replacedId
91 << "which doesn't exist, creating a new one. This is an application bug!";
92 onNotificationAdded(notification);
93 return;
94 }
95
96 setupNotificationTimeout(notification);
97
98 Notification newNotification(notification);
99
100 const Notification &oldNotification = notifications.at(row);
101 // As per spec a notification must be replaced atomically with no visual cues.
102 // Transfer over properties that might cause this, such as unread showing the bell again,
103 // or created() which should indicate the original date, whereas updated() is when it was last updated
104 newNotification.setCreated(oldNotification.created());
105 newNotification.setExpired(oldNotification.expired());
106 newNotification.setDismissed(oldNotification.dismissed());
107 newNotification.setRead(oldNotification.read());
108 newNotification.setWasAddedDuringInhibition(Server::self().inhibited());
109
110 notifications[row] = newNotification;
111 const QModelIndex idx = q->index(row, 0);
112 Q_EMIT q->dataChanged(idx, idx);
113}
114
115void AbstractNotificationsModel::Private::onNotificationRemoved(uint removedId, Server::CloseReason reason)
116{
117 const int row = q->rowOfNotification(removedId);
118 if (row == -1) {
119 return;
120 }
121
122 q->stopTimeout(removedId);
123
124 // When a notification expired, keep it around in the history and mark it as such
125 if (reason == Server::CloseReason::Expired) {
126 const QModelIndex idx = q->index(row, 0);
127
128 Notification &notification = notifications[row];
129 notification.setExpired(true);
130
131 // Since the notification is "closed" it cannot have any actions
132 // unless it is "resident" which we don't support
133 notification.setActions(QStringList());
134
135 // clang-format off
136 Q_EMIT q->dataChanged(idx, idx, {
138 // TODO only Q_EMIT those if actually changed?
144 });
145 // clang-format on
146
147 return;
148 }
149
150 // Otherwise if explicitly closed by either user or app, mark it for removal
151 // some apps are notorious for closing a bunch of notifications at once
152 // causing newer notifications to move up and have a dialogs created for them
153 // just to then be discarded causing excess CPU usage
154 if (!pendingRemovals.contains(removedId)) {
155 pendingRemovals.append(removedId);
156 }
157
158 if (!pendingRemovalTimer.isActive()) {
159 pendingRemovalTimer.start();
160 }
161}
162
163void AbstractNotificationsModel::Private::setupNotificationTimeout(const Notification &notification)
164{
165 if (notification.timeout() == 0) {
166 // In case it got replaced by a persistent notification
167 q->stopTimeout(notification.id());
168 return;
169 }
170
171 QTimer *timer = notificationTimeouts.value(notification.id());
172 if (!timer) {
173 timer = new QTimer();
174 timer->setSingleShot(true);
175
176 connect(timer, &QTimer::timeout, q, [this, timer] {
177 const uint id = timer->property("notificationId").toUInt();
178 q->expire(id);
179 });
180 notificationTimeouts.insert(notification.id(), timer);
181 }
182
183 timer->stop();
184 timer->setProperty("notificationId", notification.id());
185 timer->setInterval(60000 /*1min*/ + (notification.timeout() == -1 ? 120000 /*2min, max configurable default timeout*/ : notification.timeout()));
186 timer->start();
187}
188
189void AbstractNotificationsModel::Private::removeRows(const QList<int> &rows)
190{
191 if (rows.isEmpty()) {
192 return;
193 }
194
195 QList<int> rowsToBeRemoved(rows);
196 std::sort(rowsToBeRemoved.begin(), rowsToBeRemoved.end());
197
198 QList<QPair<int, int>> clearQueue;
199
200 QPair<int, int> clearRange{rowsToBeRemoved.first(), rowsToBeRemoved.first()};
201
202 for (int row : rowsToBeRemoved) {
203 if (row > clearRange.second + 1) {
204 clearQueue.append(clearRange);
205 clearRange.first = row;
206 }
207
208 clearRange.second = row;
209 }
210
211 if (clearQueue.isEmpty() || clearQueue.last() != clearRange) {
212 clearQueue.append(clearRange);
213 }
214
215 int rowsRemoved = 0;
216
217 for (int i = clearQueue.count() - 1; i >= 0; --i) {
218 const auto &range = clearQueue.at(i);
219
220 q->beginRemoveRows(QModelIndex(), range.first, range.second);
221 for (int j = range.second; j >= range.first; --j) {
222 notifications.removeAt(j);
223 ++rowsRemoved;
224 }
225 q->endRemoveRows();
226 }
227
228 Q_ASSERT(rowsRemoved == rowsToBeRemoved.count());
229
230 pendingRemovals.clear();
231}
232
233int AbstractNotificationsModel::rowOfNotification(uint id) const
234{
235 auto it = std::find_if(d->notifications.constBegin(), d->notifications.constEnd(), [id](const Notification &item) {
236 return item.id() == id;
237 });
238
239 if (it == d->notifications.constEnd()) {
240 return -1;
241 }
242
243 return std::distance(d->notifications.constBegin(), it);
244}
245
246AbstractNotificationsModel::AbstractNotificationsModel()
247 : QAbstractListModel(nullptr)
248 , d(new Private(this))
249{
250}
251
252AbstractNotificationsModel::~AbstractNotificationsModel() = default;
253
254QDateTime AbstractNotificationsModel::lastRead() const
255{
256 return d->lastRead;
257}
258
259void AbstractNotificationsModel::setLastRead(const QDateTime &lastRead)
260{
261 if (d->lastRead != lastRead) {
262 d->lastRead = lastRead;
263 Q_EMIT lastReadChanged();
264 }
265}
266
267QWindow *AbstractNotificationsModel::window() const
268{
269 return d->window;
270}
271
272void AbstractNotificationsModel::setWindow(QWindow *window)
273{
274 if (d->window == window) {
275 return;
276 }
277 if (d->window) {
278 disconnect(d->window, &QObject::destroyed, this, nullptr);
279 }
280 d->window = window;
281 if (d->window) {
282 connect(d->window, &QObject::destroyed, this, [this] {
283 setWindow(nullptr);
284 });
285 }
286 Q_EMIT windowChanged(window);
287}
288
289QVariant AbstractNotificationsModel::data(const QModelIndex &index, int role) const
290{
291 if (!checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)) {
292 return QVariant();
293 }
294
295 const Notification &notification = d->notifications.at(index.row());
296
297 switch (role) {
299 return notification.id();
302
304 if (notification.created().isValid()) {
305 return notification.created();
306 }
307 break;
309 if (notification.updated().isValid()) {
310 return notification.updated();
311 }
312 break;
314 return notification.summary();
316 return notification.body();
318 return i18nc("@info %1 notification body %2 application name",
319 "%1 from %2",
320 QTextDocumentFragment::fromHtml(notification.body()).toPlainText(),
321 notification.applicationName());
323 if (notification.image().isNull()) {
324 return notification.icon();
325 }
326 break;
328 if (!notification.image().isNull()) {
329 return notification.image();
330 }
331 break;
333 return notification.desktopEntry();
335 return notification.notifyRcName();
336
338 return notification.applicationName();
340 return notification.applicationIconName();
342 return notification.originName();
343
345 return notification.actionNames();
347 return notification.actionLabels();
349 return notification.hasDefaultAction();
351 return notification.defaultActionLabel();
352
354 return QVariant::fromValue(notification.urls());
355
357 return static_cast<int>(notification.urgency());
359 return notification.userActionFeedback();
360
362 return notification.timeout();
363
365 return true;
367 return notification.configurable();
369 return notification.configureActionLabel();
370
372 return notification.category();
373
375 return notification.expired();
377 return notification.read();
379 return notification.resident();
381 return notification.transient();
382
384 return notification.wasAddedDuringInhibition();
385
387 return notification.hasReplyAction();
389 return notification.replyActionLabel();
391 return notification.replyPlaceholderText();
393 return notification.replySubmitButtonText();
395 return notification.replySubmitButtonIconName();
396 }
397
398 return QVariant();
399}
400
401bool AbstractNotificationsModel::setData(const QModelIndex &index, const QVariant &value, int role)
402{
403 if (!checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)) {
404 return false;
405 }
406
407 Notification &notification = d->notifications[index.row()];
408 bool dirty = false;
409
410 switch (role) {
412 if (value.toBool() != notification.read()) {
413 notification.setRead(value.toBool());
414 dirty = true;
415 }
416 break;
417 // Allows to mark a notification as expired without actually sending that out through expire() for persistency
419 if (value.toBool() != notification.expired()) {
420 notification.setExpired(value.toBool());
421 dirty = true;
422 }
423 break;
425 if (bool v = value.toBool(); v != notification.wasAddedDuringInhibition()) {
426 notification.setWasAddedDuringInhibition(v);
427 dirty = true;
428 }
429 break;
430 }
431
432 if (dirty) {
433 Q_EMIT dataChanged(index, index, {role});
434 }
435
436 return dirty;
437}
438
439int AbstractNotificationsModel::rowCount(const QModelIndex &parent) const
440{
441 if (parent.isValid()) {
442 return 0;
443 }
444
445 return d->notifications.count();
446}
447
448QHash<int, QByteArray> AbstractNotificationsModel::roleNames() const
449{
450 return Utils::roleNames();
451}
452
453void AbstractNotificationsModel::startTimeout(uint notificationId)
454{
455 const int row = rowOfNotification(notificationId);
456 if (row == -1) {
457 return;
458 }
459
460 const Notification &notification = d->notifications.at(row);
461
462 if (!notification.timeout() || notification.expired()) {
463 return;
464 }
465
466 d->setupNotificationTimeout(notification);
467}
468
469void AbstractNotificationsModel::stopTimeout(uint notificationId)
470{
471 delete d->notificationTimeouts.take(notificationId);
472}
473
474void AbstractNotificationsModel::clear(Notifications::ClearFlags flags)
475{
476 if (d->notifications.isEmpty()) {
477 return;
478 }
479
480 QList<int> rowsToRemove;
481
482 for (int i = 0; i < d->notifications.count(); ++i) {
483 const Notification &notification = d->notifications.at(i);
484
485 if (flags.testFlag(Notifications::ClearExpired) && (notification.expired() || notification.wasAddedDuringInhibition())) {
486 close(notification.id());
487 }
488 }
489}
490
491void AbstractNotificationsModel::onNotificationAdded(const Notification &notification)
492{
493 d->onNotificationAdded(notification);
494}
495
496void AbstractNotificationsModel::onNotificationReplaced(uint replacedId, const Notification &notification)
497{
498 d->onNotificationReplaced(replacedId, notification);
499}
500
501void AbstractNotificationsModel::onNotificationRemoved(uint notificationId, Server::CloseReason reason)
502{
503 d->onNotificationRemoved(notificationId, reason);
504}
505
506void AbstractNotificationsModel::setupNotificationTimeout(const Notification &notification)
507{
508 d->setupNotificationTimeout(notification);
509}
510
511const QList<Notification> &AbstractNotificationsModel::notifications()
512{
513 return d->notifications;
514}
515
516#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 Feb 28 2025 12:02:58 by doxygen 1.13.2 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.