diff --git a/applets/notifications/package/contents/ui/NotificationPopup.qml b/applets/notifications/package/contents/ui/NotificationPopup.qml index 96373c10b..9e59bc179 100644 --- a/applets/notifications/package/contents/ui/NotificationPopup.qml +++ b/applets/notifications/package/contents/ui/NotificationPopup.qml @@ -1,206 +1,210 @@ /* * Copyright 2019 Kai Uwe Broulik * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see */ import QtQuick 2.8 import QtQuick.Layouts 1.1 import org.kde.plasma.core 2.0 as PlasmaCore import org.kde.plasma.components 2.0 as Components import org.kde.notificationmanager 1.0 as NotificationManager import ".." PlasmaCore.Dialog { id: notificationPopup property int popupWidth property alias notificationType: notificationItem.notificationType property alias applicationName: notificationItem.applicationName property alias applicationIconSource: notificationItem.applicationIconSource property alias originName: notificationItem.originName property alias time: notificationItem.time property alias summary: notificationItem.summary property alias body: notificationItem.body property alias icon: notificationItem.icon property alias urls: notificationItem.urls property int urgency property int timeout property int dismissTimeout property alias jobState: notificationItem.jobState property alias percentage: notificationItem.percentage property alias jobError: notificationItem.jobError property alias suspendable: notificationItem.suspendable property alias killable: notificationItem.killable property alias jobDetails: notificationItem.jobDetails property alias configureActionLabel: notificationItem.configureActionLabel property alias configurable: notificationItem.configurable property alias dismissable: notificationItem.dismissable property alias closable: notificationItem.closable property bool hasDefaultAction property alias actionNames: notificationItem.actionNames property alias actionLabels: notificationItem.actionLabels signal configureClicked signal dismissClicked signal closeClicked signal defaultActionInvoked signal actionInvoked(string actionName) signal openUrl(string url) signal fileActionInvoked signal expired + signal hoverEntered + signal hoverExited signal suspendJobClicked signal resumeJobClicked signal killJobClicked property int defaultTimeout: 5000 readonly property int effectiveTimeout: { if (timeout === -1) { return defaultTimeout; } if (dismissTimeout) { return dismissTimeout; } return timeout; } location: PlasmaCore.Types.Floating flags: Qt.WindowDoesNotAcceptFocus visible: false // When notification is updated, restart hide timer onTimeChanged: { if (timer.running) { timer.restart(); } } mainItem: MouseArea { id: area width: notificationPopup.popupWidth height: notificationItem.implicitHeight + notificationItem.y hoverEnabled: true cursorShape: hasDefaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor acceptedButtons: hasDefaultAction ? Qt.LeftButton : Qt.NoButton onClicked: notificationPopup.defaultActionInvoked() + onEntered: notificationPopup.hoverEntered() + onExited: notificationPopup.hoverExited() LayoutMirroring.enabled: Qt.application.layoutDirection === Qt.RightToLeft LayoutMirroring.childrenInherit: true Timer { id: timer interval: notificationPopup.effectiveTimeout running: notificationPopup.visible && !area.containsMouse && interval > 0 && !notificationItem.dragging && !notificationItem.menuOpen onTriggered: { if (notificationPopup.dismissTimeout) { notificationPopup.dismissClicked(); } else { notificationPopup.expired(); } } } Timer { id: timeoutIndicatorDelayTimer // only show indicator for the last ten seconds of timeout readonly property int remainingTimeout: 10000 interval: Math.max(0, timer.interval - remainingTimeout) running: interval > 0 && timer.running } Rectangle { id: timeoutIndicatorRect anchors { right: parent.right rightMargin: -notificationPopup.margins.right bottom: parent.bottom bottomMargin: -notificationPopup.margins.bottom } width: units.devicePixelRatio * 3 color: theme.highlightColor opacity: timeoutIndicatorAnimation.running ? 0.6 : 0 visible: units.longDuration > 1 Behavior on opacity { NumberAnimation { duration: units.longDuration } } NumberAnimation { id: timeoutIndicatorAnimation target: timeoutIndicatorRect property: "height" from: area.height + notificationPopup.margins.top + notificationPopup.margins.bottom to: 0 duration: Math.min(timer.interval, timeoutIndicatorDelayTimer.remainingTimeout) running: timer.running && !timeoutIndicatorDelayTimer.running && units.longDuration > 1 } } NotificationItem { id: notificationItem // let the item bleed into the dialog margins so the close button margins cancel out y: closable || dismissable || configurable ? -notificationPopup.margins.top : 0 headingRightPadding: -notificationPopup.margins.right width: parent.width hovered: area.containsMouse maximumLineCount: 8 bodyCursorShape: notificationPopup.hasDefaultAction ? Qt.PointingHandCursor : 0 thumbnailLeftPadding: -notificationPopup.margins.left thumbnailRightPadding: -notificationPopup.margins.right thumbnailTopPadding: -notificationPopup.margins.top thumbnailBottomPadding: -notificationPopup.margins.bottom closable: true onBodyClicked: { if (area.acceptedButtons & mouse.button) { area.clicked(null /*mouse*/); } } onCloseClicked: notificationPopup.closeClicked() onDismissClicked: notificationPopup.dismissClicked() onConfigureClicked: notificationPopup.configureClicked() onActionInvoked: notificationPopup.actionInvoked(actionName) onOpenUrl: notificationPopup.openUrl(url) onFileActionInvoked: notificationPopup.fileActionInvoked() onSuspendJobClicked: notificationPopup.suspendJobClicked() onResumeJobClicked: notificationPopup.resumeJobClicked() onKillJobClicked: notificationPopup.killJobClicked() } } } diff --git a/applets/notifications/package/contents/ui/global/Globals.qml b/applets/notifications/package/contents/ui/global/Globals.qml index f8759af35..0d2403a9d 100644 --- a/applets/notifications/package/contents/ui/global/Globals.qml +++ b/applets/notifications/package/contents/ui/global/Globals.qml @@ -1,451 +1,452 @@ /* * Copyright 2019 Kai Uwe Broulik * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see */ pragma Singleton import QtQuick 2.8 import QtQuick.Layouts 1.1 import org.kde.plasma.plasmoid 2.0 import org.kde.plasma.core 2.0 as PlasmaCore import org.kde.plasma.components 2.0 as Components import org.kde.kquickcontrolsaddons 2.0 import org.kde.notificationmanager 1.0 as NotificationManager import ".." // This singleton object contains stuff shared between all notification plasmoids, namely: // - Popup creation and placement // - Do not disturb mode QtObject { id: globals // Listened to by "ago" label in NotificationHeader to update all of them in unison signal timeChanged property bool inhibited: false onInhibitedChanged: { var pa = pulseAudio.item; if (!pa) { return; } var stream = pa.notificationStream; if (!stream) { return; } if (inhibited) { // Only remember that we muted if previously not muted. if (!stream.muted) { notificationSettings.notificationSoundsInhibited = true; stream.mute(); } } else { // Only unmute if we previously muted it. if (notificationSettings.notificationSoundsInhibited) { stream.unmute(); } notificationSettings.notificationSoundsInhibited = false; } notificationSettings.save(); } // Some parts of the code rely on plasmoid.nativeInterface and since we're in a singleton here // this is named "plasmoid" property QtObject plasmoid: plasmoids[0] // HACK When a plasmoid is destroyed, QML sets its value to "null" in the Array // so we then remove it so we have a working "plasmoid" again onPlasmoidChanged: { if (!plasmoid) { // this doesn't emit a change, only in ratePlasmoids() it will detect the change plasmoids.splice(0, 1); // remove first ratePlasmoids(); } } // all notification plasmoids property var plasmoids: [] property int popupLocation: { switch (notificationSettings.popupPosition) { // Auto-determine location based on plasmoid location case NotificationManager.Settings.CloseToWidget: if (!plasmoid) { return Qt.AlignBottom | Qt.AlignRight; // just in case } var alignment = 0; if (plasmoid.location === PlasmaCore.Types.LeftEdge) { alignment |= Qt.AlignLeft; } else if (plasmoid.location === PlasmaCore.Types.RightEdge) { alignment |= Qt.AlignRight; } else { // would be nice to do plasmoid.compactRepresentationItem.mapToItem(null) and then // position the popups depending on the relative position within the panel alignment |= Qt.application.layoutDirection === Qt.RightToLeft ? Qt.AlignLeft : Qt.AlignRight; } if (plasmoid.location === PlasmaCore.Types.TopEdge) { alignment |= Qt.AlignTop; } else { alignment |= Qt.AlignBottom; } return alignment; case NotificationManager.Settings.TopLeft: return Qt.AlignTop | Qt.AlignLeft; case NotificationManager.Settings.TopCenter: return Qt.AlignTop | Qt.AlignHCenter; case NotificationManager.Settings.TopRight: return Qt.AlignTop | Qt.AlignRight; case NotificationManager.Settings.BottomLeft: return Qt.AlignBottom | Qt.AlignLeft; case NotificationManager.Settings.BottomCenter: return Qt.AlignBottom | Qt.AlignHCenter; case NotificationManager.Settings.BottomRight: return Qt.AlignBottom | Qt.AlignRight; } } // The raw width of the popup's content item, the Dialog itself adds some margins property int popupWidth: units.gridUnit * 18 property int popupEdgeDistance: units.largeSpacing * 2 property int popupSpacing: units.largeSpacing // How much vertical screen real estate the notification popups may consume readonly property real popupMaximumScreenFill: 0.75 onPopupLocationChanged: Qt.callLater(positionPopups) Component.onCompleted: checkInhibition() function adopt(plasmoid) { // this doesn't emit a change, only in ratePlasmoids() it will detect the change globals.plasmoids.push(plasmoid); ratePlasmoids(); } // Sorts plasmoids based on a heuristic to find a suitable plasmoid to follow when placing popups function ratePlasmoids() { var plasmoidScore = function(plasmoid) { if (!plasmoid) { return 0; } var score = 0; // Prefer plasmoids in a panel, prefer horizontal panels over vertical ones if (plasmoid.location === PlasmaCore.Types.LeftEdge || plasmoid.location === PlasmaCore.Types.RightEdge) { score += 1; } else if (plasmoid.location === PlasmaCore.Types.TopEdge || plasmoid.location === PlasmaCore.Types.BottomEdge) { score += 2; } // Prefer iconified plasmoids if (!plasmoid.expanded) { ++score; } // Prefer plasmoids on primary screen if (plasmoid.nativeInterface && plasmoid.nativeInterface.isPrimaryScreen(plasmoid.screenGeometry)) { ++score; } return score; } var newPlasmoids = plasmoids; newPlasmoids.sort(function (a, b) { var scoreA = plasmoidScore(a); var scoreB = plasmoidScore(b); // Sort descending by score if (scoreA < scoreB) { return 1; } else if (scoreA > scoreB) { return -1; } else { return 0; } }); globals.plasmoids = newPlasmoids; } function checkInhibition() { globals.inhibited = Qt.binding(function() { var inhibited = false; var inhibitedUntil = notificationSettings.notificationsInhibitedUntil; if (!isNaN(inhibitedUntil.getTime())) { inhibited |= (new Date().getTime() < inhibitedUntil.getTime()); } if (notificationSettings.notificationsInhibitedByApplication) { inhibited |= true; } if (notificationSettings.inhibitNotificationsWhenScreensMirrored) { inhibited |= notificationSettings.screensMirrored; } return inhibited; }); } function positionPopups() { if (!plasmoid) { return; } var screenRect = Qt.rect(plasmoid.screenGeometry.x + plasmoid.availableScreenRect.x, plasmoid.screenGeometry.y + plasmoid.availableScreenRect.y, plasmoid.availableScreenRect.width, plasmoid.availableScreenRect.height); if (screenRect.width <= 0 || screenRect.height <= 0) { return; } var y = screenRect.y; if (popupLocation & Qt.AlignBottom) { y += screenRect.height - popupEdgeDistance; } else { y += popupEdgeDistance; } var x = screenRect.x; if (popupLocation & Qt.AlignLeft) { x += popupEdgeDistance; } for (var i = 0; i < popupInstantiator.count; ++i) { var popup = popupInstantiator.objectAt(i); // Popup width is fixed, so don't rely on the actual window size var popupEffectiveWidth = popupWidth + popup.margins.left + popup.margins.right; if (popupLocation & Qt.AlignHCenter) { popup.x = x + (screenRect.width - popupEffectiveWidth) / 2; } else if (popupLocation & Qt.AlignRight) { popup.x = x + screenRect.width - popupEdgeDistance - popupEffectiveWidth; } else { popup.x = x; } // If the popup isn't ready yet, ignore its occupied space for now. // We'll reposition everything in onHeightChanged eventually. var delta = popup.height + (popup.height > 0 ? popupSpacing : 0); if (popupLocation & Qt.AlignTop) { popup.y = y; y += delta; } else { y -= delta; popup.y = y; } // don't let notifications take more than popupMaximumScreenFill of the screen var visible = true; if (i > 0) { // however always show at least one popup if (popupLocation & Qt.AlignTop) { visible = (popup.y + popup.height < screenRect.y + (screenRect.height * popupMaximumScreenFill)); } else { visible = (popup.y > screenRect.y + (screenRect.height * (1 - popupMaximumScreenFill))); } } // TODO would be nice to hide popups when systray or panel controller is open popup.visible = visible; } } property QtObject popupNotificationsModel: NotificationManager.Notifications { limit: plasmoid ? (Math.ceil(plasmoid.availableScreenRect.height / (theme.mSize(theme.defaultFont).height * 4))) : 0 showExpired: false showDismissed: false blacklistedDesktopEntries: notificationSettings.popupBlacklistedApplications blacklistedNotifyRcNames: notificationSettings.popupBlacklistedServices whitelistedDesktopEntries: globals.inhibited ? notificationSettings.doNotDisturbPopupWhitelistedApplications : [] whitelistedNotifyRcNames: globals.inhibited ? notificationSettings.doNotDisturbPopupWhitelistedServices : [] showJobs: notificationSettings.jobsInNotifications sortMode: NotificationManager.Notifications.SortByTypeAndUrgency groupMode: NotificationManager.Notifications.GroupDisabled urgencies: { var urgencies = 0; // Critical always except in do not disturb mode when disabled in settings if (!globals.inhibited || notificationSettings.criticalPopupsInDoNotDisturbMode) { urgencies |= NotificationManager.Notifications.CriticalUrgency; } // Normal only when not in do not disturb mode if (!globals.inhibited) { urgencies |= NotificationManager.Notifications.NormalUrgency; } // Low only when enabled in settings and not in do not disturb mode if (!globals.inhibited && notificationSettings.lowPriorityPopups) { urgencies |=NotificationManager.Notifications.LowUrgency; } return urgencies; } } property QtObject notificationSettings: NotificationManager.Settings { onNotificationsInhibitedUntilChanged: globals.checkInhibition() } // This periodically checks whether do not disturb mode timed out and updates the "minutes ago" labels property QtObject timeSource: PlasmaCore.DataSource { engine: "time" connectedSources: ["Local"] interval: 60000 // 1 min intervalAlignment: PlasmaCore.Types.AlignToMinute onDataChanged: { checkInhibition(); globals.timeChanged(); } } property Instantiator popupInstantiator: Instantiator { model: popupNotificationsModel delegate: NotificationPopup { // so Instantiator can access that after the model row is gone readonly property var notificationId: model.notificationId popupWidth: globals.popupWidth type: model.urgency === NotificationManager.Notifications.CriticalUrgency && notificationSettings.keepCriticalAlwaysOnTop ? PlasmaCore.Dialog.CriticalNotification : PlasmaCore.Dialog.Notification notificationType: model.type applicationName: model.applicationName applicationIconSource: model.applicationIconName originName: model.originName || "" time: model.updated || model.created configurable: model.configurable // For running jobs instead of offering a "close" button that might lead the user to // think that will cancel the job, we offer a "dismiss" button that hides it in the history dismissable: model.type === NotificationManager.Notifications.JobType && model.jobState !== NotificationManager.Notifications.JobStateStopped // TODO would be nice to be able to "pin" jobs when they autohide && notificationSettings.permanentJobPopups closable: model.closable summary: model.summary body: model.body || "" icon: model.image || model.iconName hasDefaultAction: model.hasDefaultAction || false timeout: model.timeout // Increase default timeout for notifications with a URL so you have enough time // to interact with the thumbnail or bring the window to the front where you want to drag it into defaultTimeout: notificationSettings.popupTimeout + (model.urls && model.urls.length > 0 ? 5000 : 0) // When configured to not keep jobs open permanently, we autodismiss them after the standard timeout dismissTimeout: !notificationSettings.permanentJobPopups && model.type === NotificationManager.Notifications.JobType && model.jobState !== NotificationManager.Notifications.JobStateStopped ? defaultTimeout : 0 urls: model.urls || [] urgency: model.urgency || NotificationManager.Notifications.NormalUrgency jobState: model.jobState || 0 percentage: model.percentage || 0 jobError: model.jobError || 0 suspendable: !!model.suspendable killable: !!model.killable jobDetails: model.jobDetails || null configureActionLabel: model.configureActionLabel || "" actionNames: model.actionNames actionLabels: model.actionLabels onExpired: popupNotificationsModel.expire(popupNotificationsModel.index(index, 0)) + onHoverEntered: model.read = true onCloseClicked: popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) onDismissClicked: model.dismissed = true onConfigureClicked: popupNotificationsModel.configure(popupNotificationsModel.index(index, 0)) onDefaultActionInvoked: { popupNotificationsModel.invokeDefaultAction(popupNotificationsModel.index(index, 0)) popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) } onActionInvoked: { popupNotificationsModel.invokeAction(popupNotificationsModel.index(index, 0), actionName) popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) } onOpenUrl: { Qt.openUrlExternally(url); popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) } onFileActionInvoked: popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) onSuspendJobClicked: popupNotificationsModel.suspendJob(popupNotificationsModel.index(index, 0)) onResumeJobClicked: popupNotificationsModel.resumeJob(popupNotificationsModel.index(index, 0)) onKillJobClicked: popupNotificationsModel.killJob(popupNotificationsModel.index(index, 0)) // popup width is fixed onHeightChanged: positionPopups() Component.onCompleted: { // Register apps that were seen spawning a popup so they can be configured later // Apps with notifyrc can already be configured anyway if (model.type === NotificationManager.Notifications.NotificationType && model.desktopEntry && !model.notifyRcName) { notificationSettings.registerKnownApplication(model.desktopEntry); notificationSettings.save(); } // Tell the model that we're handling the timeout now popupNotificationsModel.stopTimeout(popupNotificationsModel.index(index, 0)); } } onObjectAdded: { positionPopups(); object.visible = true; } onObjectRemoved: { var notificationId = object.notificationId // Popup might have been destroyed because of a filter change, tell the model to do the timeout work for us again // cannot use QModelIndex here as the model row is already gone popupNotificationsModel.startTimeout(notificationId); positionPopups(); } } // TODO use pulseaudio-qt for this once it becomes a framework property QtObject pulseAudio: Loader { source: "PulseAudio.qml" } property Connections screenWatcher: Connections { target: plasmoid onAvailableScreenRectChanged: repositionTimer.start() onScreenGeometryChanged: repositionTimer.start() } // Normally popups are repositioned through Qt.callLater but in case of e.g. screen geometry changes we want to compress that property Timer repositionTimer: Timer { interval: 250 onTriggered: positionPopups() } } diff --git a/libnotificationmanager/jobsmodel.cpp b/libnotificationmanager/jobsmodel.cpp index 0cb0df1c5..6e5e9275f 100644 --- a/libnotificationmanager/jobsmodel.cpp +++ b/libnotificationmanager/jobsmodel.cpp @@ -1,225 +1,231 @@ /* * Copyright 2019 Kai Uwe Broulik * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) version 3, or any * later version accepted by the membership of KDE e.V. (or its * successor approved by the membership of KDE e.V.), which shall * act as a proxy defined in Section 6 of version 3 of the license. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . */ #include "jobsmodel.h" #include "jobsmodel_p.h" #include "notifications.h" #include #include #include #include #include #include #include "job.h" #include "job_p.h" using namespace NotificationManager; JobsModel::JobsModel() : QAbstractListModel(nullptr) , d(new JobsModelPrivate(this)) { connect(d, &JobsModelPrivate::jobViewAboutToBeAdded, this, [this](int row, Job *job) { Q_UNUSED(job); beginInsertRows(QModelIndex(), row, row); }); connect(d, &JobsModelPrivate::jobViewAdded, this, [this](int row) { Q_UNUSED(row); endInsertRows(); }); connect(d, &JobsModelPrivate::jobViewAboutToBeRemoved, this, [this](int row) { beginRemoveRows(QModelIndex(), row, row); }); connect(d, &JobsModelPrivate::jobViewRemoved, this, [this](int row) { Q_UNUSED(row); endRemoveRows(); }); connect(d, &JobsModelPrivate::jobViewChanged, this, [this](int row, Job *job, const QVector &roles) { Q_UNUSED(job); const QModelIndex idx = index(row, 0); emit dataChanged(idx, idx, roles); }); connect(d, &JobsModelPrivate::serviceOwnershipLost, this, &JobsModel::serviceOwnershipLost); } JobsModel::~JobsModel() = default; JobsModel::Ptr JobsModel::createJobsModel() { static QWeakPointer s_instance; if (!s_instance) { QSharedPointer ptr(new JobsModel()); s_instance = ptr.toWeakRef(); return ptr; } return s_instance.toStrongRef(); } bool JobsModel::init() { return d->init(); } bool JobsModel::isValid() const { return d->m_valid; } QVariant JobsModel::data(const QModelIndex &index, int role) const { if (!checkIndex(index)) { return QVariant(); } Job *job = d->m_jobViews.at(index.row()); switch (role) { case Notifications::IdRole: return job->id(); case Notifications::TypeRole: return Notifications::JobType; // basically when it started case Notifications::CreatedRole: if (job->created().isValid()) { return job->created(); } break; // basically when it finished case Notifications::UpdatedRole: if (job->updated().isValid()) { return job->updated(); } break; case Notifications::SummaryRole: return job->summary(); case Notifications::BodyRole: return job->text(); case Notifications::DesktopEntryRole: return job->desktopEntry(); case Notifications::ApplicationNameRole: return job->applicationName(); case Notifications::ApplicationIconNameRole: return job->applicationIconName(); case Notifications::JobStateRole: return job->state(); case Notifications::PercentageRole: return job->percentage(); case Notifications::JobErrorRole: return job->error(); case Notifications::SuspendableRole: return job->suspendable(); case Notifications::KillableRole: return job->killable(); case Notifications::JobDetailsRole: return QVariant::fromValue(job); // successfully finished jobs timeout like a regular notifiation // whereas running or error'd jobs are persistent case Notifications::TimeoutRole: return job->state() == Notifications::JobStateStopped && !job->error() ? -1 : 0; case Notifications::ClosableRole: return job->state() == Notifications::JobStateStopped; case Notifications::ConfigurableRole: return false; case Notifications::ExpiredRole: return job->expired(); case Notifications::DismissedRole: return job->dismissed(); + + // A job is usually either a long lasting operation you're aware about + // or a quick job you don't care about. + // When it's running, it's there, when it failed, it's persistent. + // There's hardly a reason why it should show up as "unread". + case Notifications::ReadRole: return true; } return QVariant(); } bool JobsModel::setData(const QModelIndex &index, const QVariant &value, int role) { if (!checkIndex(index)) { return false; } Job *job = d->m_jobViews.at(index.row()); switch (role) { case Notifications::DismissedRole: if (value.toBool() != job->dismissed()) { job->setDismissed(value.toBool()); return true; } break; } return false; } int JobsModel::rowCount(const QModelIndex &parent) const { if (parent.isValid()) { return 0; } return d->m_jobViews.count(); } void JobsModel::close(const QModelIndex &idx) { if (checkIndex(idx)) { d->removeAt(idx.row()); } } void JobsModel::expire(const QModelIndex &idx) { if (checkIndex(idx)) { d->m_jobViews.at(idx.row())->setExpired(true); } } void JobsModel::suspend(const QModelIndex &idx) { if (checkIndex(idx)) { d->m_jobViews.at(idx.row())->suspend(); } } void JobsModel::resume(const QModelIndex &idx) { if (checkIndex(idx)) { d->m_jobViews.at(idx.row())->resume(); } } void JobsModel::kill(const QModelIndex &idx) { if (checkIndex(idx)) { d->m_jobViews.at(idx.row())->kill(); } } void JobsModel::clear(Notifications::ClearFlags flags) { if (d->m_jobViews.isEmpty()) { return; } for (int i = d->m_jobViews.count() - 1; i >= 0; --i) { Job *job = d->m_jobViews.at(i); bool clear = (flags.testFlag(Notifications::ClearExpired) && job->expired()); // Compared to notifications, the number of jobs is typically small // so for simplicity we can just delete one item at a time if (clear) { d->removeAt(i); } } } diff --git a/libnotificationmanager/notification.cpp b/libnotificationmanager/notification.cpp index 034de874b..501e5aeb6 100644 --- a/libnotificationmanager/notification.cpp +++ b/libnotificationmanager/notification.cpp @@ -1,691 +1,701 @@ /* * Copyright 2008 Dmitry Suzdalev * Copyright 2017 David Edmundson * Copyright 2018-2019 Kai Uwe Broulik * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) version 3, or any * later version accepted by the membership of KDE e.V. (or its * successor approved by the membership of KDE e.V.), which shall * act as a proxy defined in Section 6 of version 3 of the license. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . */ #include "notification.h" #include "notification_p.h" #include "notifications.h" #include #include #include #include #include #include #include #include #include #include #include #include #include "debug.h" #include "notifications.h" using namespace NotificationManager; Notification::Private::Private() { } Notification::Private::~Private() = default; QString Notification::Private::sanitize(const QString &text) { // replace all \ns with
QString t = text; t.replace(QLatin1String("\n"), QStringLiteral("
")); // Now remove all inner whitespace (\ns are already
s) t = t.simplified(); // Finally, check if we don't have multiple
s following, // can happen for example when "\n \n" is sent, this replaces // all
s in succsession with just one t.replace(QRegularExpression(QStringLiteral("
\\s*
(\\s|
)*")), QLatin1String("
")); // This fancy RegExp escapes every occurrence of & since QtQuick Text will blatantly cut off // text where it finds a stray ampersand. // Only &{apos, quot, gt, lt, amp}; as well as { character references will be allowed t.replace(QRegularExpression(QStringLiteral("&(?!(?:apos|quot|[gl]t|amp);|#)")), QLatin1String("&")); // Don't bother adding some HTML structure if the body is now empty if (t.isEmpty()) { return t; } QXmlStreamReader r(QStringLiteral("") + t + QStringLiteral("")); QString result; QXmlStreamWriter out(&result); const QVector allowedTags = {"b", "i", "u", "img", "a", "html", "br", "table", "tr", "td"}; out.writeStartDocument(); while (!r.atEnd()) { r.readNext(); if (r.tokenType() == QXmlStreamReader::StartElement) { const QString name = r.name().toString(); if (!allowedTags.contains(name)) { continue; } out.writeStartElement(name); if (name == QLatin1String("img")) { auto src = r.attributes().value("src").toString(); auto alt = r.attributes().value("alt").toString(); const QUrl url(src); if (url.isLocalFile()) { out.writeAttribute(QStringLiteral("src"), src); } else { //image denied for security reasons! Do not copy the image src here! } out.writeAttribute(QStringLiteral("alt"), alt); } if (name == QLatin1Char('a')) { out.writeAttribute(QStringLiteral("href"), r.attributes().value("href").toString()); } } if (r.tokenType() == QXmlStreamReader::EndElement) { const QString name = r.name().toString(); if (!allowedTags.contains(name)) { continue; } out.writeEndElement(); } if (r.tokenType() == QXmlStreamReader::Characters) { const auto text = r.text().toString(); out.writeCharacters(text); //this auto escapes chars -> HTML entities } } out.writeEndDocument(); if (r.hasError()) { qCWarning(NOTIFICATIONMANAGER) << "Notification to send to backend contains invalid XML: " << r.errorString() << "line" << r.lineNumber() << "col" << r.columnNumber(); } // The Text.StyledText format handles only html3.2 stuff and ' is html4 stuff // so we need to replace it here otherwise it will not render at all. result.replace(QLatin1String("'"), QChar('\'')); return result; } QImage Notification::Private::decodeNotificationSpecImageHint(const QDBusArgument &arg) { int width, height, rowStride, hasAlpha, bitsPerSample, channels; QByteArray pixels; char* ptr; char* end; arg.beginStructure(); arg >> width >> height >> rowStride >> hasAlpha >> bitsPerSample >> channels >> pixels; arg.endStructure(); #define SANITY_CHECK(condition) \ if (!(condition)) { \ qCWarning(NOTIFICATIONMANAGER) << "Image decoding sanity check failed on" << #condition; \ return QImage(); \ } SANITY_CHECK(width > 0); SANITY_CHECK(width < 2048); SANITY_CHECK(height > 0); SANITY_CHECK(height < 2048); SANITY_CHECK(rowStride > 0); #undef SANITY_CHECK auto copyLineRGB32 = [](QRgb* dst, const char* src, int width) { const char* end = src + width * 3; for (; src != end; ++dst, src+=3) { *dst = qRgb(src[0], src[1], src[2]); } }; auto copyLineARGB32 = [](QRgb* dst, const char* src, int width) { const char* end = src + width * 4; for (; src != end; ++dst, src+=4) { *dst = qRgba(src[0], src[1], src[2], src[3]); } }; QImage::Format format = QImage::Format_Invalid; void (*fcn)(QRgb*, const char*, int) = nullptr; if (bitsPerSample == 8) { if (channels == 4) { format = QImage::Format_ARGB32; fcn = copyLineARGB32; } else if (channels == 3) { format = QImage::Format_RGB32; fcn = copyLineRGB32; } } if (format == QImage::Format_Invalid) { qCWarning(NOTIFICATIONMANAGER) << "Unsupported image format (hasAlpha:" << hasAlpha << "bitsPerSample:" << bitsPerSample << "channels:" << channels << ")"; return QImage(); } QImage image(width, height, format); ptr = pixels.data(); end = ptr + pixels.length(); for (int y=0; y end) { qCWarning(NOTIFICATIONMANAGER) << "Image data is incomplete. y:" << y << "height:" << height; break; } fcn((QRgb*)image.scanLine(y), ptr, width); } return image; } void Notification::Private::sanitizeImage(QImage &image) { if (image.isNull()) { return; } const QSize max = maximumImageSize(); if (image.size().width() > max.width() || image.size().height() > max.height()) { image = image.scaled(max, Qt::KeepAspectRatio, Qt::SmoothTransformation); } } void Notification::Private::loadImagePath(const QString &path) { // image_path and appIcon should either be a URL with file scheme or the name of a themed icon. // We're lenient and also allow local paths. image = QImage(); // clear icon.clear(); QUrl imageUrl; if (path.startsWith(QLatin1Char('/'))) { imageUrl = QUrl::fromLocalFile(path); } else if (path.contains(QLatin1Char('/'))) { // bad heuristic to detect a URL imageUrl = QUrl(path); if (!imageUrl.isLocalFile()) { qCDebug(NOTIFICATIONMANAGER) << "Refused to load image from" << path << "which isn't a valid local location."; return; } } if (!imageUrl.isValid()) { // try icon path instead; icon = path; return; } QImageReader reader(imageUrl.toLocalFile()); reader.setAutoTransform(true); const QSize imageSize = reader.size(); if (imageSize.isValid() && (imageSize.width() > maximumImageSize().width() || imageSize.height() > maximumImageSize().height())) { const QSize thumbnailSize = imageSize.scaled(maximumImageSize(), Qt::KeepAspectRatio); reader.setScaledSize(thumbnailSize); } image = reader.read(); } QString Notification::Private::defaultComponentName() { // NOTE Keep in sync with KNotification return QStringLiteral("plasma_workspace"); } QSize Notification::Private::maximumImageSize() { return QSize(256, 256); } KService::Ptr Notification::Private::serviceForDesktopEntry(const QString &desktopEntry) { KService::Ptr service; if (desktopEntry.startsWith(QLatin1Char('/'))) { service = KService::serviceByDesktopPath(desktopEntry); } else { service = KService::serviceByDesktopName(desktopEntry); } if (!service) { const QString lowerDesktopEntry = desktopEntry.toLower(); service = KService::serviceByDesktopName(lowerDesktopEntry); } // Try if it's a renamed flatpak if (!service) { const QString desktopId = desktopEntry + QLatin1String(".desktop"); // HACK Querying for XDG lists in KServiceTypeTrader does not work, do it manually const auto services = KServiceTypeTrader::self()->query(QStringLiteral("Application"), QStringLiteral("exist Exec and exist [X-Flatpak-RenamedFrom]")); for (auto it = services.constBegin(); it != services.constEnd() && !service; ++it) { const QVariant renamedFrom = (*it)->property(QStringLiteral("X-Flatpak-RenamedFrom"), QVariant::String); const auto names = renamedFrom.toString().split(QChar(';')); for (const QString &name : names) { if (name == desktopId) { service = *it; break; } } } } return service; } void Notification::Private::setDesktopEntry(const QString &desktopEntry) { QString serviceName; configurableService = false; KService::Ptr service = serviceForDesktopEntry(desktopEntry); if (service) { this->desktopEntry = service->desktopEntryName(); serviceName = service->name(); applicationIconName = service->icon(); configurableService = !service->noDisplay(); } const bool isDefaultEvent = (notifyRcName == defaultComponentName()); configurableNotifyRc = false; if (!notifyRcName.isEmpty()) { // Check whether the application actually has notifications we can configure KConfig config(notifyRcName + QStringLiteral(".notifyrc"), KConfig::NoGlobals); config.addConfigSources(QStandardPaths::locateAll(QStandardPaths::GenericDataLocation, QStringLiteral("knotifications5/") + notifyRcName + QStringLiteral(".notifyrc"))); KConfigGroup globalGroup(&config, "Global"); const QString iconName = globalGroup.readEntry("IconName"); // For default events we try to show the application name from the desktop entry if possible // This will have us show e.g. "Dr Konqi" instead of generic "Plasma Desktop" if (isDefaultEvent && !serviceName.isEmpty()) { applicationName = serviceName; } // also only overwrite application icon name for non-default events (or if we don't have a service icon) if (!iconName.isEmpty() && (!isDefaultEvent || applicationIconName.isEmpty())) { applicationIconName = iconName; } const QRegularExpression regexp(QStringLiteral("^Event/([^/]*)$")); configurableNotifyRc = !config.groupList().filter(regexp).isEmpty(); } } void Notification::Private::processHints(const QVariantMap &hints) { auto end = hints.end(); notifyRcName = hints.value(QStringLiteral("x-kde-appname")).toString(); setDesktopEntry(hints.value(QStringLiteral("desktop-entry")).toString()); // Special override for KDE Connect since the notification is sent by kdeconnectd // but actually comes from a different app on the phone const QString applicationDisplayName = hints.value(QStringLiteral("x-kde-display-appname")).toString(); if (!applicationDisplayName.isEmpty()) { applicationName = applicationDisplayName; } originName = hints.value(QStringLiteral("x-kde-origin-name")).toString(); eventId = hints.value(QStringLiteral("x-kde-eventId")).toString(); bool ok; const int urgency = hints.value(QStringLiteral("urgency")).toInt(&ok); // DBus type is actually "byte" if (ok) { // FIXME use separate enum again switch (urgency) { case 0: setUrgency(Notifications::LowUrgency); break; case 1: setUrgency(Notifications::NormalUrgency); break; case 2: setUrgency(Notifications::CriticalUrgency); break; } } urls = QUrl::fromStringList(hints.value(QStringLiteral("x-kde-urls")).toStringList()); // Underscored hints was in use in version 1.1 of the spec but has been // replaced by dashed hints in version 1.2. We need to support it for // users of the 1.2 version of the spec. auto it = hints.find(QStringLiteral("image-data")); if (it == end) { it = hints.find(QStringLiteral("image_data")); } if (it == end) { // This hint was in use in version 1.0 of the spec but has been // replaced by "image_data" in version 1.1. We need to support it for // users of the 1.0 version of the spec. it = hints.find(QStringLiteral("icon_data")); } if (it != end) { image = decodeNotificationSpecImageHint(it->value()); } if (image.isNull()) { it = hints.find(QStringLiteral("image-path")); if (it == end) { it = hints.find(QStringLiteral("image_path")); } if (it != end) { loadImagePath(it->toString()); } } sanitizeImage(image); } void Notification::Private::setUrgency(Notifications::Urgency urgency) { this->urgency = urgency; // Critical notifications must not time out // TODO should we really imply this here and not on the view side? // are there usecases for critical but can expire? // "critical updates available"? if (urgency == Notifications::CriticalUrgency) { timeout = 0; } } Notification::Notification(uint id) : d(new Private()) { d->id = id; d->created = QDateTime::currentDateTimeUtc(); } Notification::Notification(const Notification &other) : d(new Private(*other.d)) { } Notification::Notification(Notification &&other) : d(other.d) { other.d = nullptr; } Notification &Notification::operator=(const Notification &other) { d = new Private(*other.d); return *this; } Notification &Notification::operator=(Notification &&other) { d = other.d; other.d = nullptr; return *this; } Notification::~Notification() { delete d; } uint Notification::id() const { return d->id; } QDateTime Notification::created() const { return d->created; } QDateTime Notification::updated() const { return d->updated; } void Notification::resetUpdated() { d->updated = QDateTime::currentDateTimeUtc(); } +bool Notification::read() const +{ + return d->read; +} + +void Notification::setRead(bool read) +{ + d->read = read; +} + QString Notification::summary() const { return d->summary; } void Notification::setSummary(const QString &summary) { d->summary = summary; } QString Notification::body() const { return d->body; } void Notification::setBody(const QString &body) { d->body = Private::sanitize(body.trimmed()); } QString Notification::icon() const { return d->icon; } void Notification::setIcon(const QString &icon) { d->loadImagePath(icon); Private::sanitizeImage(d->image); } QImage Notification::image() const { return d->image; } void Notification::setImage(const QImage &image) { d->image = image; } QString Notification::desktopEntry() const { return d->desktopEntry; } void Notification::setDesktopEntry(const QString &desktopEntry) { d->setDesktopEntry(desktopEntry); } QString Notification::notifyRcName() const { return d->notifyRcName; } QString Notification::eventId() const { return d->eventId; } QString Notification::applicationName() const { return d->applicationName; } void Notification::setApplicationName(const QString &applicationName) { d->applicationName = applicationName; } QString Notification::applicationIconName() const { return d->applicationIconName; } void Notification::setApplicationIconName(const QString &applicationIconName) { d->applicationIconName = applicationIconName; } QString Notification::originName() const { return d->originName; } QStringList Notification::actionNames() const { return d->actionNames; } QStringList Notification::actionLabels() const { return d->actionLabels; } bool Notification::hasDefaultAction() const { return d->hasDefaultAction; } QString Notification::defaultActionLabel() const { return d->defaultActionLabel; } void Notification::setActions(const QStringList &actions) { if (actions.count() % 2 != 0) { qCWarning(NOTIFICATIONMANAGER) << "List of actions must contain an even number of items, tried to set actions to" << actions; return; } d->hasDefaultAction = false; d->hasConfigureAction = false; QStringList names; QStringList labels; for (int i = 0; i < actions.count(); i += 2) { const QString &name = actions.at(i); const QString &label = actions.at(i + 1); if (!d->hasDefaultAction && name == QLatin1String("default")) { d->hasDefaultAction = true; d->defaultActionLabel = label; continue; } if (!d->hasConfigureAction && name == QLatin1String("settings")) { d->hasConfigureAction = true; d->configureActionLabel = label; continue; } names << name; labels << label; } d->actionNames = names; d->actionLabels = labels; } QList Notification::urls() const { return d->urls; } void Notification::setUrls(const QList &urls) { d->urls = urls; } Notifications::Urgency Notification::urgency() const { return d->urgency; } int Notification::timeout() const { return d->timeout; } void Notification::setTimeout(int timeout) { d->timeout = timeout; } bool Notification::configurable() const { return d->hasConfigureAction || d->configurableNotifyRc || d->configurableService; } QString Notification::configureActionLabel() const { return d->configureActionLabel; } bool Notification::expired() const { return d->expired; } void Notification::setExpired(bool expired) { d->expired = expired; } bool Notification::dismissed() const { return d->dismissed; } void Notification::setDismissed(bool dismissed) { d->dismissed = dismissed; } void Notification::processHints(const QVariantMap &hints) { d->processHints(hints); } diff --git a/libnotificationmanager/notification.h b/libnotificationmanager/notification.h index 27c10ed70..95d303324 100644 --- a/libnotificationmanager/notification.h +++ b/libnotificationmanager/notification.h @@ -1,124 +1,127 @@ /* * Copyright 2018-2019 Kai Uwe Broulik * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) version 3, or any * later version accepted by the membership of KDE e.V. (or its * successor approved by the membership of KDE e.V.), which shall * act as a proxy defined in Section 6 of version 3 of the license. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . */ #pragma once #include #include #include #include #include #include "notifications.h" #include "notificationmanager_export.h" namespace NotificationManager { /** * @short Represents a single notification * * @author Kai Uwe Broulik **/ class NOTIFICATIONMANAGER_EXPORT Notification { public: explicit Notification(uint id = 0); Notification(const Notification &other); Notification(Notification &&other) Q_DECL_NOEXCEPT; Notification &operator=(const Notification &other); Notification &operator=(Notification &&other) Q_DECL_NOEXCEPT; virtual ~Notification(); uint id() const; QDateTime created() const; QDateTime updated() const; void resetUpdated(); + bool read() const; + void setRead(bool read); + QString summary() const; void setSummary(const QString &summary); QString body() const; void setBody(const QString &body); QString icon() const; void setIcon(const QString &icon); QImage image() const; void setImage(const QImage &image); QString desktopEntry() const; void setDesktopEntry(const QString &desktopEntry); QString notifyRcName() const; QString eventId() const; QString applicationName() const; void setApplicationName(const QString &applicationName); QString applicationIconName() const; void setApplicationIconName(const QString &applicationIconName); QString originName() const; // should we group the two into a QPair or something? QStringList actionNames() const; QStringList actionLabels() const; bool hasDefaultAction() const; QString defaultActionLabel() const; void setActions(const QStringList &actions); QList urls() const; void setUrls(const QList &urls); // FIXME use separate enum again Notifications::Urgency urgency() const; void setUrgency(Notifications::Urgency urgency); int timeout() const; void setTimeout(int timeout); bool configurable() const; QString configureActionLabel() const; bool expired() const; void setExpired(bool expired); bool dismissed() const; void setDismissed(bool dismissed); void processHints(const QVariantMap &hints); private: friend class NotificationsModel; friend class ServerPrivate; class Private; Private *d; }; } // namespace NotificationManager diff --git a/libnotificationmanager/notification_p.h b/libnotificationmanager/notification_p.h index a533aec93..dfbdc27ee 100644 --- a/libnotificationmanager/notification_p.h +++ b/libnotificationmanager/notification_p.h @@ -1,99 +1,100 @@ /* * Copyright 2018-2019 Kai Uwe Broulik * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) version 3, or any * later version accepted by the membership of KDE e.V. (or its * successor approved by the membership of KDE e.V.), which shall * act as a proxy defined in Section 6 of version 3 of the license. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . */ #pragma once #include #include #include #include #include #include #include #include #include "notifications.h" namespace NotificationManager { class Q_DECL_HIDDEN Notification::Private { public: Private(); ~Private(); static QString sanitize(const QString &text); static QImage decodeNotificationSpecImageHint(const QDBusArgument &arg); static void sanitizeImage(QImage &image); void loadImagePath(const QString &path); static QString defaultComponentName(); static QSize maximumImageSize(); static KService::Ptr serviceForDesktopEntry(const QString &desktopEntry); void setDesktopEntry(const QString &desktopEntry); void processHints(const QVariantMap &hints); void setUrgency(Notifications::Urgency urgency); uint id = 0; QDateTime created; QDateTime updated; + bool read = false; QString summary; QString body; // Can be theme icon name or path QString icon; QImage image; QString applicationName; QString desktopEntry; bool configurableService = false; QString serviceName; // "Name" field in KService from desktopEntry QString applicationIconName; QString originName; QStringList actionNames; QStringList actionLabels; bool hasDefaultAction = false; QString defaultActionLabel; bool hasConfigureAction = false; QString configureActionLabel; bool configurableNotifyRc = false; QString notifyRcName; QString eventId; QList urls; Notifications::Urgency urgency = Notifications::NormalUrgency; int timeout = -1; bool expired = false; bool dismissed = false; }; } // namespace NotificationManager diff --git a/libnotificationmanager/notificationgroupcollapsingproxymodel.cpp b/libnotificationmanager/notificationgroupcollapsingproxymodel.cpp index 969c7e8b8..34a1ac87c 100644 --- a/libnotificationmanager/notificationgroupcollapsingproxymodel.cpp +++ b/libnotificationmanager/notificationgroupcollapsingproxymodel.cpp @@ -1,220 +1,223 @@ /* * Copyright 2019 Kai Uwe Broulik * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) version 3, or any * later version accepted by the membership of KDE e.V. (or its * successor approved by the membership of KDE e.V.), which shall * act as a proxy defined in Section 6 of version 3 of the license. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . */ #include "notificationgroupcollapsingproxymodel_p.h" #include "notifications.h" #include "debug.h" using namespace NotificationManager; NotificationGroupCollapsingProxyModel::NotificationGroupCollapsingProxyModel(QObject *parent) : QSortFilterProxyModel(parent) { } NotificationGroupCollapsingProxyModel::~NotificationGroupCollapsingProxyModel() = default; void NotificationGroupCollapsingProxyModel::setSourceModel(QAbstractItemModel *source) { if (sourceModel()) { disconnect(sourceModel(), nullptr, this, nullptr); } QSortFilterProxyModel::setSourceModel(source); if (source) { connect(source, &QAbstractItemModel::rowsInserted, this, &NotificationGroupCollapsingProxyModel::invalidateFilter); connect(source, &QAbstractItemModel::rowsRemoved, this, &NotificationGroupCollapsingProxyModel::invalidateFilter); // When a group is removed, there is no item that's being removed, instead the item morphs back into a single notification connect(source, &QAbstractItemModel::dataChanged, this, [this, source](const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles) { if (roles.isEmpty() || roles.contains(Notifications::IsGroupRole)) { for (int i = topLeft.row(); i <= bottomRight.row(); ++i) { const QModelIndex sourceIdx = source->index(i, 0); if (!sourceIdx.data(Notifications::IsGroupRole).toBool()) { if (m_expandedGroups.contains(sourceIdx)) { setGroupExpanded(topLeft, false); } } } } }); } } QVariant NotificationGroupCollapsingProxyModel::data(const QModelIndex &index, int role) const { switch (role) { case Notifications::IsGroupExpandedRole: { if (m_limit > 0) { // so each item in a group knows whether the group is expanded const QModelIndex sourceIdx = mapToSource(index); return m_expandedGroups.contains(sourceIdx.parent().isValid() ? sourceIdx.parent() : sourceIdx); } return true; } case Notifications::ExpandedGroupChildrenCountRole: return rowCount(index.parent().isValid() ? index.parent() : index); } return QSortFilterProxyModel::data(index, role); } bool NotificationGroupCollapsingProxyModel::setData(const QModelIndex &index, const QVariant &value, int role) { if (role == Notifications::IsGroupExpandedRole && m_limit > 0) { QModelIndex groupIdx = index; // so an item inside a group can expand/collapse the group if (groupIdx.parent().isValid()) { groupIdx = groupIdx.parent(); } const bool expanded = value.toBool(); if (!groupIdx.data(Notifications::IsGroupRole).toBool()) { qCWarning(NOTIFICATIONMANAGER) << "Cannot" << (expanded ? "expand" : "collapse") << "an item isn't a group or inside of one"; return false; } return setGroupExpanded(groupIdx, expanded); } return QSortFilterProxyModel::setData(index, value, role); } int NotificationGroupCollapsingProxyModel::limit() const { return m_limit; } void NotificationGroupCollapsingProxyModel::setLimit(int limit) { if (m_limit != limit) { m_limit = limit; invalidateFilter(); invalidateGroupRoles(); emit limitChanged(); } } QDateTime NotificationGroupCollapsingProxyModel::lastRead() const { return m_lastRead; } void NotificationGroupCollapsingProxyModel::setLastRead(const QDateTime &lastRead) { if (m_lastRead != lastRead) { m_lastRead = lastRead; invalidateFilter(); invalidateGroupRoles(); emit lastReadChanged(); } } bool NotificationGroupCollapsingProxyModel::expandUnread() const { return m_expandUnread; } void NotificationGroupCollapsingProxyModel::setExpandUnread(bool expand) { if (m_expandUnread != expand) { m_expandUnread = expand; invalidateFilter(); invalidateGroupRoles(); emit expandUnreadChanged(); } } void NotificationGroupCollapsingProxyModel::collapseAll() { m_expandedGroups.clear(); invalidateFilter(); invalidateGroupRoles(); } bool NotificationGroupCollapsingProxyModel::setGroupExpanded(const QModelIndex &idx, bool expanded) { if (idx.data(Notifications::IsGroupExpandedRole).toBool() == expanded) { return false; } QPersistentModelIndex persistentIdx(mapToSource(idx)); if (expanded) { m_expandedGroups.append(persistentIdx); } else { m_expandedGroups.removeOne(persistentIdx); } invalidateFilter(); const QVector dirtyRoles = {Notifications::ExpandedGroupChildrenCountRole, Notifications::IsGroupExpandedRole}; emit dataChanged(idx, idx, dirtyRoles); emit dataChanged(index(0, 0, idx), index(rowCount(idx) - 1, 0, idx), dirtyRoles); return true; } void NotificationGroupCollapsingProxyModel::invalidateGroupRoles() { const QVector dirtyRoles = {Notifications::ExpandedGroupChildrenCountRole, Notifications::IsGroupExpandedRole}; emit dataChanged(index(0, 0), index(rowCount() - 1, 0), dirtyRoles); for (int row = 0; row < rowCount(); ++row) { const QModelIndex groupIdx = index(row, 0); emit dataChanged(index(0, 0, groupIdx), index(rowCount(groupIdx) - 1, 0, groupIdx), dirtyRoles); } } bool NotificationGroupCollapsingProxyModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const { if (m_limit > 0 && source_parent.isValid()) { if (!m_expandedGroups.isEmpty() && m_expandedGroups.contains(source_parent)) { return true; } if (m_expandUnread && m_lastRead.isValid()) { const QModelIndex sourceIdx = sourceModel()->index(source_row, 0, source_parent); - QDateTime time = sourceIdx.data(Notifications::UpdatedRole).toDateTime(); - if (!time.isValid()) { - time = sourceIdx.data(Notifications::CreatedRole).toDateTime(); - } - if (time.isValid() && m_lastRead < time) { - return true; + if (!sourceIdx.data(Notifications::ReadRole).toBool()) { + QDateTime time = sourceIdx.data(Notifications::UpdatedRole).toDateTime(); + if (!time.isValid()) { + time = sourceIdx.data(Notifications::CreatedRole).toDateTime(); + } + + if (time.isValid() && m_lastRead < time) { + return true; + } } } // should we raise the limit when there's just one group? // FIXME why is this reversed? // grouping proxy model seems to reverse the order? return source_row >= sourceModel()->rowCount(source_parent) - m_limit; } return true; } diff --git a/libnotificationmanager/notifications.cpp b/libnotificationmanager/notifications.cpp index f265e140f..7b16de7a7 100644 --- a/libnotificationmanager/notifications.cpp +++ b/libnotificationmanager/notifications.cpp @@ -1,850 +1,850 @@ /* * Copyright 2019 Kai Uwe Broulik * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) version 3, or any * later version accepted by the membership of KDE e.V. (or its * successor approved by the membership of KDE e.V.), which shall * act as a proxy defined in Section 6 of version 3 of the license. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . */ #include "notifications.h" #include #include #include #include #include #include "notificationsmodel.h" #include "notificationfilterproxymodel_p.h" #include "notificationsortproxymodel_p.h" #include "notificationgroupingproxymodel_p.h" #include "notificationgroupcollapsingproxymodel_p.h" #include "limitedrowcountproxymodel_p.h" #include "jobsmodel.h" #include "settings.h" #include "notification.h" #include "utils_p.h" #include "debug.h" using namespace NotificationManager; class Q_DECL_HIDDEN Notifications::Private { public: explicit Private(Notifications *q); ~Private(); void initSourceModels(); void initProxyModels(); void updateCount(); bool showNotifications = true; bool showJobs = false; Notifications::GroupMode groupMode = Notifications::GroupDisabled; int groupLimit = 0; bool expandUnread = false; int activeNotificationsCount = 0; int expiredNotificationsCount = 0; int unreadNotificationsCount = 0; int activeJobsCount = 0; int jobsPercentage = 0; static bool isGroup(const QModelIndex &idx); static uint notificationId(const QModelIndex &idx); QModelIndex mapFromModel(const QModelIndex &idx) const; // NOTE when you add or re-arrange models make sure to update mapFromModel()! NotificationsModel::Ptr notificationsModel; JobsModel::Ptr jobsModel; QSharedPointer settings() const; KConcatenateRowsProxyModel *notificationsAndJobsModel = nullptr; NotificationFilterProxyModel *filterModel = nullptr; NotificationSortProxyModel *sortModel = nullptr; NotificationGroupingProxyModel *groupingModel = nullptr; NotificationGroupCollapsingProxyModel *groupCollapsingModel = nullptr; KDescendantsProxyModel *flattenModel = nullptr; LimitedRowCountProxyModel *limiterModel = nullptr; private: Notifications *q; }; Notifications::Private::Private(Notifications *q) : q(q) { } Notifications::Private::~Private() { } void Notifications::Private::initSourceModels() { Q_ASSERT(notificationsAndJobsModel); // initProxyModels must be called before initSourceModels if (showNotifications && !notificationsModel) { notificationsModel = NotificationsModel::createNotificationsModel(); connect(notificationsModel.data(), &NotificationsModel::lastReadChanged, q, [this] { updateCount(); emit q->lastReadChanged(); }); notificationsAndJobsModel->addSourceModel(notificationsModel.data()); } else if (!showNotifications && notificationsModel) { notificationsAndJobsModel->removeSourceModel(notificationsModel.data()); disconnect(notificationsModel.data(), nullptr, q, nullptr); // disconnect all notificationsModel = nullptr; } if (showJobs && !jobsModel) { jobsModel = JobsModel::createJobsModel(); notificationsAndJobsModel->addSourceModel(jobsModel.data()); jobsModel->init(); } else if (!showJobs && jobsModel) { notificationsAndJobsModel->removeSourceModel(jobsModel.data()); jobsModel = nullptr; } } void Notifications::Private::initProxyModels() { /* The data flow is as follows: * NOTE when you add or re-arrange models make sure to update mapFromModel()! * * NotificationsModel JobsModel * \\ / * \\ / * KConcatenateRowsProxyModel * ||| * ||| * NotificationFilterProxyModel * (filters by urgency, whitelist, etc) * | * | * NotificationSortProxyModel * (sorts by urgency, date, etc) * | * --- BEGIN: Only when grouping is enabled --- * | * NotificationGroupingProxyModel * (turns list into tree grouped by app) * //\\ * //\\ * NotificationGroupCollapsingProxyModel * (limits number of tree leaves for expand/collapse feature) * /\ * /\ * KDescendantsProxyModel * (flattens tree back into a list for consumption in ListView) * | * --- END: Only when grouping is enabled --- * | * LimitedRowCountProxyModel * (limits the total number of items in the model) * | * | * \o/ <- Happy user seeing their notifications */ if (!notificationsAndJobsModel) { notificationsAndJobsModel = new KConcatenateRowsProxyModel(q); } if (!filterModel) { filterModel = new NotificationFilterProxyModel(); connect(filterModel, &NotificationFilterProxyModel::urgenciesChanged, q, &Notifications::urgenciesChanged); connect(filterModel, &NotificationFilterProxyModel::showExpiredChanged, q, &Notifications::showExpiredChanged); connect(filterModel, &NotificationFilterProxyModel::showDismissedChanged, q, &Notifications::showDismissedChanged); connect(filterModel, &NotificationFilterProxyModel::blacklistedDesktopEntriesChanged, q, &Notifications::blacklistedDesktopEntriesChanged); connect(filterModel, &NotificationFilterProxyModel::blacklistedNotifyRcNamesChanged, q, &Notifications::blacklistedNotifyRcNamesChanged); connect(filterModel, &QAbstractItemModel::rowsInserted, q, [this] { updateCount(); }); connect(filterModel, &QAbstractItemModel::rowsRemoved, q, [this] { updateCount(); }); connect(filterModel, &QAbstractItemModel::dataChanged, q, [this](const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles) { Q_UNUSED(topLeft); Q_UNUSED(bottomRight); if (roles.isEmpty() || roles.contains(Notifications::UpdatedRole) || roles.contains(Notifications::ExpiredRole) || roles.contains(Notifications::JobStateRole) || roles.contains(Notifications::PercentageRole)) { updateCount(); } }); filterModel->setSourceModel(notificationsAndJobsModel); } if (!sortModel) { sortModel = new NotificationSortProxyModel(q); connect(sortModel, &NotificationSortProxyModel::sortModeChanged, q, &Notifications::sortModeChanged); } if (!limiterModel) { limiterModel = new LimitedRowCountProxyModel(q); connect(limiterModel, &LimitedRowCountProxyModel::limitChanged, q, &Notifications::limitChanged); } if (groupMode == GroupApplicationsFlat) { if (!groupingModel) { groupingModel = new NotificationGroupingProxyModel(q); groupingModel->setSourceModel(filterModel); } if (!groupCollapsingModel) { groupCollapsingModel = new NotificationGroupCollapsingProxyModel(q); groupCollapsingModel->setLimit(groupLimit); groupCollapsingModel->setExpandUnread(expandUnread); groupCollapsingModel->setLastRead(q->lastRead()); groupCollapsingModel->setSourceModel(groupingModel); } sortModel->setSourceModel(groupCollapsingModel); flattenModel = new KDescendantsProxyModel(q); flattenModel->setSourceModel(sortModel); limiterModel->setSourceModel(flattenModel); } else { sortModel->setSourceModel(filterModel); limiterModel->setSourceModel(sortModel); delete flattenModel; flattenModel = nullptr; delete groupingModel; groupingModel = nullptr; } q->setSourceModel(limiterModel); } void Notifications::Private::updateCount() { int active = 0; int expired = 0; int unread = 0; int jobs = 0; int totalPercentage = 0; // We want to get the numbers after main filtering (urgencies, whitelists, etc) // but before any limiting or group limiting, hence asking the filterModel for advice // at which point notifications and jobs also have already been merged for (int i = 0; i < filterModel->rowCount(); ++i) { const QModelIndex idx = filterModel->index(i, 0); if (idx.data(Notifications::ExpiredRole).toBool()) { ++expired; } else { ++active; } - QDateTime date = idx.data(Notifications::UpdatedRole).toDateTime(); - if (!date.isValid()) { - date = idx.data(Notifications::CreatedRole).toDateTime(); - } + const bool read = idx.data(Notifications::ReadRole).toBool(); + if (!active && !read) { + QDateTime date = idx.data(Notifications::UpdatedRole).toDateTime(); + if (!date.isValid()) { + date = idx.data(Notifications::CreatedRole).toDateTime(); + } - // TODO Jobs could also be unread? - if (notificationsModel) { - if (!active && date > notificationsModel->lastRead()) { + if (notificationsModel && date > notificationsModel->lastRead()) { ++unread; } } if (idx.data(Notifications::TypeRole).toInt() == Notifications::JobType) { if (idx.data(Notifications::JobStateRole).toInt() != Notifications::JobStateStopped) { ++jobs; totalPercentage += idx.data(Notifications::PercentageRole).toInt(); } } } if (activeNotificationsCount != active) { activeNotificationsCount = active; emit q->activeNotificationsCountChanged(); } if (expiredNotificationsCount != expired) { expiredNotificationsCount = expired; emit q->expiredNotificationsCountChanged(); } if (unreadNotificationsCount != unread) { unreadNotificationsCount = unread; emit q->unreadNotificationsCountChanged(); } if (activeJobsCount != jobs) { activeJobsCount = jobs; emit q->activeJobsCountChanged(); } const int percentage = (jobs > 0 ? totalPercentage / jobs : 0); if (jobsPercentage != percentage) { jobsPercentage = percentage; emit q->jobsPercentageChanged(); } // TODO don't emit in dataChanged emit q->countChanged(); } bool Notifications::Private::isGroup(const QModelIndex &idx) { return idx.data(Notifications::IsGroupRole).toBool(); } uint Notifications::Private::notificationId(const QModelIndex &idx) { return idx.data(Notifications::IdRole).toUInt(); } QModelIndex Notifications::Private::mapFromModel(const QModelIndex &idx) const { QModelIndex resolvedIdx = idx; QAbstractItemModel *models[] = { notificationsAndJobsModel, filterModel, sortModel, groupingModel, groupCollapsingModel, flattenModel, limiterModel, }; // TODO can we do this with a generic loop like mapFromModel while (resolvedIdx.isValid() && resolvedIdx.model() != q) { const auto *idxModel = resolvedIdx.model(); // HACK try to find the model that uses the index' model as source bool found = false; for (QAbstractItemModel *model : models) { if (!model) { continue; } if (auto *proxyModel = qobject_cast(model)) { if (proxyModel->sourceModel() == idxModel) { resolvedIdx = proxyModel->mapFromSource(resolvedIdx); found = true; break; } } else if (auto *concatenateModel = qobject_cast(model)) { // There's no "sourceModels()" on KConcatenateRowsProxyModel if (idxModel == notificationsModel.data() || idxModel == jobsModel.data()) { resolvedIdx = concatenateModel->mapFromSource(resolvedIdx); found = true; break; } } } if (!found) { break; } } return resolvedIdx; } QSharedPointer Notifications::Private::settings() const { static QWeakPointer s_instance; if (!s_instance) { QSharedPointer ptr(new Settings()); s_instance = ptr.toWeakRef(); return ptr; } return s_instance.toStrongRef(); } Notifications::Notifications(QObject *parent) : QSortFilterProxyModel(parent) , d(new Private(this)) { // The proxy models are always the same, just with different // properties set whereas we want to avoid loading a source model // e.g. notifications or jobs when we're not actually using them d->initProxyModels(); // init source models when used from C++ QMetaObject::invokeMethod(this, [this] { d->initSourceModels(); }, Qt::QueuedConnection); } Notifications::~Notifications() = default; void Notifications::classBegin() { } void Notifications::componentComplete() { // init source models when used from QML d->initSourceModels(); } int Notifications::limit() const { return d->limiterModel->limit(); } void Notifications::setLimit(int limit) { d->limiterModel->setLimit(limit); } int Notifications::groupLimit() const { return d->groupLimit; } void Notifications::setGroupLimit(int limit) { if (d->groupLimit == limit) { return; } d->groupLimit = limit; if (d->groupCollapsingModel) { d->groupCollapsingModel->setLimit(limit); } emit groupLimitChanged(); } bool Notifications::expandUnread() const { return d->expandUnread; } void Notifications::setExpandUnread(bool expand) { if (d->expandUnread == expand) { return; } d->expandUnread = expand; if (d->groupCollapsingModel) { d->groupCollapsingModel->setExpandUnread(expand); } emit expandUnreadChanged(); } bool Notifications::showExpired() const { return d->filterModel->showExpired(); } void Notifications::setShowExpired(bool show) { d->filterModel->setShowExpired(show); } bool Notifications::showDismissed() const { return d->filterModel->showDismissed(); } void Notifications::setShowDismissed(bool show) { d->filterModel->setShowDismissed(show); } QStringList Notifications::blacklistedDesktopEntries() const { return d->filterModel->blacklistedDesktopEntries(); } void Notifications::setBlacklistedDesktopEntries(const QStringList &blacklist) { d->filterModel->setBlackListedDesktopEntries(blacklist); } QStringList Notifications::blacklistedNotifyRcNames() const { return d->filterModel->blacklistedNotifyRcNames(); } void Notifications::setBlacklistedNotifyRcNames(const QStringList &blacklist) { d->filterModel->setBlacklistedNotifyRcNames(blacklist); } QStringList Notifications::whitelistedDesktopEntries() const { return d->filterModel->whitelistedDesktopEntries(); } void Notifications::setWhitelistedDesktopEntries(const QStringList &whitelist) { d->filterModel->setWhiteListedDesktopEntries(whitelist); } QStringList Notifications::whitelistedNotifyRcNames() const { return d->filterModel->whitelistedNotifyRcNames(); } void Notifications::setWhitelistedNotifyRcNames(const QStringList &whitelist) { d->filterModel->setWhitelistedNotifyRcNames(whitelist); } bool Notifications::showNotifications() const { return d->showNotifications; } void Notifications::setShowNotifications(bool show) { if (d->showNotifications == show) { return; } d->showNotifications = show; d->initSourceModels(); emit showNotificationsChanged(); } bool Notifications::showJobs() const { return d->showJobs; } void Notifications::setShowJobs(bool show) { if (d->showJobs == show) { return; } d->showJobs = show; d->initSourceModels(); emit showJobsChanged(); } Notifications::Urgencies Notifications::urgencies() const { return d->filterModel->urgencies(); } void Notifications::setUrgencies(Urgencies urgencies) { d->filterModel->setUrgencies(urgencies); } Notifications::SortMode Notifications::sortMode() const { return d->sortModel->sortMode(); } void Notifications::setSortMode(SortMode sortMode) { d->sortModel->setSortMode(sortMode); } Notifications::GroupMode Notifications::groupMode() const { return d->groupMode; } void Notifications::setGroupMode(GroupMode groupMode) { if (d->groupMode != groupMode) { d->groupMode = groupMode; d->initProxyModels(); emit groupModeChanged(); } } int Notifications::count() const { return rowCount(QModelIndex()); } int Notifications::activeNotificationsCount() const { return d->activeNotificationsCount; } int Notifications::expiredNotificationsCount() const { return d->expiredNotificationsCount; } QDateTime Notifications::lastRead() const { if (d->notificationsModel) { return d->notificationsModel->lastRead(); } return QDateTime(); } void Notifications::setLastRead(const QDateTime &lastRead) { // TODO jobs could also be unread? if (d->notificationsModel) { d->notificationsModel->setLastRead(lastRead); } if (d->groupCollapsingModel) { d->groupCollapsingModel->setLastRead(lastRead); } } void Notifications::resetLastRead() { setLastRead(QDateTime::currentDateTimeUtc()); } int Notifications::unreadNotificationsCount() const { return d->unreadNotificationsCount; } int Notifications::activeJobsCount() const { return d->activeJobsCount; } int Notifications::jobsPercentage() const { return d->jobsPercentage; } QPersistentModelIndex Notifications::makePersistentModelIndex(const QModelIndex &idx) const { return QPersistentModelIndex(idx); } void Notifications::expire(const QModelIndex &idx) { switch (static_cast(idx.data(Notifications::TypeRole).toInt())) { case Notifications::NotificationType: d->notificationsModel->expire(Private::notificationId(idx)); break; case Notifications::JobType: d->jobsModel->expire(Utils::mapToModel(idx, d->jobsModel.data())); break; default: Q_UNREACHABLE(); } } void Notifications::close(const QModelIndex &idx) { if (idx.data(Notifications::IsGroupRole).toBool()) { const QModelIndex groupIdx = Utils::mapToModel(idx, d->groupingModel); if (!groupIdx.isValid()) { qCWarning(NOTIFICATIONMANAGER) << "Failed to find group model index for this item"; return; } Q_ASSERT(groupIdx.model() == d->groupingModel); const int childCount = d->groupingModel->rowCount(groupIdx); for (int i = childCount - 1; i >= 0; --i) { const QModelIndex childIdx = d->groupingModel->index(i, 0, groupIdx); close(childIdx); } return; } if (!idx.data(Notifications::ClosableRole).toBool()) { return; } switch (static_cast(idx.data(Notifications::TypeRole).toInt())) { case Notifications::NotificationType: d->notificationsModel->close(Private::notificationId(idx)); break; case Notifications::JobType: d->jobsModel->close(Utils::mapToModel(idx, d->jobsModel.data())); break; default: Q_UNREACHABLE(); } } void Notifications::configure(const QModelIndex &idx) { if (!d->notificationsModel) { return; } // For groups just configure the application, not the individual event if (Private::isGroup(idx)) { const QString desktopEntry = idx.data(Notifications::DesktopEntryRole).toString(); const QString notifyRcName = idx.data(Notifications::NotifyRcNameRole).toString(); d->notificationsModel->configure(desktopEntry, notifyRcName, QString() /*eventId*/); return; } d->notificationsModel->configure(Private::notificationId(idx)); } void Notifications::invokeDefaultAction(const QModelIndex &idx) { if (d->notificationsModel) { d->notificationsModel->invokeDefaultAction(Private::notificationId(idx)); } } void Notifications::invokeAction(const QModelIndex &idx, const QString &actionId) { if (d->notificationsModel) { d->notificationsModel->invokeAction(Private::notificationId(idx), actionId); } } void Notifications::startTimeout(const QModelIndex &idx) { startTimeout(Private::notificationId(idx)); } void Notifications::startTimeout(uint notificationId) { if (d->notificationsModel) { d->notificationsModel->startTimeout(notificationId); } } void Notifications::stopTimeout(const QModelIndex &idx) { if (d->notificationsModel) { d->notificationsModel->stopTimeout(Private::notificationId(idx)); } } void Notifications::suspendJob(const QModelIndex &idx) { if (d->jobsModel) { d->jobsModel->suspend(Utils::mapToModel(idx, d->jobsModel.data())); } } void Notifications::resumeJob(const QModelIndex &idx) { if (d->jobsModel) { d->jobsModel->resume(Utils::mapToModel(idx, d->jobsModel.data())); } } void Notifications::killJob(const QModelIndex &idx) { if (d->jobsModel) { d->jobsModel->kill(Utils::mapToModel(idx, d->jobsModel.data())); } } void Notifications::clear(ClearFlags flags) { if (d->notificationsModel) { d->notificationsModel->clear(flags); } if (d->jobsModel) { d->jobsModel->clear(flags); } } QModelIndex Notifications::groupIndex(const QModelIndex &idx) const { if (idx.data(Notifications::IsGroupRole).toBool()) { return idx; } if (idx.data(Notifications::IsInGroupRole).toBool()) { QModelIndex groupingIdx = Utils::mapToModel(idx, d->groupingModel); return d->mapFromModel(groupingIdx.parent()); } qCWarning(NOTIFICATIONMANAGER) << "Cannot get group index for item that isn't a group or inside one"; return QModelIndex(); } void Notifications::collapseAllGroups() { if (d->groupCollapsingModel) { d->groupCollapsingModel->collapseAll(); } } QVariant Notifications::data(const QModelIndex &index, int role) const { return QSortFilterProxyModel::data(index, role); } bool Notifications::setData(const QModelIndex &index, const QVariant &value, int role) { return QSortFilterProxyModel::setData(index, value, role); } bool Notifications::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const { return QSortFilterProxyModel::filterAcceptsRow(source_row, source_parent); } bool Notifications::lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const { return QSortFilterProxyModel::lessThan(source_left, source_right); } int Notifications::rowCount(const QModelIndex &parent) const { return QSortFilterProxyModel::rowCount(parent); } QHash Notifications::roleNames() const { static QHash s_roles; if (s_roles.isEmpty()) { s_roles = QSortFilterProxyModel::roleNames(); // This generates role names from the Roles enum in the form of: FooRole -> foo const QMetaEnum e = staticMetaObject.enumerator(staticMetaObject.indexOfEnumerator("Roles")); for (int i = 0; i < e.keyCount(); ++i) { const int value = e.value(i); QByteArray key(e.key(i)); key[0] = key[0] + 32; // lower case first letter key.chop(4); // strip "Role" suffix s_roles.insert(value, key); } s_roles.insert(IdRole, QByteArrayLiteral("notificationId")); // id is QML-reserved } return s_roles; } diff --git a/libnotificationmanager/notifications.h b/libnotificationmanager/notifications.h index 3dc22e514..15c39f86b 100644 --- a/libnotificationmanager/notifications.h +++ b/libnotificationmanager/notifications.h @@ -1,525 +1,526 @@ /* * Copyright 2019 Kai Uwe Broulik * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) version 3, or any * later version accepted by the membership of KDE e.V. (or its * successor approved by the membership of KDE e.V.), which shall * act as a proxy defined in Section 6 of version 3 of the license. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . */ #pragma once #include #include #include #include "notificationmanager_export.h" namespace NotificationManager { /** * @brief A model with notifications and jobs * * This model contains application notifications as well as jobs * and lets you apply fine-grained filter, sorting, and grouping rules. * * @author Kai Uwe Broulik **/ class NOTIFICATIONMANAGER_EXPORT Notifications : public QSortFilterProxyModel, public QQmlParserStatus { Q_OBJECT Q_INTERFACES(QQmlParserStatus) /** * The number of notifications the model should at most contain. * * Default is 0, which is no limit. */ Q_PROPERTY(int limit READ limit WRITE setLimit NOTIFY limitChanged) /** * Whether to show expired notifications. * * Expired notifications are those that timed out, i.e. ones that were not explicitly * closed or acted upon by the user, nor revoked by the issuing application. * * An expired notification has its actions removed. * * Default is false. */ Q_PROPERTY(bool showExpired READ showExpired WRITE setShowExpired NOTIFY showExpiredChanged) /** * Whether to show dismissed notifications. * * Dismissed notifications are those that are temporarily hidden by the user. * This can e.g. be a copy job that has its popup closed but still continues in the background. * * Default is false. */ Q_PROPERTY(bool showDismissed READ showDismissed WRITE setShowDismissed NOTIFY showDismissedChanged) /** * A list of desktop entries for which no notifications should be shown. * * If the same desktop entry is present in both blacklist and whitelist, * the blacklist takes precedence, i.e. the notification is not shown. */ Q_PROPERTY(QStringList blacklistedDesktopEntries READ blacklistedDesktopEntries WRITE setBlacklistedDesktopEntries NOTIFY blacklistedDesktopEntriesChanged) /** * A list of notifyrc names for which no notifications should be shown. * * If the same notifyrc name is present in both blacklist and whitelist, * the blacklist takes precedence, i.e. the notification is not shown. */ Q_PROPERTY(QStringList blacklistedNotifyRcNames READ blacklistedNotifyRcNames WRITE setBlacklistedNotifyRcNames NOTIFY blacklistedNotifyRcNamesChanged) /** * A list of desktop entries for which notifications should be shown. * * This bypasses any filtering for urgency. * * If the same desktop entry is present in both whitelist and blacklist, * the blacklist takes precedence, i.e. the notification is not shown. * * Default is empty list, which means normal filtering is applied. */ Q_PROPERTY(QStringList whitelistedDesktopEntries READ whitelistedDesktopEntries WRITE setWhitelistedDesktopEntries NOTIFY whitelistedDesktopEntriesChanged) /** * A list of notifyrc names for which notifications should be shown. * * This bypasses any filtering for urgency. * * If the same notifyrc name is present in both whitelist and blacklist, * the blacklist takes precedence, i.e. the notification is not shown. * * Default is empty list, which means normal filtering is applied. */ Q_PROPERTY(QStringList whitelistedNotifyRcNames READ whitelistedNotifyRcNames WRITE setWhitelistedNotifyRcNames NOTIFY whitelistedNotifyRcNamesChanged) /** * Whether to show notifications. * * Default is true. */ Q_PROPERTY(bool showNotifications READ showNotifications WRITE setShowNotifications NOTIFY showNotificationsChanged) /** * Whether to show application jobs. * * Default is false. */ Q_PROPERTY(bool showJobs READ showJobs WRITE setShowJobs NOTIFY showJobsChanged) /** * The notification urgency types the model should contain. * * Default is all urgencies: low, normal, critical. */ Q_PROPERTY(Urgencies urgencies READ urgencies WRITE setUrgencies NOTIFY urgenciesChanged) /** * The sort mode for notifications. * * Default is strictly by date created/updated. */ Q_PROPERTY(SortMode sortMode READ sortMode WRITE setSortMode NOTIFY sortModeChanged) /** * The group mode for notifications. * * Default is ungrouped. */ Q_PROPERTY(GroupMode groupMode READ groupMode WRITE setGroupMode NOTIFY groupModeChanged) /** * How many notifications are shown in each group. * * You can expand a group by setting the IsGroupExpandedRole to true. * * Default is 0, which means no limit. */ Q_PROPERTY(int groupLimit READ groupLimit WRITE setGroupLimit NOTIFY groupLimitChanged) /** * Whether to automatically show notifications that are unread. * * This is any notification that was created or updated after the value of @c lastRead. */ Q_PROPERTY(bool expandUnread READ expandUnread WRITE setExpandUnread NOTIFY expandUnreadChanged) /** * The number of notifications in the model */ Q_PROPERTY(int count READ count NOTIFY countChanged) /** * The number of active, i.e. non-expired notifications */ Q_PROPERTY(int activeNotificationsCount READ activeNotificationsCount NOTIFY activeNotificationsCountChanged) /** * The number of inactive, i.e. non-expired notifications */ Q_PROPERTY(int expiredNotificationsCount READ expiredNotificationsCount NOTIFY expiredNotificationsCountChanged) /** * The time when the user last could read the notifications. * This is typically reset whenever the list of notifications is opened and is used to determine * the @c unreadNotificationsCount */ Q_PROPERTY(QDateTime lastRead READ lastRead WRITE setLastRead RESET resetLastRead NOTIFY lastReadChanged) /** * The number of notifications added since lastRead * * This can be used to show a "n unread notifications" label */ Q_PROPERTY(int unreadNotificationsCount READ unreadNotificationsCount NOTIFY unreadNotificationsCountChanged) /** * The number of active jobs */ Q_PROPERTY(int activeJobsCount READ activeJobsCount NOTIFY activeJobsCountChanged) /** * The combined percentage of all jobs. * * This is the average of all percentages and could can be used to show * a global progress bar. */ Q_PROPERTY(int jobsPercentage READ jobsPercentage NOTIFY jobsPercentageChanged) public: explicit Notifications(QObject *parent = nullptr); ~Notifications() override; enum Roles { IdRole = Qt::UserRole + 1, ///< A notification identifier. This can be uint notification ID or string application job source. SummaryRole = Qt::DisplayRole, ///< The notification summary. ImageRole = Qt::DecorationRole, ///< The notification main image, which is not the application icon. Only valid for pixmap icons. IsGroupRole = Qt::UserRole + 2, ///< Whether the item is a group GroupChildrenCountRole, ///< The number of children in a group. ExpandedGroupChildrenCountRole, ///< The number of children in a group that are expanded. IsGroupExpandedRole, ///< Whether the group is expanded, this role is writable. IsInGroupRole, ///< Whether the notification is currently inside a group. TypeRole, ///< The type of model entry, either NotificationType or JobType. CreatedRole, ///< When the notification was first created. UpdatedRole, ///< When the notification was last updated, invalid when it hasn't been updated. BodyRole, ///< The notification body text. IconNameRole, ///< The notification main icon name, which is not the application icon. Only valid for icon names, if a URL supplied, it is loaded and exposed as ImageRole instead. DesktopEntryRole, ///< The desktop entry (without .desktop suffix, e.g. org.kde.spectacle) of the application that sent the notification. NotifyRcNameRole, ///< The notifyrc name (e.g. spectaclerc) of the application that sent the notification. ApplicationNameRole, ///< The user-visible name of the application (e.g. Spectacle) ApplicationIconNameRole, ///< The icon name of the application OriginNameRole, ///< The name of the device or account the notification originally came from, e.g. "My Phone" (in case of device sync) or "foo@example.com" (in case of an email notification) // Jobs JobStateRole, ///< The state of the job, either JobStateJopped, JobStateSuspended, or JobStateRunning. PercentageRole, ///< The percentage of the job. Use @c jobsPercentage to get a global percentage for all jobs. JobErrorRole, ///< The error id of the job, zero in case of no error. SuspendableRole, ///< Whether the job can be suspended @sa suspendJob KillableRole, ///< Whether the job can be killed/canceled @sa killJob JobDetailsRole, ///< A pointer to a Job item itself containing more detailed information about the job ActionNamesRole, ///< The IDs of the actions, excluding the default and settings action, e.g. [action1, action2] ActionLabelsRole, ///< The user-visible labels of the actions, excluding the default and settings action, e.g. ["Accept", "Reject"] HasDefaultActionRole, ///< Whether the notification has a default action, which is one that is invoked when the popup itself is clicked DefaultActionLabelRole, ///< The user-visible label of the default action, typically not shown as the popup itself becomes clickable UrlsRole, ///< A list of URLs associated with the notification, e.g. a path to a screenshot that was just taken or image received UrgencyRole, ///< The notification urgency, either LowUrgency, NormalUrgency, or CriticalUrgency. Jobs do not have an urgency. TimeoutRole, ///< The timeout for the notification in milliseconds. 0 means the notification should not timeout, -1 means a sensible default should be applied. ConfigurableRole, ///< Whether the notification can be configured because a desktopEntry or notifyRcName is known, or the notification has a setting action. @sa configure ConfigureActionLabelRole, ///< The user-visible label for the settings action ClosableRole, ///< Whether the item can be closed. Notifications are always closable, jobs are only when in JobStateStopped. ExpiredRole, ///< The notification timed out and closed. Actions on it cannot be invoked anymore. - DismissedRole ///< The notification got temporarily hidden by the user but could still be interacted with. + DismissedRole, ///< The notification got temporarily hidden by the user but could still be interacted with. + ReadRole ///< Whether the notification got read by the user. If true, the notification isn't considered unread even if created after lastRead. @since 5.17 }; Q_ENUM(Roles) /** * The type of model item. */ enum Type { NoType, NotificationType, ///< This item represents a notification. JobType ///< This item represents an application job. }; Q_ENUM(Type) /** * The notification urgency. * * @note jobs do not have an urgency, yet still might be above normal urgency notifications. */ enum Urgency { // these don't match the spec's value LowUrgency = 1 << 0, ///< The notification has low urgency, it is not important and may not be shown or added to a history. NormalUrgency = 1 << 1, ///< The notification has normal urgency. This is also the default if no urgecny is supplied. CriticalUrgency = 1 << 2 }; Q_ENUM(Urgency) Q_DECLARE_FLAGS(Urgencies, Urgency) Q_FLAG(Urgencies) /** * Which items should be cleared in a call to @c clear */ enum ClearFlag { ClearExpired = 1 << 1, // TODO more }; Q_ENUM(ClearFlag) Q_DECLARE_FLAGS(ClearFlags, ClearFlag) Q_FLAG(ClearFlags) /** * The state an application job is in. */ enum JobState { JobStateStopped, ///< The job is stopped. It has either finished (error is 0) or failed (error is not 0) JobStateRunning, ///< The job is currently running. JobStateSuspended ///< The job is currentl paused }; Q_ENUM(JobState) /** * The sort mode for the model. */ enum SortMode { SortByDate = 0, ///< Sort notifications strictly by the date they were updated or created. // should this be flags? SortJobsFirst | SortByUrgency | ...? SortByTypeAndUrgency ///< Sort notifications taking into account their type and urgency. The order is (descending): Critical, jobs, Normal, Low. }; Q_ENUM(SortMode) /** * The group mode for the model. */ enum GroupMode { GroupDisabled = 0, //GroupApplicationsTree, // TODO make actual tree GroupApplicationsFlat }; Q_ENUM(GroupMode) int limit() const; void setLimit(int limit); bool showExpired() const; void setShowExpired(bool show); bool showDismissed() const; void setShowDismissed(bool show); QStringList blacklistedDesktopEntries() const; void setBlacklistedDesktopEntries(const QStringList &blacklist); QStringList blacklistedNotifyRcNames() const; void setBlacklistedNotifyRcNames(const QStringList &blacklist); QStringList whitelistedDesktopEntries() const; void setWhitelistedDesktopEntries(const QStringList &whitelist); QStringList whitelistedNotifyRcNames() const; void setWhitelistedNotifyRcNames(const QStringList &whitelist); bool showNotifications() const; void setShowNotifications(bool showNotifications); bool showJobs() const; void setShowJobs(bool showJobs); Urgencies urgencies() const; void setUrgencies(Urgencies urgencies); SortMode sortMode() const; void setSortMode(SortMode sortMode); GroupMode groupMode() const; void setGroupMode(GroupMode groupMode); int groupLimit() const; void setGroupLimit(int limit); bool expandUnread() const; void setExpandUnread(bool expand); int count() const; int activeNotificationsCount() const; int expiredNotificationsCount() const; QDateTime lastRead() const; void setLastRead(const QDateTime &lastRead); void resetLastRead(); int unreadNotificationsCount() const; int activeJobsCount() const; int jobsPercentage() const; /** * Convert the given QModelIndex into a QPersistentModelIndex */ Q_INVOKABLE QPersistentModelIndex makePersistentModelIndex(const QModelIndex &idx) const; /** * @brief Expire a notification * * Closes the notification in response to its timeout running out. * * Call this if you have an implementation that handles the timeout itself * by having called @c stopTimeout * * @sa stopTimeout */ Q_INVOKABLE void expire(const QModelIndex &idx); /** * @brief Close a notification * * Closes the notification in response to the user explicitly closing it. * * When the model index belongs to a group, the entire group is closed. */ Q_INVOKABLE void close(const QModelIndex &idx); /** * @brief Configure a notification * * This will invoke the settings action, if available, otherwise open the * kcm_notifications KCM for configuring the respective application and event. */ Q_INVOKABLE void configure(const QModelIndex &idx); // TODO pass ctx for transient handling /** * @brief Invoke the default notification action * * Invokes the action that should be triggered when clicking * the notification bubble itself. */ Q_INVOKABLE void invokeDefaultAction(const QModelIndex &idx); /** * @brief Invoke a notification action * * Invokes the action with the given actionId on the notification. * For invoking the default action, i.e. the one that is triggered * when clicking the notification bubble, use invokeDefaultAction */ Q_INVOKABLE void invokeAction(const QModelIndex &idx, const QString &actionId); /** * @brief Start automatic timeout of notifications * * Call this if you no longer handle the timeout yourself. * * @sa stopTimeout */ Q_INVOKABLE void startTimeout(const QModelIndex &idx); Q_INVOKABLE void startTimeout(uint notificationId); /** * @brief Stop the automatic timeout of notifications * * Call this if you have an implementation that handles the timeout itself * taking into account e.g. whether the user is currently interacting with * the notification to not close it under their mouse. Call @c expire * once your custom timer has run out. * * @sa expire */ Q_INVOKABLE void stopTimeout(const QModelIndex &idx); /** * @brief Suspend a job */ Q_INVOKABLE void suspendJob(const QModelIndex &idx); /** * @brief Resume a job */ Q_INVOKABLE void resumeJob(const QModelIndex &idx); /** * @brief Kill a job */ Q_INVOKABLE void killJob(const QModelIndex &idx); /** * @brief Clear notifications * * Removes the notifications matching th ClearFlags from the model. * This can be used for e.g. a "Clear History" action. */ Q_INVOKABLE void clear(ClearFlags flags); /** * Returns a model index pointing to the group of a notification. */ Q_INVOKABLE QModelIndex groupIndex(const QModelIndex &idx) const; Q_INVOKABLE void collapseAllGroups(); QVariant data(const QModelIndex &index, int role) const override; bool setData(const QModelIndex &index, const QVariant &value, int role) override; int rowCount(const QModelIndex &parent = QModelIndex()) const override; QHash roleNames() const override; bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; bool lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const override; signals: void limitChanged(); void showExpiredChanged(); void showDismissedChanged(); void blacklistedDesktopEntriesChanged(); void blacklistedNotifyRcNamesChanged(); void whitelistedDesktopEntriesChanged(); void whitelistedNotifyRcNamesChanged(); void showNotificationsChanged(); void showJobsChanged(); void urgenciesChanged(); void sortModeChanged(); void groupModeChanged(); void groupLimitChanged(); void expandUnreadChanged(); void countChanged(); void activeNotificationsCountChanged(); void expiredNotificationsCountChanged(); void lastReadChanged(); void unreadNotificationsCountChanged(); void activeJobsCountChanged(); void jobsPercentageChanged(); protected: void classBegin() override; void componentComplete() override; private: class Private; QScopedPointer d; }; } // namespace NotificationManager Q_DECLARE_OPERATORS_FOR_FLAGS(NotificationManager::Notifications::Urgencies) diff --git a/libnotificationmanager/notificationsmodel.cpp b/libnotificationmanager/notificationsmodel.cpp index 311b30c8b..2dc4576ba 100644 --- a/libnotificationmanager/notificationsmodel.cpp +++ b/libnotificationmanager/notificationsmodel.cpp @@ -1,486 +1,507 @@ /* * Copyright 2018-2019 Kai Uwe Broulik * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) version 3, or any * later version accepted by the membership of KDE e.V. (or its * successor approved by the membership of KDE e.V.), which shall * act as a proxy defined in Section 6 of version 3 of the license. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . */ #include "notificationsmodel.h" #include "debug.h" #include "server.h" #include "notifications.h" #include "notification.h" #include "notification_p.h" #include #include #include #include #include #include static const int s_notificationsLimit = 1000; using namespace NotificationManager; class Q_DECL_HIDDEN NotificationsModel::Private { public: explicit Private(NotificationsModel *q); ~Private(); void onNotificationAdded(const Notification ¬ification); void onNotificationReplaced(uint replacedId, const Notification ¬ification); void onNotificationRemoved(uint notificationId, Server::CloseReason reason); void setupNotificationTimeout(const Notification ¬ification); int rowOfNotification(uint id) const; NotificationsModel *q; QVector notifications; // Fallback timeout to ensure all notifications expire eventually // otherwise when it isn't shown to the user and doesn't expire // an app might wait indefinitely for the notification to do so QHash notificationTimeouts; QDateTime lastRead; }; NotificationsModel::Private::Private(NotificationsModel *q) : q(q) , lastRead(QDateTime::currentDateTimeUtc()) { } NotificationsModel::Private::~Private() { qDeleteAll(notificationTimeouts); notificationTimeouts.clear(); } void NotificationsModel::Private::onNotificationAdded(const Notification ¬ification) { // Once we reach a certain insane number of notifications discard some old ones // as we keep pixmaps around etc if (notifications.count() >= s_notificationsLimit) { const int cleanupCount = s_notificationsLimit / 2; qCDebug(NOTIFICATIONMANAGER) << "Reached the notification limit of" << s_notificationsLimit << ", discarding the oldest" << cleanupCount << "notifications"; q->beginRemoveRows(QModelIndex(), 0, cleanupCount - 1); for (int i = 0 ; i < cleanupCount; ++i) { notifications.removeAt(0); // TODO close gracefully? } q->endRemoveRows(); } setupNotificationTimeout(notification); q->beginInsertRows(QModelIndex(), notifications.count(), notifications.count()); notifications.append(std::move(notification)); q->endInsertRows(); } void NotificationsModel::Private::onNotificationReplaced(uint replacedId, const Notification ¬ification) { const int row = rowOfNotification(replacedId); if (row == -1) { qCWarning(NOTIFICATIONMANAGER) << "Trying to replace notification with id" << replacedId << "which doesn't exist, creating a new one. This is an application bug!"; onNotificationAdded(notification); return; } setupNotificationTimeout(notification); notifications[row] = notification; const QModelIndex idx = q->index(row, 0); emit q->dataChanged(idx, idx); } void NotificationsModel::Private::onNotificationRemoved(uint removedId, Server::CloseReason reason) { const int row = rowOfNotification(removedId); if (row == -1) { return; } q->stopTimeout(removedId); // When a notification expired, keep it around in the history and mark it as such if (reason == Server::CloseReason::Expired) { const QModelIndex idx = q->index(row, 0); Notification ¬ification = notifications[row]; notification.setExpired(true); // Since the notification is "closed" it cannot have any actions // unless it is "resident" which we don't support notification.setActions(QStringList()); emit q->dataChanged(idx, idx, { Notifications::ExpiredRole, // TODO only emit those if actually changed? Notifications::ActionNamesRole, Notifications::ActionLabelsRole, Notifications::HasDefaultActionRole, Notifications::DefaultActionLabelRole, Notifications::ConfigurableRole }); return; } // Otherwise if explicitly closed by either user or app, remove it q->beginRemoveRows(QModelIndex(), row, row); notifications.removeAt(row); q->endRemoveRows(); } void NotificationsModel::Private::setupNotificationTimeout(const Notification ¬ification) { if (notification.timeout() == 0) { // In case it got replaced by a persistent notification q->stopTimeout(notification.id()); return; } QTimer *timer = notificationTimeouts.value(notification.id()); if (!timer) { timer = new QTimer(); timer->setSingleShot(true); connect(timer, &QTimer::timeout, q, [this, timer] { const uint id = timer->property("notificationId").toUInt(); q->expire(id); }); notificationTimeouts.insert(notification.id(), timer); } timer->stop(); timer->setProperty("notificationId", notification.id()); timer->setInterval(60000 /*1min*/ + (notification.timeout() == -1 ? 120000 /*2min, max configurable default timeout*/ : notification.timeout())); timer->start(); } int NotificationsModel::Private::rowOfNotification(uint id) const { auto it = std::find_if(notifications.constBegin(), notifications.constEnd(), [id](const Notification &item) { return item.id() == id; }); if (it == notifications.constEnd()) { return -1; } return std::distance(notifications.constBegin(), it); } NotificationsModel::NotificationsModel() : QAbstractListModel(nullptr) , d(new Private(this)) { connect(&Server::self(), &Server::notificationAdded, this, [this](const Notification ¬ification) { d->onNotificationAdded(notification); }); connect(&Server::self(), &Server::notificationReplaced, this, [this](uint replacedId, const Notification ¬ification) { d->onNotificationReplaced(replacedId, notification); }); connect(&Server::self(), &Server::notificationRemoved, this, [this](uint removedId, Server::CloseReason reason) { d->onNotificationRemoved(removedId, reason); }); connect(&Server::self(), &Server::serviceOwnershipLost, this, [this] { // Expire all notifications as we're defunct now const auto notifications = d->notifications; for (const Notification ¬ification : notifications) { if (!notification.expired()) { d->onNotificationRemoved(notification.id(), Server::CloseReason::Expired); } } }); Server::self().init(); } NotificationsModel::~NotificationsModel() = default; NotificationsModel::Ptr NotificationsModel::createNotificationsModel() { static QWeakPointer s_instance; if (!s_instance) { QSharedPointer ptr(new NotificationsModel()); s_instance = ptr.toWeakRef(); return ptr; } return s_instance.toStrongRef(); } QDateTime NotificationsModel::lastRead() const { return d->lastRead; } void NotificationsModel::setLastRead(const QDateTime &lastRead) { if (d->lastRead != lastRead) { d->lastRead = lastRead; emit lastReadChanged(); } } QVariant NotificationsModel::data(const QModelIndex &index, int role) const { if (!checkIndex(index)) { return QVariant(); } const Notification ¬ification = d->notifications.at(index.row()); switch (role) { case Notifications::IdRole: return notification.id(); case Notifications::TypeRole: return Notifications::NotificationType; case Notifications::CreatedRole: if (notification.created().isValid()) { return notification.created(); } break; case Notifications::UpdatedRole: if (notification.updated().isValid()) { return notification.updated(); } break; case Notifications::SummaryRole: return notification.summary(); case Notifications::BodyRole: return notification.body(); case Notifications::IconNameRole: if (notification.image().isNull()) { return notification.icon(); } break; case Notifications::ImageRole: if (!notification.image().isNull()) { return notification.image(); } break; case Notifications::DesktopEntryRole: return notification.desktopEntry(); case Notifications::NotifyRcNameRole: return notification.notifyRcName(); case Notifications::ApplicationNameRole: return notification.applicationName(); case Notifications::ApplicationIconNameRole: return notification.applicationIconName(); case Notifications::OriginNameRole: return notification.originName(); case Notifications::ActionNamesRole: return notification.actionNames(); case Notifications::ActionLabelsRole: return notification.actionLabels(); case Notifications::HasDefaultActionRole: return notification.hasDefaultAction(); case Notifications::DefaultActionLabelRole: return notification.defaultActionLabel(); case Notifications::UrlsRole: return QVariant::fromValue(notification.urls()); case Notifications::UrgencyRole: return static_cast(notification.urgency()); case Notifications::TimeoutRole: return notification.timeout(); case Notifications::ClosableRole: return true; case Notifications::ConfigurableRole: return notification.configurable(); case Notifications::ConfigureActionLabelRole: return notification.configureActionLabel(); case Notifications::ExpiredRole: return notification.expired(); + case Notifications::ReadRole: return notification.read(); } return QVariant(); } +bool NotificationsModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + if (!checkIndex(index)) { + return false; + } + + Notification ¬ification = d->notifications[index.row()]; + + switch (role) { + case Notifications::ReadRole: + if (value.toBool() != notification.read()) { + notification.setRead(value.toBool()); + return true; + } + break; + } + + return false; +} + int NotificationsModel::rowCount(const QModelIndex &parent) const { if (parent.isValid()) { return 0; } return d->notifications.count(); } void NotificationsModel::expire(uint notificationId) { if (d->rowOfNotification(notificationId) > -1) { Server::self().closeNotification(notificationId, Server::CloseReason::Expired); } } void NotificationsModel::close(uint notificationId) { if (d->rowOfNotification(notificationId) > -1) { Server::self().closeNotification(notificationId, Server::CloseReason::DismissedByUser); } } void NotificationsModel::configure(uint notificationId) { const int row = d->rowOfNotification(notificationId); if (row == -1) { return; } const Notification ¬ification = d->notifications.at(row); if (notification.d->hasConfigureAction) { Server::self().invokeAction(notificationId, QStringLiteral("settings")); // FIXME make a static Notification::configureActionName() or something return; } if (!notification.desktopEntry().isEmpty() || !notification.notifyRcName().isEmpty()) { configure(notification.desktopEntry(), notification.notifyRcName(), notification.eventId()); return; } qCWarning(NOTIFICATIONMANAGER) << "Trying to configure notification" << notificationId << "which isn't configurable"; } void NotificationsModel::configure(const QString &desktopEntry, const QString ¬ifyRcName, const QString &eventId) { // TODO would be nice to just have a signal but since NotificationsModel is shared, // if we connect to this from Notifications you would get a signal in every instance // and potentialy open the config dialog multiple times. QStringList args; if (!desktopEntry.isEmpty()) { args.append(QStringLiteral("--desktop-entry")); args.append(desktopEntry); } if (!notifyRcName.isEmpty()) { args.append(QStringLiteral("--notifyrc")); args.append(notifyRcName); } if (!eventId.isEmpty()) { args.append(QStringLiteral("--event-id")); args.append(eventId); } QProcess::startDetached(QStringLiteral("kcmshell5"), { QStringLiteral("notifications"), QStringLiteral("--args"), KShell::joinArgs(args) }); } void NotificationsModel::invokeDefaultAction(uint notificationId) { const int row = d->rowOfNotification(notificationId); if (row == -1) { return; } const Notification ¬ification = d->notifications.at(row); if (!notification.hasDefaultAction()) { qCWarning(NOTIFICATIONMANAGER) << "Trying to invoke default action on notification" << notificationId << "which doesn't have one"; return; } Server::self().invokeAction(notificationId, QStringLiteral("default")); // FIXME make a static Notification::defaultActionName() or something } void NotificationsModel::invokeAction(uint notificationId, const QString &actionName) { const int row = d->rowOfNotification(notificationId); if (row == -1) { return; } const Notification ¬ification = d->notifications.at(row); if (!notification.actionNames().contains(actionName)) { qCWarning(NOTIFICATIONMANAGER) << "Trying to invoke action" << actionName << "on notification" << notificationId << "which it doesn't have"; return; } Server::self().invokeAction(notificationId, actionName); } void NotificationsModel::startTimeout(uint notificationId) { const int row = d->rowOfNotification(notificationId); if (row == -1) { return; } const Notification ¬ification = d->notifications.at(row); if (!notification.timeout() || notification.expired()) { return; } d->setupNotificationTimeout(notification); } void NotificationsModel::stopTimeout(uint notificationId) { delete d->notificationTimeouts.take(notificationId); } void NotificationsModel::clear(Notifications::ClearFlags flags) { if (d->notifications.isEmpty()) { return; } // Tries to remove a contiguous group if possible as the likely case is // you have n unread notifications at the end of the list, we don't want to // remove and signal each item individually QVector> clearQueue; QPair clearRange{-1, -1}; for (int i = d->notifications.count() - 1; i >= 0; --i) { const Notification ¬ification = d->notifications.at(i); bool clear = (flags.testFlag(Notifications::ClearExpired) && notification.expired()); if (clear) { if (clearRange.second == -1) { clearRange.second = i; } clearRange.first = i; } else { if (clearRange.first != -1) { clearQueue.append(clearRange); clearRange.first = -1; clearRange.second = -1; } } } if (clearRange.first != -1) { clearQueue.append(clearRange); clearRange.first = -1; clearRange.second = -1; } for (const auto &range : clearQueue) { beginRemoveRows(QModelIndex(), range.first, range.second); for (int i = range.second; i >= range.first; --i) { d->notifications.removeAt(i); } endRemoveRows(); } } diff --git a/libnotificationmanager/notificationsmodel.h b/libnotificationmanager/notificationsmodel.h index 90dfb0ef0..ff49cea09 100644 --- a/libnotificationmanager/notificationsmodel.h +++ b/libnotificationmanager/notificationsmodel.h @@ -1,72 +1,73 @@ /* * Copyright 2018-2019 Kai Uwe Broulik * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) version 3, or any * later version accepted by the membership of KDE e.V. (or its * successor approved by the membership of KDE e.V.), which shall * act as a proxy defined in Section 6 of version 3 of the license. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . */ #pragma once #include #include #include #include "notifications.h" namespace NotificationManager { class NotificationsModel : public QAbstractListModel { Q_OBJECT public: ~NotificationsModel() override; using Ptr = QSharedPointer; static Ptr createNotificationsModel(); QDateTime lastRead() const; void setLastRead(const QDateTime &lastRead); QVariant data(const QModelIndex &index, int role) const override; + bool setData(const QModelIndex &index, const QVariant &value, int role) override; int rowCount(const QModelIndex &parent = QModelIndex()) const override; void expire(uint notificationId); void close(uint notificationId); void configure(uint notificationId); void configure(const QString &desktopEntry, const QString ¬ifyRcName, const QString &eventId); void invokeDefaultAction(uint notificationId); void invokeAction(uint notificationId, const QString &actionName); void startTimeout(uint notificationId); void stopTimeout(uint notificationId); void clear(Notifications::ClearFlags flags); signals: void lastReadChanged(); private: class Private; QScopedPointer d; NotificationsModel(); Q_DISABLE_COPY(NotificationsModel) }; } // namespace NotificationManager