diff --git a/applets/notifications/notificationapplet.cpp b/applets/notifications/notificationapplet.cpp index dd8fd2770..aa890719f 100644 --- a/applets/notifications/notificationapplet.cpp +++ b/applets/notifications/notificationapplet.cpp @@ -1,170 +1,180 @@ /* * Copyright 2018 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 . * */ #include "notificationapplet.h" #include #include #include #include #include #include #include #include #include #include +#include + +#include #include #include "filemenu.h" #include "globalshortcuts.h" #include "thumbnailer.h" NotificationApplet::NotificationApplet(QObject *parent, const QVariantList &data) : Plasma::Applet(parent, data) { static bool s_typesRegistered = false; if (!s_typesRegistered) { const char uri[] = "org.kde.plasma.private.notifications"; qmlRegisterType(uri, 2, 0, "FileMenu"); qmlRegisterType(uri, 2, 0, "GlobalShortcuts"); qmlRegisterType(uri, 2, 0, "Thumbnailer"); qmlProtectModule(uri, 2); s_typesRegistered = true; } connect(qApp, &QGuiApplication::focusWindowChanged, this, &NotificationApplet::focussedPlasmaDialogChanged); } NotificationApplet::~NotificationApplet() = default; void NotificationApplet::init() { } void NotificationApplet::configChanged() { } bool NotificationApplet::dragActive() const { return m_dragActive; } int NotificationApplet::dragPixmapSize() const { return m_dragPixmapSize; } void NotificationApplet::setDragPixmapSize(int dragPixmapSize) { if (m_dragPixmapSize != dragPixmapSize) { m_dragPixmapSize = dragPixmapSize; emit dragPixmapSizeChanged(); } } bool NotificationApplet::isDrag(int oldX, int oldY, int newX, int newY) const { return ((QPoint(oldX, oldY) - QPoint(newX, newY)).manhattanLength() >= qApp->styleHints()->startDragDistance()); } void NotificationApplet::startDrag(QQuickItem *item, const QUrl &url, const QString &iconName) { startDrag(item, url, QIcon::fromTheme(iconName).pixmap(m_dragPixmapSize, m_dragPixmapSize)); } void NotificationApplet::startDrag(QQuickItem *item, const QUrl &url, const QPixmap &pixmap) { // This allows the caller to return, making sure we don't crash if // the caller is destroyed mid-drag QMetaObject::invokeMethod(this, "doDrag", Qt::QueuedConnection, Q_ARG(QQuickItem*, item), Q_ARG(QUrl, url), Q_ARG(QPixmap, pixmap)); } void NotificationApplet::doDrag(QQuickItem *item, const QUrl &url, const QPixmap &pixmap) { if (item && item->window() && item->window()->mouseGrabberItem()) { item->window()->mouseGrabberItem()->ungrabMouse(); } QDrag *drag = new QDrag(item); QMimeData *mimeData = new QMimeData(); if (!url.isEmpty()) { mimeData->setUrls(QList() << url); } drag->setMimeData(mimeData); if (!pixmap.isNull()) { drag->setPixmap(pixmap); } m_dragActive = true; emit dragActiveChanged(); drag->exec(); m_dragActive = false; emit dragActiveChanged(); } QWindow *NotificationApplet::focussedPlasmaDialog() const { return qobject_cast(qApp->focusWindow()); } void NotificationApplet::setSelectionClipboardText(const QString &text) { // FIXME KDeclarative Clipboard item uses QClipboard::Mode for "mode" // which is an enum inaccessible from QML QGuiApplication::clipboard()->setText(text, QClipboard::Selection); } bool NotificationApplet::isPrimaryScreen(const QRect &rect) const { QScreen *screen = QGuiApplication::primaryScreen(); if (!screen) { return false; } // HACK return rect == screen->geometry(); } QString NotificationApplet::iconNameForUrl(const QUrl &url) const { QMimeType mime = QMimeDatabase().mimeTypeForUrl(url); if (mime.isDefault()) { return QString(); } return mime.iconName(); } +void NotificationApplet::forceActivateWindow(QWindow *window) +{ + if (window && window->winId()) { + KWindowSystem::forceActiveWindow(window->winId()); + } +} + K_EXPORT_PLASMA_APPLET_WITH_JSON(icon, NotificationApplet, "metadata.json") #include "notificationapplet.moc" diff --git a/applets/notifications/notificationapplet.h b/applets/notifications/notificationapplet.h index 7a78b36fc..82205d22e 100644 --- a/applets/notifications/notificationapplet.h +++ b/applets/notifications/notificationapplet.h @@ -1,75 +1,77 @@ /* * Copyright 2018 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 once #include class QQuickItem; class QString; class QRect; +class QWindow; class NotificationApplet : public Plasma::Applet { Q_OBJECT Q_PROPERTY(bool dragActive READ dragActive NOTIFY dragActiveChanged) Q_PROPERTY(int dragPixmapSize READ dragPixmapSize WRITE setDragPixmapSize NOTIFY dragPixmapSizeChanged) Q_PROPERTY(QWindow *focussedPlasmaDialog READ focussedPlasmaDialog NOTIFY focussedPlasmaDialogChanged) public: explicit NotificationApplet(QObject *parent, const QVariantList &data); ~NotificationApplet() override; void init() override; void configChanged() override; bool dragActive() const; int dragPixmapSize() const; void setDragPixmapSize(int dragPixmapSize); Q_INVOKABLE bool isDrag(int oldX, int oldY, int newX, int newY) const; Q_INVOKABLE void startDrag(QQuickItem *item, const QUrl &url, const QString &iconName); Q_INVOKABLE void startDrag(QQuickItem *item, const QUrl &url, const QPixmap &pixmap); QWindow *focussedPlasmaDialog() const; Q_INVOKABLE void setSelectionClipboardText(const QString &text); Q_INVOKABLE bool isPrimaryScreen(const QRect &rect) const; Q_INVOKABLE QString iconNameForUrl(const QUrl &url) const; + Q_INVOKABLE void forceActivateWindow(QWindow *window); signals: void dragActiveChanged(); void dragPixmapSizeChanged(); void focussedPlasmaDialogChanged(); private slots: void doDrag(QQuickItem *item, const QUrl &url, const QPixmap &pixmap); private: bool m_dragActive = false; int m_dragPixmapSize = 48; // Bound to units.iconSizes.large in main.qml }; diff --git a/applets/notifications/package/contents/ui/NotificationItem.qml b/applets/notifications/package/contents/ui/NotificationItem.qml index 6d36e3d60..282689d76 100644 --- a/applets/notifications/package/contents/ui/NotificationItem.qml +++ b/applets/notifications/package/contents/ui/NotificationItem.qml @@ -1,373 +1,451 @@ /* * Copyright 2018-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 QtQuick.Window 2.2 import org.kde.plasma.core 2.0 as PlasmaCore import org.kde.plasma.components 2.0 as PlasmaComponents import org.kde.plasma.extras 2.0 as PlasmaExtras import org.kde.kquickcontrolsaddons 2.0 as KQCAddons import org.kde.notificationmanager 1.0 as NotificationManager ColumnLayout { id: notificationItem property bool hovered: false property int maximumLineCount: 0 property alias bodyCursorShape: bodyLabel.cursorShape property int notificationType property bool inGroup: false property alias applicationIconSource: notificationHeading.applicationIconSource property alias applicationName: notificationHeading.applicationName property alias originName: notificationHeading.originName property string summary property alias time: notificationHeading.time property alias configurable: notificationHeading.configurable property alias dismissable: notificationHeading.dismissable property alias dismissed: notificationHeading.dismissed property alias closable: notificationHeading.closable // This isn't an alias because TextEdit RichText adds some HTML tags to it property string body property var icon property var urls: [] property int jobState property int percentage property int jobError: 0 property bool suspendable property bool killable property QtObject jobDetails property bool showDetails property alias configureActionLabel: notificationHeading.configureActionLabel property var actionNames: [] property var actionLabels: [] + property bool hasReplyAction + property string replyActionLabel + property string replyPlaceholderText + property string replySubmitButtonText + property string replySubmitButtonIconName + property int headingLeftPadding: 0 property int headingRightPadding: 0 property int thumbnailLeftPadding: 0 property int thumbnailRightPadding: 0 property int thumbnailTopPadding: 0 property int thumbnailBottomPadding: 0 property alias timeout: notificationHeading.timeout property alias remainingTime: notificationHeading.remainingTime readonly property bool menuOpen: bodyLabel.contextMenu !== null || (thumbnailStripLoader.item && thumbnailStripLoader.item.menuOpen) || (jobLoader.item && jobLoader.item.menuOpen) + readonly property bool dragging: (thumbnailStripLoader.item && thumbnailStripLoader.item.dragging) || (jobLoader.item && jobLoader.item.dragging) + property bool replying: false signal bodyClicked(var mouse) signal closeClicked signal configureClicked signal dismissClicked signal actionInvoked(string actionName) + signal replied(string text) signal openUrl(string url) signal fileActionInvoked signal suspendJobClicked signal resumeJobClicked signal killJobClicked spacing: units.smallSpacing NotificationHeader { id: notificationHeading Layout.fillWidth: true Layout.leftMargin: notificationItem.headingLeftPadding Layout.rightMargin: notificationItem.headingRightPadding inGroup: notificationItem.inGroup notificationType: notificationItem.notificationType jobState: notificationItem.jobState jobDetails: notificationItem.jobDetails onConfigureClicked: notificationItem.configureClicked() onDismissClicked: notificationItem.dismissClicked() onCloseClicked: notificationItem.closeClicked() } RowLayout { id: defaultHeaderContainer Layout.fillWidth: true } // Notification body RowLayout { id: bodyRow Layout.fillWidth: true spacing: units.smallSpacing ColumnLayout { Layout.fillWidth: true spacing: 0 RowLayout { id: summaryRow Layout.fillWidth: true visible: summaryLabel.text !== "" PlasmaExtras.Heading { id: summaryLabel Layout.fillWidth: true Layout.preferredHeight: implicitHeight textFormat: Text.PlainText maximumLineCount: 3 wrapMode: Text.WordWrap elide: Text.ElideRight level: 4 text: { if (notificationItem.notificationType === NotificationManager.Notifications.JobType) { if (notificationItem.jobState === NotificationManager.Notifications.JobStateSuspended) { if (notificationItem.summary) { return i18ndc("plasma_applet_org.kde.plasma.notifications", "Job name, e.g. Copying is paused", "%1 (Paused)", notificationItem.summary); } } else if (notificationItem.jobState === NotificationManager.Notifications.JobStateStopped) { if (notificationItem.jobError) { if (notificationItem.summary) { return i18ndc("plasma_applet_org.kde.plasma.notifications", "Job name, e.g. Copying has failed", "%1 (Failed)", notificationItem.summary); } else { return i18nd("plasma_applet_org.kde.plasma.notifications", "Job Failed"); } } else { if (notificationItem.summary) { return i18ndc("plasma_applet_org.kde.plasma.notifications", "Job name, e.g. Copying has finished", "%1 (Finished)", notificationItem.summary); } else { return i18nd("plasma_applet_org.kde.plasma.notifications", "Job Finished"); } } } } // some apps use their app name as summary, avoid showing the same text twice // try very hard to match the two if (notificationItem.summary && notificationItem.summary.toLocaleLowerCase().trim() != notificationItem.applicationName.toLocaleLowerCase().trim()) { return notificationItem.summary; } return ""; } visible: text !== "" } // inGroup headerItem is reparented here } RowLayout { id: bodyTextRow Layout.fillWidth: true spacing: units.smallSpacing SelectableLabel { id: bodyLabel // FIXME how to assign this via State? target: bodyLabel.Layout doesn't work and just assigning the property doesn't either Layout.alignment: notificationItem.inGroup ? Qt.AlignTop : Qt.AlignVCenter Layout.fillWidth: true Layout.maximumHeight: notificationItem.maximumLineCount > 0 ? (theme.mSize(font).height * notificationItem.maximumLineCount) : -1 text: notificationItem.body // Cannot do text !== "" because RichText adds some HTML tags even when empty visible: notificationItem.body !== "" onClicked: notificationItem.bodyClicked(mouse) onLinkActivated: Qt.openUrlExternally(link) } // inGroup iconContainer is reparented here } } Item { id: iconContainer Layout.preferredWidth: units.iconSizes.large Layout.preferredHeight: units.iconSizes.large visible: iconItem.active || imageItem.active PlasmaCore.IconItem { id: iconItem // don't show two identical icons readonly property bool active: valid && source != notificationItem.applicationIconSource anchors.fill: parent usesPlasmaTheme: false smooth: true source: { var icon = notificationItem.icon; if (typeof icon !== "string") { // displayed by QImageItem below return ""; } // don't show a generic "info" icon since this is a notification already if (icon === "dialog-information") { return ""; } return icon; } visible: active } KQCAddons.QImageItem { id: imageItem readonly property bool active: !null && nativeWidth > 0 anchors.fill: parent smooth: true fillMode: KQCAddons.QImageItem.PreserveAspectFit visible: active image: typeof notificationItem.icon === "object" ? notificationItem.icon : undefined } // JobItem reparents a file icon here for finished jobs with one total file } } // Job progress reporting Loader { id: jobLoader Layout.fillWidth: true active: notificationItem.notificationType === NotificationManager.Notifications.JobType visible: active sourceComponent: JobItem { iconContainerItem: iconContainer jobState: notificationItem.jobState jobError: notificationItem.jobError percentage: notificationItem.percentage suspendable: notificationItem.suspendable killable: notificationItem.killable jobDetails: notificationItem.jobDetails showDetails: notificationItem.showDetails onSuspendJobClicked: notificationItem.suspendJobClicked() onResumeJobClicked: notificationItem.resumeJobClicked() onKillJobClicked: notificationItem.killJobClicked() onOpenUrl: notificationItem.openUrl(url) onFileActionInvoked: notificationItem.fileActionInvoked() hovered: notificationItem.hovered } } - RowLayout { + Item { + id: actionContainer Layout.fillWidth: true + Layout.preferredHeight: Math.max(actionFlow.implicitHeight, replyLoader.height) visible: actionRepeater.count > 0 + states: [ + State { + when: notificationItem.replying + PropertyChanges { + target: actionFlow + enabled: false + opacity: 0 + } + PropertyChanges { + target: replyLoader + active: true + visible: true + opacity: 1 + x: 0 + } + } + ] + + transitions: [ + Transition { + to: "*" // any state + NumberAnimation { + targets: [actionFlow, replyLoader] + properties: "opacity,scale,x" + duration: units.longDuration + easing.type: Easing.InOutQuad + } + } + ] + // Notification actions Flow { // it's a Flow so it can wrap if too long - Layout.fillWidth: true + id: actionFlow + width: parent.width spacing: units.smallSpacing layoutDirection: Qt.RightToLeft Repeater { id: actionRepeater model: { var buttons = []; // HACK We want the actions to be right-aligned but Flow also reverses var actionNames = (notificationItem.actionNames || []).reverse(); var actionLabels = (notificationItem.actionLabels || []).reverse(); for (var i = 0; i < actionNames.length; ++i) { buttons.push({ actionName: actionNames[i], label: actionLabels[i] }); } + + if (notificationItem.hasReplyAction) { + buttons.unshift({ + actionName: "inline-reply", + label: notificationItem.replyActionLabel || i18nc("Reply to message", "Reply") + }); + } + return buttons; } PlasmaComponents.ToolButton { flat: false // why does it spit "cannot assign undefined to string" when a notification becomes expired? text: modelData.label || "" Layout.preferredWidth: minimumWidth - onClicked: notificationItem.actionInvoked(modelData.actionName) + + onClicked: { + if (modelData.actionName === "inline-reply") { + notificationItem.replying = true; + + plasmoid.nativeInterface.forceActivateWindow(notificationItem.Window.window); + replyLoader.item.activate(); + return; + } + + notificationItem.actionInvoked(modelData.actionName); + } } } } + + // inline reply field + Loader { + id: replyLoader + width: parent.width + height: active ? item.implicitHeight : 0 + active: false + visible: false + opacity: 0 + x: parent.width + sourceComponent: NotificationReplyField { + placeholderText: notificationItem.replyPlaceholderText + buttonIconName: notificationItem.replySubmitButtonIconName + buttonText: notificationItem.replySubmitButtonText + onReplied: notificationItem.replied(text) + } + } } // thumbnails Loader { id: thumbnailStripLoader Layout.leftMargin: notificationItem.thumbnailLeftPadding Layout.rightMargin: notificationItem.thumbnailRightPadding // no change in Layout.topMargin to keep spacing to notification text consistent Layout.topMargin: 0 Layout.bottomMargin: notificationItem.thumbnailBottomPadding Layout.fillWidth: true active: notificationItem.urls.length > 0 visible: active sourceComponent: ThumbnailStrip { leftPadding: -thumbnailStripLoader.Layout.leftMargin rightPadding: -thumbnailStripLoader.Layout.rightMargin topPadding: -notificationItem.thumbnailTopPadding bottomPadding: -thumbnailStripLoader.Layout.bottomMargin urls: notificationItem.urls onOpenUrl: notificationItem.openUrl(url) onFileActionInvoked: notificationItem.fileActionInvoked() } } states: [ State { when: notificationItem.inGroup PropertyChanges { target: notificationHeading parent: summaryRow } PropertyChanges { target: summaryRow visible: true } PropertyChanges { target: summaryLabel visible: true } /*PropertyChanges { target: bodyLabel.Label alignment: Qt.AlignTop }*/ PropertyChanges { target: iconContainer parent: bodyTextRow } } ] } diff --git a/applets/notifications/package/contents/ui/NotificationPopup.qml b/applets/notifications/package/contents/ui/NotificationPopup.qml index 114bca963..0ba7ef31a 100644 --- a/applets/notifications/package/contents/ui/NotificationPopup.qml +++ b/applets/notifications/package/contents/ui/NotificationPopup.qml @@ -1,194 +1,201 @@ /* * 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 + property alias hasReplyAction: notificationItem.hasReplyAction + property alias replyActionLabel: notificationItem.replyActionLabel + property alias replyPlaceholderText: notificationItem.replyPlaceholderText + property alias replySubmitButtonText: notificationItem.replySubmitButtonText + property alias replySubmitButtonIconName: notificationItem.replySubmitButtonIconName + signal configureClicked signal dismissClicked signal closeClicked signal defaultActionInvoked signal actionInvoked(string actionName) + signal replied(string text) 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 + flags: notificationItem.replying ? 0 : Qt.WindowDoesNotAcceptFocus visible: false // When notification is updated, restart hide timer onTimeChanged: { if (timer.running) { timer.restart(); } } mainItem: Item { width: notificationPopup.popupWidth height: notificationItem.implicitHeight + notificationItem.y DraggableDelegate { id: area width: parent.width height: parent.height hoverEnabled: true draggable: notificationItem.notificationType != NotificationManager.Notifications.JobType onDismissRequested: popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) cursorShape: hasDefaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor acceptedButtons: hasDefaultAction || draggable ? Qt.LeftButton : Qt.NoButton onClicked: { if (hasDefaultAction) { 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 + && !notificationItem.dragging && !notificationItem.menuOpen && !notificationItem.replying onTriggered: { if (notificationPopup.dismissTimeout) { notificationPopup.dismissClicked(); } else { notificationPopup.expired(); } } } NumberAnimation { target: notificationItem property: "remainingTime" from: timer.interval to: 0 duration: timer.interval running: timer.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 timeout: timer.running ? timer.interval : 0 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) + onReplied: notificationPopup.replied(text) onOpenUrl: notificationPopup.openUrl(url) onFileActionInvoked: notificationPopup.fileActionInvoked() onSuspendJobClicked: notificationPopup.suspendJobClicked() onResumeJobClicked: notificationPopup.resumeJobClicked() onKillJobClicked: notificationPopup.killJobClicked() } } } } diff --git a/applets/notifications/package/contents/ui/NotificationReplyField.qml b/applets/notifications/package/contents/ui/NotificationReplyField.qml new file mode 100644 index 000000000..ff342bd2f --- /dev/null +++ b/applets/notifications/package/contents/ui/NotificationReplyField.qml @@ -0,0 +1,63 @@ +/* + * 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.components 2.0 as PlasmaComponents + +RowLayout { + id: replyRow + + signal replied(string text) + + property alias text: replyTextField.text + property string placeholderText + property string buttonIconName + property string buttonText + + spacing: units.smallSpacing + + function activate() { + replyTextField.forceActiveFocus(); + } + + PlasmaComponents.TextField { + id: replyTextField + Layout.fillWidth: true + placeholderText: replyRow.replyPlaceholderText + || i18ndc("plasma_applet_org.kde.plasma.notifications", "Text field placeholder", "Type a reply...") + onAccepted: { + if (replyButton.enabled) { + replyRow.replied(text); + } + } + } + + PlasmaComponents.Button { + id: replyButton + Layout.preferredWidth: minimumWidth + iconName: replyRow.buttonIconName || "document-send" + text: replyRow.buttonText + || i18ndc("plasma_applet_org.kde.plasma.notifications", "@action:button", "Send") + enabled: replyTextField.length > 0 + onClicked: replyRow.replied(replyTextField.text) + } +} diff --git a/applets/notifications/package/contents/ui/global/Globals.qml b/applets/notifications/package/contents/ui/global/Globals.qml index 51c955c8e..d6a8d7b7b 100644 --- a/applets/notifications/package/contents/ui/global/Globals.qml +++ b/applets/notifications/package/contents/ui/global/Globals.qml @@ -1,515 +1,525 @@ /* * 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 org.kde.plasma.private.notifications 2.0 as Notifications 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; } } readonly property QtObject focusDialog: plasmoid.nativeInterface.focussedPlasmaDialog onFocusDialogChanged: positionPopups() // 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; if (!NotificationManager.Server.valid) { return 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 revokeInhibitions() { notificationSettings.notificationsInhibitedUntil = undefined; notificationSettings.revokeApplicationInhibitions(); // overrules current mirrored screen setup, updates again when screen configuration changes notificationSettings.screensMirrored = false; notificationSettings.save(); } function rectIntersect(rect1 /*dialog*/, rect2 /*popup*/) { return rect1.x < rect2.x + rect2.width && rect2.x < rect1.x + rect1.width && rect1.y < rect2.y + rect2.height && rect2.y < rect1.y + rect1.height; } 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) { let 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 (popupLocation & Qt.AlignTop) { // We want to calculate the new position based on its original target position to avoid positioning it and then // positioning it again, hence the temporary Qt.rect with explicit "y" and not just the popup as a whole if (focusDialog && focusDialog.visible && focusDialog !== popup && rectIntersect(focusDialog, Qt.rect(popup.x, y, popup.width, popup.height))) { y = focusDialog.y + focusDialog.height + popupEdgeDistance; } popup.y = y; // If the popup isn't ready yet, ignore its occupied space for now. // We'll reposition everything in onHeightChanged eventually. y += popup.height + (popup.height > 0 ? popupSpacing : 0); } else { y -= popup.height; if (focusDialog && focusDialog.visible && focusDialog !== popup && rectIntersect(focusDialog, Qt.rect(popup.x, y, popup.width, popup.height))) { y = focusDialog.y - popup.height - popupEdgeDistance; } popup.y = y; if (popup.height > 0) { y -= popupSpacing; } } // 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))); } } 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 + hasReplyAction: model.hasReplyAction || false + replyActionLabel: model.replyActionLabel || "" + replyPlaceholderText: model.replyPlaceholderText || "" + replySubmitButtonText: model.replySubmitButtonText || "" + replySubmitButtonIconName: model.replySubmitButtonIconName || "" + 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)) } + onReplied: { + popupNotificationsModel.reply(popupNotificationsModel.index(index, 0), text); + 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() } // Keeps the Inhibited property on DBus in sync with our inhibition handling property Binding serverInhibitedBinding: Binding { target: NotificationManager.Server property: "inhibited" value: globals.inhibited } property Notifications.GlobalShortcuts shortcuts: Notifications.GlobalShortcuts { onToggleDoNotDisturbTriggered: { var oldInhibited = globals.inhibited; if (oldInhibited) { globals.revokeInhibitions(); } else { // Effectively "in a year" is "until turned off" var d = new Date(); d.setFullYear(d.getFullYear() + 1); notificationSettings.notificationsInhibitedUntil = d; notificationSettings.save(); } checkInhibition(); if (globals.inhibited !== oldInhibited) { showDoNotDisturbOsd(globals.inhibited); } } } } diff --git a/libnotificationmanager/dbus/org.freedesktop.Notifications.xml b/libnotificationmanager/dbus/org.freedesktop.Notifications.xml index fc0825df0..91718d6b6 100644 --- a/libnotificationmanager/dbus/org.freedesktop.Notifications.xml +++ b/libnotificationmanager/dbus/org.freedesktop.Notifications.xml @@ -1,60 +1,65 @@ + + + + + diff --git a/libnotificationmanager/notification.cpp b/libnotificationmanager/notification.cpp index 8e89a331c..ad7042c60 100644 --- a/libnotificationmanager/notification.cpp +++ b/libnotificationmanager/notification.cpp @@ -1,709 +1,745 @@ /* * 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 "debug.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; } } userActionFeedback = hints.value(QStringLiteral("x-kde-user-action-feedback")).toBool(); if (userActionFeedback) { // A confirmation of an explicit user interaction is assumed to have been seen by the user. read = true; } urls = QUrl::fromStringList(hints.value(QStringLiteral("x-kde-urls")).toStringList()); + replyPlaceholderText = hints.value(QStringLiteral("x-kde-reply-placeholder-text")).toString(); + replySubmitButtonText = hints.value(QStringLiteral("x-kde-reply-submit-button-text")).toString(); + replySubmitButtonIconName = hints.value(QStringLiteral("x-kde-reply-submit-button-icon-name")).toString(); + // 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; + d->hasReplyAction = 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; } + if (!d->hasReplyAction && name == QLatin1String("inline-reply")) { + d->hasReplyAction = true; + d->replyActionLabel = 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; } bool Notification::userActionFeedback() const { return d->userActionFeedback; } 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::hasReplyAction() const +{ + return d->hasReplyAction; +} + +QString Notification::replyActionLabel() const +{ + return d->replyActionLabel; +} + +QString Notification::replyPlaceholderText() const +{ + return d->replyPlaceholderText; +} + +QString Notification::replySubmitButtonText() const +{ + return d->replySubmitButtonText; +} + +QString Notification::replySubmitButtonIconName() const +{ + return d->replySubmitButtonIconName; +} + 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 51ad4a210..36da3afc1 100644 --- a/libnotificationmanager/notification.h +++ b/libnotificationmanager/notification.h @@ -1,129 +1,135 @@ /* * 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); bool userActionFeedback() const; int timeout() const; void setTimeout(int timeout); bool configurable() const; QString configureActionLabel() const; + bool hasReplyAction() const; + QString replyActionLabel() const; + QString replyPlaceholderText() const; + QString replySubmitButtonText() const; + QString replySubmitButtonIconName() 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 5596884aa..7d812d39f 100644 --- a/libnotificationmanager/notification_p.h +++ b/libnotificationmanager/notification_p.h @@ -1,101 +1,107 @@ /* * 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; + bool hasReplyAction = false; + QString replyActionLabel; + QString replyPlaceholderText; + QString replySubmitButtonText; + QString replySubmitButtonIconName; + QList urls; bool userActionFeedback = false; Notifications::Urgency urgency = Notifications::NormalUrgency; int timeout = -1; bool expired = false; bool dismissed = false; }; } // namespace NotificationManager diff --git a/libnotificationmanager/notifications.cpp b/libnotificationmanager/notifications.cpp index 7b16de7a7..dd7b5a179 100644 --- a/libnotificationmanager/notifications.cpp +++ b/libnotificationmanager/notifications.cpp @@ -1,850 +1,857 @@ /* * 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; } 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(); } 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::reply(const QModelIndex &idx, const QString &text) +{ + if (d->notificationsModel) { + d->notificationsModel->reply(Private::notificationId(idx), text); + } +} + 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 61f2ecd26..7e46fb5a2 100644 --- a/libnotificationmanager/notifications.h +++ b/libnotificationmanager/notifications.h @@ -1,528 +1,542 @@ /* * 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. 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 UserActionFeedbackRole, ///< Whether this notification is a response/confirmation to an explicit user action. @since 5.18 + + HasReplyActionRole, ///< Whether the action has a reply action. @since 5.18 + ReplyActionLabelRole, ///< The user-visible label for the reply action. @since 5.18 + ReplyPlaceholderTextRole, ///< A custom placeholder text for the reply action, e.g. "Reply to Max...". @since 5.18 + ReplySubmitButtonTextRole, ///< A custom text for the reply submit button, e.g. "Submit Comment". @since 5.18 + ReplySubmitButtonIconNameRole, ///< A custom icon name for the reply submit button. @since 5.18 }; 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 Reply to a notification + * + * Replies to the given notification with the given text. + * @since 5.18 + */ + Q_INVOKABLE void reply(const QModelIndex &idx, const QString &text); + /** * @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 2eb034c0a..3ae1a0835 100644 --- a/libnotificationmanager/notificationsmodel.cpp +++ b/libnotificationmanager/notificationsmodel.cpp @@ -1,508 +1,530 @@ /* * 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::UserActionFeedbackRole: return notification.userActionFeedback(); 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(); + + case Notifications::HasReplyActionRole: return notification.hasReplyAction(); + case Notifications::ReplyActionLabelRole: return notification.replyActionLabel(); + case Notifications::ReplyPlaceholderTextRole: return notification.replyPlaceholderText(); + case Notifications::ReplySubmitButtonTextRole: return notification.replySubmitButtonText(); + case Notifications::ReplySubmitButtonIconNameRole: return notification.replySubmitButtonIconName(); } 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::reply(uint notificationId, const QString &text) +{ + const int row = d->rowOfNotification(notificationId); + if (row == -1) { + return; + } + + const Notification ¬ification = d->notifications.at(row); + if (!notification.hasReplyAction()) { + qCWarning(NOTIFICATIONMANAGER) << "Trying to reply to a notification which doesn't have a reply action"; + return; + } + + Server::self().reply(notificationId, text); +} + 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 ff49cea09..edef90ae2 100644 --- a/libnotificationmanager/notificationsmodel.h +++ b/libnotificationmanager/notificationsmodel.h @@ -1,73 +1,74 @@ /* * 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 reply(uint notificationId, const QString &text); 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 diff --git a/libnotificationmanager/server.cpp b/libnotificationmanager/server.cpp index 6c8e56fb5..cce480712 100644 --- a/libnotificationmanager/server.cpp +++ b/libnotificationmanager/server.cpp @@ -1,128 +1,133 @@ /* * Copyright 2018 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 "server.h" #include "server_p.h" #include "notification.h" #include "notification_p.h" #include "debug.h" #include using namespace NotificationManager; Server::Server(QObject *parent) : QObject(parent) , d(new ServerPrivate(this)) { connect(d.data(), &ServerPrivate::validChanged, this, &Server::validChanged); connect(d.data(), &ServerPrivate::inhibitedChanged, this, [this] { emit inhibitedChanged(inhibited()); }); connect(d.data(), &ServerPrivate::externalInhibitedChanged, this, [this] { emit inhibitedByApplicationChanged(inhibitedByApplication()); }); connect(d.data(), &ServerPrivate::externalInhibitionsChanged, this, &Server::inhibitionApplicationsChanged); connect(d.data(), &ServerPrivate::serviceOwnershipLost, this, &Server::serviceOwnershipLost); } Server::~Server() = default; Server &Server::self() { static Server s_self; return s_self; } bool Server::init() { return d->init(); } bool Server::isValid() const { return d->m_valid; } ServerInfo *Server::currentOwner() const { return d->currentOwner(); } void Server::closeNotification(uint notificationId, CloseReason reason) { emit notificationRemoved(notificationId, reason); emit d->NotificationClosed(notificationId, static_cast(reason)); // tell on DBus } void Server::invokeAction(uint notificationId, const QString &actionName) { emit d->ActionInvoked(notificationId, actionName); } +void Server::reply(uint notificationId, const QString &text) +{ + emit d->NotificationReplied(notificationId, text); +} + uint Server::add(const Notification ¬ification) { return d->add(notification); } bool Server::inhibited() const { return d->inhibited(); } void Server::setInhibited(bool inhibited) { d->setInhibited(inhibited); } bool Server::inhibitedByApplication() const { return d->externalInhibited(); } QStringList Server::inhibitionApplications() const { QStringList applications; const auto inhibitions = d->externalInhibitions(); applications.reserve(inhibitions.count()); for (const auto &inhibition : inhibitions) { applications.append(!inhibition.applicationName.isEmpty() ? inhibition.applicationName : inhibition.desktopEntry); } return applications; } QStringList Server::inhibitionReasons() const { QStringList reasons; const auto inhibitions = d->externalInhibitions(); reasons.reserve(inhibitions.count()); for (const auto &inhibition : inhibitions) { reasons.append(inhibition.reason); } return reasons; } void Server::clearInhibitions() { d->clearExternalInhibitions(); } diff --git a/libnotificationmanager/server.h b/libnotificationmanager/server.h index beb4436ec..7b8e40a3f 100644 --- a/libnotificationmanager/server.h +++ b/libnotificationmanager/server.h @@ -1,223 +1,232 @@ /* * Copyright 2018 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 "notificationmanager_export.h" namespace NotificationManager { class Notification; class ServerInfo; class ServerPrivate; /** * @short A notification DBus server * * @author Kai Uwe Broulik **/ class NOTIFICATIONMANAGER_EXPORT Server : public QObject { Q_OBJECT /** * Whether the notification service could be registered. * Call @c init() to register. */ Q_PROPERTY(bool valid READ isValid NOTIFY validChanged) /** * Information about the current owner of the Notification service. * * This can be used to tell the user which application is currently * owning the service in case service registration failed. * * This is never null, even if there is no notification service running. * * @since 5.18 */ Q_PROPERTY(NotificationManager::ServerInfo *currentOwner READ currentOwner CONSTANT) /** * Whether notifications are currently inhibited. * * This is what is announced to other applicatons on the bus. * * @note This does not keep track of inhibitions on its own, * you need to calculate this yourself and update the property accordingly. */ Q_PROPERTY(bool inhibited READ inhibited WRITE setInhibited NOTIFY inhibitedChanged) public: ~Server() override; /** * The reason a notification was closed */ enum class CloseReason { Expired = 1, ///< The notification timed out DismissedByUser = 2, ///< The user explicitly closed or acknowledged the notification Revoked = 3 ///< The notification was revoked by the issuing app because it is no longer relevant }; Q_ENUM(CloseReason) static Server &self(); /** * Registers the Notification Service on DBus. * * @return true if it succeeded, false otherwise. */ bool init(); /** * Whether the notification service could be registered */ bool isValid() const; /** * Information about the current owner of the Notification service. * @since 5.18 */ ServerInfo *currentOwner() const; /** * Whether notifications are currently inhibited. * @since 5.17 */ bool inhibited() const; /** * Whether notifications are currently effectively inhibited. * * @note You need to keep track of inhibitions and call this * yourself when appropriate. * @since 5.17 */ void setInhibited(bool inhibited); /** * Whether an application requested to inhibit notifications. */ bool inhibitedByApplication() const; // should we return a struct or pair or something? QStringList inhibitionApplications() const; QStringList inhibitionReasons() const; /** * Remove all inhibitions. * * @note The applications are not explicitly informed about this. */ void clearInhibitions(); /** * Sends a notification closed event * * @param id The notification ID * @param reason The reason why it was closed */ void closeNotification(uint id, CloseReason reason); /** * Sends an action invocation request * * @param id The notification ID * @param actionName The name of the action, e.g. "Action 1", or "default" */ void invokeAction(uint id, const QString &actionName); + /** + * Sends a notification reply text + * + * @param id The notification ID + * @param text The reply message text + * @since 5.18 + */ + void reply(uint id, const QString &text); + /** * Adds a notification * * @note The notification isn't actually broadcast * but just emitted locally. * * @return the ID of the notification */ uint add(const Notification ¬ification); Q_SIGNALS: /** * Emitted when the notification service validity changes, * because it sucessfully registered the service or lost * ownership of it. * @since 5.18 */ void validChanged(); /** * Emitted when a notification was added. * This is emitted regardless of any filtering rules or user settings. * @param notification The notification */ void notificationAdded(const Notification ¬ification); /** * Emitted when a notification is supposed to be updated * This is emitted regardless of any filtering rules or user settings. * @param replacedId The ID of the notification it replaces * @param notification The new notification to use instead */ void notificationReplaced(uint replacedId, const Notification ¬ification); /** * Emitted when a notification got removed (closed) * @param id The notification ID * @param reason The reason why it was closed */ void notificationRemoved(uint id, CloseReason reason); /** * Emitted when the inhibited state changed. */ void inhibitedChanged(bool inhibited); /** * Emitted when inhibitions by application have been changed. * Becomes true as soon as there is one inhibition and becomes * false again when all inhibitions have been lifted. * @since 5.17 */ void inhibitedByApplicationChanged(bool inhibited); /** * Emitted when the list of applications holding a notification * inhibition changes. * Normally you would only want to listen do @c inhibitedChanged */ void inhibitionApplicationsChanged(); /** * Emitted when the ownership of the Notification DBus Service is lost. */ void serviceOwnershipLost(); private: explicit Server(QObject *parent = nullptr); Q_DISABLE_COPY(Server) // FIXME we also need to disable move and other stuff? QScopedPointer d; }; } // namespace NotificationManager diff --git a/libnotificationmanager/server_p.cpp b/libnotificationmanager/server_p.cpp index eb01bbe19..3411d5382 100644 --- a/libnotificationmanager/server_p.cpp +++ b/libnotificationmanager/server_p.cpp @@ -1,458 +1,459 @@ /* * 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 "server_p.h" #include "debug.h" #include "notificationsadaptor.h" #include "notification.h" #include "notification_p.h" #include "server.h" #include "serverinfo.h" #include "utils_p.h" #include #include #include #include #include #include using namespace NotificationManager; ServerPrivate::ServerPrivate(QObject *parent) : QObject(parent) , m_inhibitionWatcher(new QDBusServiceWatcher(this)) { m_inhibitionWatcher->setConnection(QDBusConnection::sessionBus()); m_inhibitionWatcher->setWatchMode(QDBusServiceWatcher::WatchForUnregistration); connect(m_inhibitionWatcher, &QDBusServiceWatcher::serviceUnregistered, this, &ServerPrivate::onInhibitionServiceUnregistered); } ServerPrivate::~ServerPrivate() = default; QString ServerPrivate::notificationServiceName() { return QStringLiteral("org.freedesktop.Notifications"); } ServerInfo *ServerPrivate::currentOwner() const { if (!m_currentOwner) { m_currentOwner.reset(new ServerInfo()); } return m_currentOwner.data(); } bool ServerPrivate::init() { if (m_valid) { return true; } new NotificationsAdaptor(this); if (!m_dbusObjectValid) { // if already registered, don't fail here m_dbusObjectValid = QDBusConnection::sessionBus().registerObject(QStringLiteral("/org/freedesktop/Notifications"), this); } if (!m_dbusObjectValid) { qCWarning(NOTIFICATIONMANAGER) << "Failed to register Notification DBus object"; return false; } // Only the "dbus master" (effectively plasmashell) should be the true owner of notifications const bool master = Utils::isDBusMaster(); QDBusConnectionInterface *dbusIface = QDBusConnection::sessionBus().interface(); if (!master) { // NOTE this connects to whether the application lost ownership of given service // This is not a wildcard listener for all unregistered services on the bus! connect(dbusIface, &QDBusConnectionInterface::serviceUnregistered, this, &ServerPrivate::onServiceOwnershipLost, Qt::UniqueConnection); } auto registration = dbusIface->registerService(notificationServiceName(), master ? QDBusConnectionInterface::ReplaceExistingService : QDBusConnectionInterface::DontQueueService, master ? QDBusConnectionInterface::DontAllowReplacement : QDBusConnectionInterface::AllowReplacement ); if (registration.value() != QDBusConnectionInterface::ServiceRegistered) { qCWarning(NOTIFICATIONMANAGER) << "Failed to register Notification service on DBus"; return false; } connect(this, &ServerPrivate::inhibitedChanged, this, &ServerPrivate::onInhibitedChanged, Qt::UniqueConnection); qCDebug(NOTIFICATIONMANAGER) << "Registered Notification service on DBus"; KConfigGroup config(KSharedConfig::openConfig(), QStringLiteral("Notifications")); const bool broadcastsEnabled = config.readEntry("ListenForBroadcasts", false); if (broadcastsEnabled) { qCDebug(NOTIFICATIONMANAGER) << "Notification server is configured to listen for broadcasts"; // NOTE Keep disconnect() call in onServiceOwnershipLost in sync if you change this! QDBusConnection::systemBus().connect({}, {}, QStringLiteral("org.kde.BroadcastNotifications"), QStringLiteral("Notify"), this, SLOT(onBroadcastNotification(QMap))); } m_valid = true; emit validChanged(); return true; } uint ServerPrivate::Notify(const QString &app_name, uint replaces_id, const QString &app_icon, const QString &summary, const QString &body, const QStringList &actions, const QVariantMap &hints, int timeout) { const bool wasReplaced = replaces_id > 0; uint notificationId = 0; if (wasReplaced) { notificationId = replaces_id; } else { // Avoid wrapping around to 0 in case of overflow if (!m_highestNotificationId) { ++m_highestNotificationId; } notificationId = m_highestNotificationId; ++m_highestNotificationId; } Notification notification(notificationId); notification.setSummary(summary); notification.setBody(body); notification.setApplicationName(app_name); notification.setActions(actions); notification.setTimeout(timeout); // might override some of the things we set above (like application name) notification.d->processHints(hints); // If we didn't get a pixmap, load the app_icon instead if (notification.d->image.isNull()) { notification.setIcon(app_icon); } uint pid = 0; if (notification.desktopEntry().isEmpty() || notification.applicationName().isEmpty()) { if (notification.desktopEntry().isEmpty() && notification.applicationName().isEmpty()) { qCInfo(NOTIFICATIONMANAGER) << "Notification from service" << message().service() << "didn't contain any identification information, this is an application bug!"; } QDBusReply pidReply = connection().interface()->servicePid(message().service()); if (pidReply.isValid()) { pid = pidReply.value(); } } // No desktop entry? Try to read the BAMF_DESKTOP_FILE_HINT in the environment of snaps if (notification.desktopEntry().isEmpty() && pid > 0) { const QString desktopEntry = Utils::desktopEntryFromPid(pid); if (!desktopEntry.isEmpty()) { qCDebug(NOTIFICATIONMANAGER) << "Resolved notification to be from desktop entry" << desktopEntry; notification.setDesktopEntry(desktopEntry); } } // No application name? Try to figure out the process name using the sender's PID if (notification.applicationName().isEmpty() && pid > 0) { const QString processName = Utils::processNameFromPid(pid); if (!processName.isEmpty()) { qCDebug(NOTIFICATIONMANAGER) << "Resolved notification to be from process name" << processName; notification.setApplicationName(processName); } } // If multiple identical notifications are sent in quick succession, refuse the request if (m_lastNotification.applicationName() == notification.applicationName() && m_lastNotification.summary() == notification.summary() && m_lastNotification.body() == notification.body() && m_lastNotification.desktopEntry() == notification.desktopEntry() && m_lastNotification.eventId() == notification.eventId() && m_lastNotification.actionNames() == notification.actionNames() && m_lastNotification.urls() == notification.urls() && m_lastNotification.created().msecsTo(notification.created()) < 1000) { qCDebug(NOTIFICATIONMANAGER) << "Discarding excess notification creation request"; sendErrorReply(QStringLiteral("org.freedesktop.Notifications.Error.ExcessNotificationGeneration"), QStringLiteral("Created too many similar notifications in quick succession")); return 0; } m_lastNotification = notification; if (wasReplaced) { notification.resetUpdated(); emit static_cast(parent())->notificationReplaced(replaces_id, notification); } else { emit static_cast(parent())->notificationAdded(notification); } return notificationId; } void ServerPrivate::CloseNotification(uint id) { // spec says "If the notification no longer exists, an empty D-BUS Error message is sent back." static_cast(parent())->closeNotification(id, Server::CloseReason::Revoked); } QStringList ServerPrivate::GetCapabilities() const { // should this be configurable somehow so the UI can tell what it implements? return QStringList{ QStringLiteral("body"), QStringLiteral("body-hyperlinks"), QStringLiteral("body-markup"), QStringLiteral("body-images"), QStringLiteral("icon-static"), QStringLiteral("actions"), + QStringLiteral("inline-reply"), QStringLiteral("x-kde-urls"), QStringLiteral("x-kde-origin-name"), QStringLiteral("x-kde-display-appname"), QStringLiteral("inhibitions") }; } QString ServerPrivate::GetServerInformation(QString &vendor, QString &version, QString &specVersion) const { vendor = QStringLiteral("KDE"); version = QLatin1String(PROJECT_VERSION); specVersion = QStringLiteral("1.2"); return QStringLiteral("Plasma"); } void ServerPrivate::onBroadcastNotification(const QMap &properties) { qCDebug(NOTIFICATIONMANAGER) << "Received broadcast notification"; const auto currentUserId = KUserId::currentEffectiveUserId().nativeId(); // a QVariantList with ints arrives as QDBusArgument here, using a QStringList for simplicity const QStringList &userIds = properties.value(QStringLiteral("uids")).toStringList(); if (!userIds.isEmpty()) { auto it = std::find_if(userIds.constBegin(), userIds.constEnd(), [currentUserId](const QVariant &id) { bool ok; auto uid = id.toString().toLongLong(&ok); return ok && uid == currentUserId; }); if (it == userIds.constEnd()) { qCDebug(NOTIFICATIONMANAGER) << "It is not meant for us, ignoring"; return; } } bool ok; int timeout = properties.value(QStringLiteral("timeout")).toInt(&ok); if (!ok) { timeout = -1; // -1 = server default, 0 would be "persistent" } Notify( properties.value(QStringLiteral("appName")).toString(), 0, // replaces_id properties.value(QStringLiteral("appIcon")).toString(), properties.value(QStringLiteral("summary")).toString(), properties.value(QStringLiteral("body")).toString(), {}, // no actions properties.value(QStringLiteral("hints")).toMap(), timeout ); } uint ServerPrivate::add(const Notification ¬ification) { // TODO check if notification with ID already exists and signal update instead if (notification.id() == 0) { ++m_highestNotificationId; notification.d->id = m_highestNotificationId; emit static_cast(parent())->notificationAdded(notification); } else { emit static_cast(parent())->notificationReplaced(notification.id(), notification); } return notification.id(); } uint ServerPrivate::Inhibit(const QString &desktop_entry, const QString &reason, const QVariantMap &hints) { const QString dbusService = message().service(); qCDebug(NOTIFICATIONMANAGER) << "Request inhibit from service" << dbusService << "which is" << desktop_entry << "with reason" << reason; if (desktop_entry.isEmpty()) { // TODO return error return 0; } KService::Ptr service = KService::serviceByDesktopName(desktop_entry); QString applicationName; if (service) { // should we check for this and error if it didn't find a service? applicationName = service->name(); } m_inhibitionWatcher->addWatchedService(dbusService); ++m_highestInhibitionCookie; const bool oldExternalInhibited = externalInhibited(); m_externalInhibitions.insert(m_highestInhibitionCookie, { desktop_entry, applicationName, reason, hints }); m_inhibitionServices.insert(m_highestInhibitionCookie, dbusService); if (externalInhibited() != oldExternalInhibited) { emit externalInhibitedChanged(); } emit externalInhibitionsChanged(); return m_highestInhibitionCookie; } void ServerPrivate::onServiceOwnershipLost(const QString &serviceName) { if (serviceName != notificationServiceName()) { return; } qCDebug(NOTIFICATIONMANAGER) << "Lost ownership of" << serviceName << "service"; disconnect(QDBusConnection::sessionBus().interface(), &QDBusConnectionInterface::serviceUnregistered, this, &ServerPrivate::onServiceOwnershipLost); disconnect(this, &ServerPrivate::inhibitedChanged, this, &ServerPrivate::onInhibitedChanged); QDBusConnection::systemBus().disconnect({}, {}, QStringLiteral("org.kde.BroadcastNotifications"), QStringLiteral("Notify"), this, SLOT(onBroadcastNotification(QMap))); m_valid = false; emit validChanged(); emit serviceOwnershipLost(); } void ServerPrivate::onInhibitionServiceUnregistered(const QString &serviceName) { qCDebug(NOTIFICATIONMANAGER) << "Inhibition service unregistered" << serviceName; const QList cookies = m_inhibitionServices.keys(serviceName); if (cookies.isEmpty()) { qCInfo(NOTIFICATIONMANAGER) << "Unknown inhibition service unregistered" << serviceName; return; } // We do lookups in there again... for (uint cookie : cookies) { UnInhibit(cookie); } } void ServerPrivate::onInhibitedChanged() { // emit DBus change signal... QDBusMessage signal = QDBusMessage::createSignal( QStringLiteral("/org/freedesktop/Notifications"), QStringLiteral("org.freedesktop.DBus.Properties"), QStringLiteral("PropertiesChanged") ); signal.setArguments({ QStringLiteral("org.freedesktop.Notifications"), QVariantMap{ // updated {QStringLiteral("Inhibited"), inhibited()}, }, QStringList() // invalidated }); QDBusConnection::sessionBus().send(signal); } void ServerPrivate::UnInhibit(uint cookie) { qCDebug(NOTIFICATIONMANAGER) << "Request release inhibition for cookie" << cookie; const QString service = m_inhibitionServices.value(cookie); if (service.isEmpty()) { qCInfo(NOTIFICATIONMANAGER) << "Requested to release inhibition with cookie" << cookie << "that doesn't exist"; // TODO if called from dbus raise error return; } m_inhibitionWatcher->removeWatchedService(service); m_externalInhibitions.remove(cookie); m_inhibitionServices.remove(cookie); if (m_externalInhibitions.isEmpty()) { emit externalInhibitedChanged(); } emit externalInhibitionsChanged(); } QList ServerPrivate::externalInhibitions() const { return m_externalInhibitions.values(); } bool ServerPrivate::inhibited() const { return m_inhibited; } void ServerPrivate::setInhibited(bool inhibited) { if (m_inhibited != inhibited) { m_inhibited = inhibited; emit inhibitedChanged(); } } bool ServerPrivate::externalInhibited() const { return !m_externalInhibitions.isEmpty(); } void ServerPrivate::clearExternalInhibitions() { if (m_externalInhibitions.isEmpty()) { return; } m_inhibitionWatcher->setWatchedServices(QStringList()); // remove all watches m_inhibitionServices.clear(); m_externalInhibitions.clear(); emit externalInhibitedChanged(); emit externalInhibitionsChanged(); } diff --git a/libnotificationmanager/server_p.h b/libnotificationmanager/server_p.h index 03c279bb0..a6e72aa39 100644 --- a/libnotificationmanager/server_p.h +++ b/libnotificationmanager/server_p.h @@ -1,128 +1,130 @@ /* * 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 "notification.h" class QDBusServiceWatcher; struct Inhibition { QString desktopEntry; QString applicationName; //QString applicationIconName; QString reason; QVariantMap hints; }; namespace NotificationManager { class ServerInfo; class Q_DECL_HIDDEN ServerPrivate : public QObject, protected QDBusContext { Q_OBJECT // DBus // Inhibitions Q_PROPERTY(bool Inhibited READ inhibited) public: ServerPrivate(QObject *parent); ~ServerPrivate() override; // DBus uint Notify(const QString &app_name, uint replaces_id, const QString &app_icon, const QString &summary, const QString &body, const QStringList &actions, const QVariantMap &hints, int timeout); void CloseNotification(uint id); QStringList GetCapabilities() const; QString GetServerInformation(QString &vendor, QString &version, QString &specVersion) const; // Inhibitions uint Inhibit(const QString &desktop_entry, const QString &reason, const QVariantMap &hints); void UnInhibit(uint cookie); bool inhibited() const; // property getter Q_SIGNALS: // DBus void NotificationClosed(uint id, uint reason); void ActionInvoked(uint id, const QString &actionKey); + // non-standard + void NotificationReplied(uint id, const QString &text); void validChanged(); void inhibitedChanged(); void externalInhibitedChanged(); void externalInhibitionsChanged(); void serviceOwnershipLost(); public: // stuff used by public class friend class ServerInfo; static QString notificationServiceName(); bool init(); uint add(const Notification ¬ification); ServerInfo *currentOwner() const; // Server only handles external application inhibitions but we still want the Inhibited property // expose the actual inhibition state for applications to check. void setInhibited(bool inhibited); bool externalInhibited() const; QList externalInhibitions() const; void clearExternalInhibitions(); bool m_valid = false; uint m_highestNotificationId = 1; private slots: void onBroadcastNotification(const QMap &properties); private: void onServiceOwnershipLost(const QString &serviceName); void onInhibitionServiceUnregistered(const QString &serviceName); void onInhibitedChanged(); // emit DBus change signal bool m_dbusObjectValid = false; mutable QScopedPointer m_currentOwner; QDBusServiceWatcher *m_inhibitionWatcher = nullptr; uint m_highestInhibitionCookie = 0; QHash m_externalInhibitions; QHash m_inhibitionServices; bool m_inhibited = false; Notification m_lastNotification; }; } // namespace NotificationManager