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
108 notifications[row] = newNotification;
109 const QModelIndex idx = q->index(row, 0);
110 Q_EMIT q->dataChanged(idx, idx);
111}
112
113void AbstractNotificationsModel::Private::onNotificationRemoved(uint removedId, Server::CloseReason reason)
114{
115 const int row = q->rowOfNotification(removedId);
116 if (row == -1) {
117 return;
118 }
119
120 q->stopTimeout(removedId);
121
122 // When a notification expired, keep it around in the history and mark it as such
123 if (reason == Server::CloseReason::Expired) {
124 const QModelIndex idx = q->index(row, 0);
125
126 Notification &notification = notifications[row];
127 notification.setExpired(true);
128
129 // Since the notification is "closed" it cannot have any actions
130 // unless it is "resident" which we don't support
131 notification.setActions(QStringList());
132
133 // clang-format off
134 Q_EMIT q->dataChanged(idx, idx, {
136 // TODO only Q_EMIT those if actually changed?
142 });
143 // clang-format on
144
145 return;
146 }
147
148 // Otherwise if explicitly closed by either user or app, mark it for removal
149 // some apps are notorious for closing a bunch of notifications at once
150 // causing newer notifications to move up and have a dialogs created for them
151 // just to then be discarded causing excess CPU usage
152 if (!pendingRemovals.contains(removedId)) {
153 pendingRemovals.append(removedId);
154 }
155
156 if (!pendingRemovalTimer.isActive()) {
157 pendingRemovalTimer.start();
158 }
159}
160
161void AbstractNotificationsModel::Private::setupNotificationTimeout(const Notification &notification)
162{
163 if (notification.timeout() == 0) {
164 // In case it got replaced by a persistent notification
165 q->stopTimeout(notification.id());
166 return;
167 }
168
169 QTimer *timer = notificationTimeouts.value(notification.id());
170 if (!timer) {
171 timer = new QTimer();
172 timer->setSingleShot(true);
173
174 connect(timer, &QTimer::timeout, q, [this, timer] {
175 const uint id = timer->property("notificationId").toUInt();
176 q->expire(id);
177 });
178 notificationTimeouts.insert(notification.id(), timer);
179 }
180
181 timer->stop();
182 timer->setProperty("notificationId", notification.id());
183 timer->setInterval(60000 /*1min*/ + (notification.timeout() == -1 ? 120000 /*2min, max configurable default timeout*/ : notification.timeout()));
184 timer->start();
185}
186
187void AbstractNotificationsModel::Private::removeRows(const QList<int> &rows)
188{
189 if (rows.isEmpty()) {
190 return;
191 }
192
193 QList<int> rowsToBeRemoved(rows);
194 std::sort(rowsToBeRemoved.begin(), rowsToBeRemoved.end());
195
196 QList<QPair<int, int>> clearQueue;
197
198 QPair<int, int> clearRange{rowsToBeRemoved.first(), rowsToBeRemoved.first()};
199
200 for (int row : rowsToBeRemoved) {
201 if (row > clearRange.second + 1) {
202 clearQueue.append(clearRange);
203 clearRange.first = row;
204 }
205
206 clearRange.second = row;
207 }
208
209 if (clearQueue.isEmpty() || clearQueue.last() != clearRange) {
210 clearQueue.append(clearRange);
211 }
212
213 int rowsRemoved = 0;
214
215 for (int i = clearQueue.count() - 1; i >= 0; --i) {
216 const auto &range = clearQueue.at(i);
217
218 q->beginRemoveRows(QModelIndex(), range.first, range.second);
219 for (int j = range.second; j >= range.first; --j) {
220 notifications.removeAt(j);
221 ++rowsRemoved;
222 }
223 q->endRemoveRows();
224 }
225
226 Q_ASSERT(rowsRemoved == rowsToBeRemoved.count());
227
228 pendingRemovals.clear();
229}
230
231int AbstractNotificationsModel::rowOfNotification(uint id) const
232{
233 auto it = std::find_if(d->notifications.constBegin(), d->notifications.constEnd(), [id](const Notification &item) {
234 return item.id() == id;
235 });
236
237 if (it == d->notifications.constEnd()) {
238 return -1;
239 }
240
241 return std::distance(d->notifications.constBegin(), it);
242}
243
244AbstractNotificationsModel::AbstractNotificationsModel()
245 : QAbstractListModel(nullptr)
246 , d(new Private(this))
247{
248}
249
250AbstractNotificationsModel::~AbstractNotificationsModel() = default;
251
252QDateTime AbstractNotificationsModel::lastRead() const
253{
254 return d->lastRead;
255}
256
257void AbstractNotificationsModel::setLastRead(const QDateTime &lastRead)
258{
259 if (d->lastRead != lastRead) {
260 d->lastRead = lastRead;
261 Q_EMIT lastReadChanged();
262 }
263}
264
265QWindow *AbstractNotificationsModel::window() const
266{
267 return d->window;
268}
269
270void AbstractNotificationsModel::setWindow(QWindow *window)
271{
272 if (d->window == window) {
273 return;
274 }
275 if (d->window) {
276 disconnect(d->window, &QObject::destroyed, this, nullptr);
277 }
278 d->window = window;
279 if (d->window) {
280 connect(d->window, &QObject::destroyed, this, [this] {
281 setWindow(nullptr);
282 });
283 }
284 Q_EMIT windowChanged(window);
285}
286
287QVariant AbstractNotificationsModel::data(const QModelIndex &index, int role) const
288{
289 if (!checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)) {
290 return QVariant();
291 }
292
293 const Notification &notification = d->notifications.at(index.row());
294
295 switch (role) {
297 return notification.id();
300
302 if (notification.created().isValid()) {
303 return notification.created();
304 }
305 break;
307 if (notification.updated().isValid()) {
308 return notification.updated();
309 }
310 break;
312 return notification.summary();
314 return notification.body();
316 return i18nc("@info %1 notification body %2 application name",
317 "%1 from %2",
318 QTextDocumentFragment::fromHtml(notification.body()).toPlainText(),
319 notification.applicationName());
321 if (notification.image().isNull()) {
322 return notification.icon();
323 }
324 break;
326 if (!notification.image().isNull()) {
327 return notification.image();
328 }
329 break;
331 return notification.desktopEntry();
333 return notification.notifyRcName();
334
336 return notification.applicationName();
338 return notification.applicationIconName();
340 return notification.originName();
341
343 return notification.actionNames();
345 return notification.actionLabels();
347 return notification.hasDefaultAction();
349 return notification.defaultActionLabel();
350
352 return QVariant::fromValue(notification.urls());
353
355 return static_cast<int>(notification.urgency());
357 return notification.userActionFeedback();
358
360 return notification.timeout();
361
363 return true;
365 return notification.configurable();
367 return notification.configureActionLabel();
368
370 return notification.category();
371
373 return notification.expired();
375 return notification.read();
377 return notification.resident();
379 return notification.transient();
380
382 return notification.hasReplyAction();
384 return notification.replyActionLabel();
386 return notification.replyPlaceholderText();
388 return notification.replySubmitButtonText();
390 return notification.replySubmitButtonIconName();
391 }
392
393 return QVariant();
394}
395
396bool AbstractNotificationsModel::setData(const QModelIndex &index, const QVariant &value, int role)
397{
398 if (!checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)) {
399 return false;
400 }
401
402 Notification &notification = d->notifications[index.row()];
403 bool dirty = false;
404
405 switch (role) {
407 if (value.toBool() != notification.read()) {
408 notification.setRead(value.toBool());
409 dirty = true;
410 }
411 break;
412 // Allows to mark a notification as expired without actually sending that out through expire() for persistency
414 if (value.toBool() != notification.expired()) {
415 notification.setExpired(value.toBool());
416 dirty = true;
417 }
418 break;
419 }
420
421 if (dirty) {
422 Q_EMIT dataChanged(index, index, {role});
423 }
424
425 return dirty;
426}
427
428int AbstractNotificationsModel::rowCount(const QModelIndex &parent) const
429{
430 if (parent.isValid()) {
431 return 0;
432 }
433
434 return d->notifications.count();
435}
436
437QHash<int, QByteArray> AbstractNotificationsModel::roleNames() const
438{
439 return Utils::roleNames();
440}
441
442void AbstractNotificationsModel::startTimeout(uint notificationId)
443{
444 const int row = rowOfNotification(notificationId);
445 if (row == -1) {
446 return;
447 }
448
449 const Notification &notification = d->notifications.at(row);
450
451 if (!notification.timeout() || notification.expired()) {
452 return;
453 }
454
455 d->setupNotificationTimeout(notification);
456}
457
458void AbstractNotificationsModel::stopTimeout(uint notificationId)
459{
460 delete d->notificationTimeouts.take(notificationId);
461}
462
463void AbstractNotificationsModel::clear(Notifications::ClearFlags flags)
464{
465 if (d->notifications.isEmpty()) {
466 return;
467 }
468
469 QList<int> rowsToRemove;
470
471 for (int i = 0; i < d->notifications.count(); ++i) {
472 const Notification &notification = d->notifications.at(i);
473
474 if (flags.testFlag(Notifications::ClearExpired) && notification.expired()) {
475 close(notification.id());
476 }
477 }
478}
479
480void AbstractNotificationsModel::onNotificationAdded(const Notification &notification)
481{
482 d->onNotificationAdded(notification);
483}
484
485void AbstractNotificationsModel::onNotificationReplaced(uint replacedId, const Notification &notification)
486{
487 d->onNotificationReplaced(replacedId, notification);
488}
489
490void AbstractNotificationsModel::onNotificationRemoved(uint notificationId, Server::CloseReason reason)
491{
492 d->onNotificationRemoved(notificationId, reason);
493}
494
495void AbstractNotificationsModel::setupNotificationTimeout(const Notification &notification)
496{
497 d->setupNotificationTimeout(notification);
498}
499
500const QList<Notification> &AbstractNotificationsModel::notifications()
501{
502 return d->notifications;
503}
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].
@ 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
QString i18nc(const char *context, const char *text, const TYPE &arg...)
QWidget * window(QObject *job)
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 override
virtual QModelIndex index(int row, int column, const QModelIndex &parent) const const override
bool isValid() const const
bool isNull() const const
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)
int row() const const
Q_EMITQ_EMIT
QMetaObject::Connection connect(const QObject *sender, PointerToMemberFunction signal, Functor functor)
void destroyed(QObject *obj)
bool disconnect(const QMetaObject::Connection &connection)
QObject * parent() const const
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-2024 The KDE developers.
Generated on Fri Oct 11 2024 12:19:55 by doxygen 1.12.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.