diff --git a/applets/notifications/notificationapplet.h b/applets/notifications/notificationapplet.h --- a/applets/notifications/notificationapplet.h +++ b/applets/notifications/notificationapplet.h @@ -26,6 +26,7 @@ class QQuickItem; class QString; class QRect; +class QWindow; class NotificationApplet : public Plasma::Applet { @@ -59,6 +60,7 @@ 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(); diff --git a/applets/notifications/notificationapplet.cpp b/applets/notifications/notificationapplet.cpp --- a/applets/notifications/notificationapplet.cpp +++ b/applets/notifications/notificationapplet.cpp @@ -31,6 +31,9 @@ #include #include #include +#include + +#include #include @@ -165,6 +168,13 @@ 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/package/contents/ui/NotificationItem.qml b/applets/notifications/package/contents/ui/NotificationItem.qml --- a/applets/notifications/package/contents/ui/NotificationItem.qml +++ b/applets/notifications/package/contents/ui/NotificationItem.qml @@ -71,6 +71,12 @@ 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 @@ -85,14 +91,17 @@ 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 @@ -282,13 +291,46 @@ } } - 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 @@ -306,18 +348,54 @@ 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 diff --git a/applets/notifications/package/contents/ui/NotificationPopup.qml b/applets/notifications/package/contents/ui/NotificationPopup.qml --- a/applets/notifications/package/contents/ui/NotificationPopup.qml +++ b/applets/notifications/package/contents/ui/NotificationPopup.qml @@ -66,12 +66,19 @@ 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 @@ -95,8 +102,7 @@ } location: PlasmaCore.Types.Floating - - flags: Qt.WindowDoesNotAcceptFocus + flags: notificationItem.replying ? 0 : Qt.WindowDoesNotAcceptFocus visible: false @@ -136,7 +142,7 @@ 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(); @@ -182,6 +188,7 @@ onDismissClicked: notificationPopup.dismissClicked() onConfigureClicked: notificationPopup.configureClicked() onActionInvoked: notificationPopup.actionInvoked(actionName) + onReplied: notificationPopup.replied(text) onOpenUrl: notificationPopup.openUrl(url) onFileActionInvoked: notificationPopup.fileActionInvoked() diff --git a/applets/notifications/package/contents/ui/NotificationReplyField.qml b/applets/notifications/package/contents/ui/NotificationReplyField.qml new file mode 100644 --- /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 --- a/applets/notifications/package/contents/ui/global/Globals.qml +++ b/applets/notifications/package/contents/ui/global/Globals.qml @@ -416,6 +416,12 @@ 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)) @@ -429,6 +435,10 @@ 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)) diff --git a/libnotificationmanager/dbus/org.freedesktop.Notifications.xml b/libnotificationmanager/dbus/org.freedesktop.Notifications.xml --- a/libnotificationmanager/dbus/org.freedesktop.Notifications.xml +++ b/libnotificationmanager/dbus/org.freedesktop.Notifications.xml @@ -9,6 +9,11 @@ + + + + + diff --git a/libnotificationmanager/notification.h b/libnotificationmanager/notification.h --- a/libnotificationmanager/notification.h +++ b/libnotificationmanager/notification.h @@ -109,6 +109,12 @@ 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); diff --git a/libnotificationmanager/notification.cpp b/libnotificationmanager/notification.cpp --- a/libnotificationmanager/notification.cpp +++ b/libnotificationmanager/notification.cpp @@ -388,6 +388,10 @@ 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. @@ -615,6 +619,7 @@ d->hasDefaultAction = false; d->hasConfigureAction = false; + d->hasReplyAction = false; QStringList names; QStringList labels; @@ -635,6 +640,12 @@ continue; } + if (!d->hasReplyAction && name == QLatin1String("inline-reply")) { + d->hasReplyAction = true; + d->replyActionLabel = label; + continue; + } + names << name; labels << label; } @@ -683,6 +694,31 @@ 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; diff --git a/libnotificationmanager/notification_p.h b/libnotificationmanager/notification_p.h --- a/libnotificationmanager/notification_p.h +++ b/libnotificationmanager/notification_p.h @@ -88,6 +88,12 @@ QString notifyRcName; QString eventId; + bool hasReplyAction = false; + QString replyActionLabel; + QString replyPlaceholderText; + QString replySubmitButtonText; + QString replySubmitButtonIconName; + QList urls; bool userActionFeedback = false; diff --git a/libnotificationmanager/notifications.h b/libnotificationmanager/notifications.h --- a/libnotificationmanager/notifications.h +++ b/libnotificationmanager/notifications.h @@ -260,6 +260,12 @@ 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) @@ -432,6 +438,14 @@ */ 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 * diff --git a/libnotificationmanager/notifications.cpp b/libnotificationmanager/notifications.cpp --- a/libnotificationmanager/notifications.cpp +++ b/libnotificationmanager/notifications.cpp @@ -726,6 +726,13 @@ } } +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)); diff --git a/libnotificationmanager/notificationsmodel.h b/libnotificationmanager/notificationsmodel.h --- a/libnotificationmanager/notificationsmodel.h +++ b/libnotificationmanager/notificationsmodel.h @@ -52,6 +52,7 @@ 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); diff --git a/libnotificationmanager/notificationsmodel.cpp b/libnotificationmanager/notificationsmodel.cpp --- a/libnotificationmanager/notificationsmodel.cpp +++ b/libnotificationmanager/notificationsmodel.cpp @@ -310,6 +310,12 @@ 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(); @@ -439,6 +445,22 @@ 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); diff --git a/libnotificationmanager/server.h b/libnotificationmanager/server.h --- a/libnotificationmanager/server.h +++ b/libnotificationmanager/server.h @@ -148,6 +148,15 @@ */ 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 * diff --git a/libnotificationmanager/server.cpp b/libnotificationmanager/server.cpp --- a/libnotificationmanager/server.cpp +++ b/libnotificationmanager/server.cpp @@ -80,6 +80,11 @@ 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); diff --git a/libnotificationmanager/server_p.h b/libnotificationmanager/server_p.h --- a/libnotificationmanager/server_p.h +++ b/libnotificationmanager/server_p.h @@ -72,6 +72,8 @@ // 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(); diff --git a/libnotificationmanager/server_p.cpp b/libnotificationmanager/server_p.cpp --- a/libnotificationmanager/server_p.cpp +++ b/libnotificationmanager/server_p.cpp @@ -231,6 +231,7 @@ QStringLiteral("body-images"), QStringLiteral("icon-static"), QStringLiteral("actions"), + QStringLiteral("inline-reply"), QStringLiteral("x-kde-urls"), QStringLiteral("x-kde-origin-name"),