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

KDE's Doxygen guidelines are available online.