From f04281cb69a90847d48845148776bf76eaf35cc2 Mon Sep 17 00:00:00 2001 From: allexzander Date: Wed, 18 Nov 2020 17:42:37 +0200 Subject: [PATCH] Implemented notification action buttons and context menu for confirm/dismiss and other actions. Signed-off-by: allexzander --- resources.qrc | 1 + src/gui/CMakeLists.txt | 1 + src/gui/tray/ActivityActionButton.qml | 106 +++++++++++ src/gui/tray/ActivityData.h | 9 +- src/gui/tray/ActivityListModel.cpp | 41 ++++- src/gui/tray/ActivityListModel.h | 4 +- src/gui/tray/NotificationHandler.cpp | 4 +- src/gui/tray/UserModel.cpp | 4 +- src/gui/tray/Window.qml | 245 +++++++++++++++++++------- 9 files changed, 334 insertions(+), 81 deletions(-) create mode 100644 src/gui/tray/ActivityActionButton.qml diff --git a/resources.qrc b/resources.qrc index 437c9567a..f2f122987 100644 --- a/resources.qrc +++ b/resources.qrc @@ -5,5 +5,6 @@ src/gui/tray/HeaderButton.qml theme/Style/Style.qml theme/Style/qmldir + src/gui/tray/ActivityActionButton.qml diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 5a49920e0..b6a666924 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -43,6 +43,7 @@ set(client_UI_SRCS addcertificatedialog.ui proxyauthdialog.ui mnemonicdialog.ui + tray/ActivityActionButton.qml tray/Window.qml tray/UserLine.qml wizard/flow2authwidget.ui diff --git a/src/gui/tray/ActivityActionButton.qml b/src/gui/tray/ActivityActionButton.qml new file mode 100644 index 000000000..6bb127e71 --- /dev/null +++ b/src/gui/tray/ActivityActionButton.qml @@ -0,0 +1,106 @@ +import QtQuick 2.5 +import QtQuick.Controls 2.3 +import Style 1.0 + +Item { + id: root + readonly property bool labelVisible: label.visible + readonly property bool iconVisible: icon.visible + + // label value + property string text: "" + + // icon value + property string imageSource: "" + + // Tooltip value + property string tooltipText: text + + // text color + property color textColor: Style.ncTextColor + property color textColorHovered: Style.lightHover + + // text background color + property color textBgColor: "transparent" + property color textBgColorHovered: Style.lightHover + + // icon background color + property color iconBgColor: "transparent" + property color iconBgColorHovered: Style.lightHover + + // text border color + property color textBorderColor: "transparent" + + property alias hovered: mouseArea.containsMouse + + signal clicked() + + Accessible.role: Accessible.Button + Accessible.name: text !== "" ? text : (tooltipText !== "" ? tooltipText : qsTr("Activity action button")) + Accessible.onPressAction: clicked() + + // background with border around the Text + Rectangle { + visible: parent.labelVisible + + anchors.fill: parent + + // padding + anchors.topMargin: 10 + anchors.bottomMargin: 10 + + border.color: parent.textBorderColor + border.width: 1 + + color: parent.hovered ? parent.textBgColorHovered : parent.textBgColor + + radius: 25 + } + + // background with border around the Image + Rectangle { + visible: parent.iconVisible + + anchors.fill: parent + + color: parent.hovered ? parent.iconBgColorHovered : parent.iconBgColor + } + + // label + Text { + id: label + visible: parent.text !== "" + text: parent.text + font: parent.font + color: parent.hovered ? parent.textColorHovered : parent.textColor + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + // icon + Image { + id: icon + visible: parent.imageSource !== "" + anchors.centerIn: parent + source: parent.imageSource + sourceSize.width: visible ? 32 : 0 + sourceSize.height: visible ? 32 : 0 + } + + MouseArea { + id: mouseArea + anchors.fill: parent + onClicked: parent.clicked() + hoverEnabled: true + } + + ToolTip { + text: parent.tooltipText + delay: 1000 + visible: text != "" && parent.hovered + } +} diff --git a/src/gui/tray/ActivityData.h b/src/gui/tray/ActivityData.h index be30f77a2..f8bbef101 100644 --- a/src/gui/tray/ActivityData.h +++ b/src/gui/tray/ActivityData.h @@ -27,11 +27,18 @@ namespace OCC { class ActivityLink { + Q_GADGET + + Q_PROPERTY(QString label MEMBER _label) + Q_PROPERTY(QString link MEMBER _link) + Q_PROPERTY(QByteArray verb MEMBER _verb) + Q_PROPERTY(bool primary MEMBER _primary) + public: QString _label; QString _link; QByteArray _verb; - bool _isPrimary; + bool _primary; }; /* ==================================================================== */ diff --git a/src/gui/tray/ActivityListModel.cpp b/src/gui/tray/ActivityListModel.cpp index 15d7a22bd..a2d39252d 100644 --- a/src/gui/tray/ActivityListModel.cpp +++ b/src/gui/tray/ActivityListModel.cpp @@ -53,6 +53,7 @@ QHash ActivityListModel::roleNames() const roles[ActionRole] = "type"; roles[ActionIconRole] = "icon"; roles[ActionTextRole] = "subject"; + roles[ActionsLinksRole] = "links"; roles[ActionTextColorRole] = "activityTextTitleColor"; roles[ObjectTypeRole] = "objectType"; roles[PointInTimeRole] = "dateTime"; @@ -139,10 +140,8 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const } case ActionsLinksRole: { QList customList; - foreach (ActivityLink customItem, a._links) { - QVariant customVariant; - customVariant.setValue(customItem); - customList << customVariant; + foreach (ActivityLink activityLink, a._links) { + customList << QVariant::fromValue(activityLink); } return customList; } @@ -418,17 +417,17 @@ void ActivityListModel::removeActivityFromActivityList(Activity activity) } } -void ActivityListModel::triggerActionAtIndex(int id) const +void ActivityListModel::triggerDefaultAction(int activityIndex) const { - if (id < 0 || id >= _finalList.size()) { - qCWarning(lcActivity) << "Couldn't trigger action at index" << id << "/ final list size:" << _finalList.size(); + if (activityIndex < 0 || activityIndex >= _finalList.size()) { + qCWarning(lcActivity) << "Couldn't trigger default action at index" << activityIndex << "/ final list size:" << _finalList.size(); return; } - const auto modelIndex = index(id); + const auto modelIndex = index(activityIndex); const auto path = data(modelIndex, PathRole).toUrl(); - const auto activity = _finalList.at(id); + const auto activity = _finalList.at(activityIndex); if (activity._status == SyncFileItem::Conflict) { Q_ASSERT(!activity._file.isEmpty()); Q_ASSERT(!activity._folder.isEmpty()); @@ -461,6 +460,30 @@ void ActivityListModel::triggerActionAtIndex(int id) const } } +void ActivityListModel::triggerAction(int activityIndex, int actionIndex) +{ + if (activityIndex < 0 || activityIndex >= _finalList.size()) { + qCWarning(lcActivity) << "Couldn't trigger action on activity at index" << activityIndex << "/ final list size:" << _finalList.size(); + return; + } + + const auto activity = _finalList[activityIndex]; + + if (actionIndex < 0 || actionIndex >= activity._links.size()) { + qCWarning(lcActivity) << "Couldn't trigger action at index" << actionIndex << "/ actions list size:" << activity._links.size(); + return; + } + + const auto action = activity._links[actionIndex]; + + if (action._verb == "WEB") { + QDesktopServices::openUrl(QUrl(action._link)); + return; + } + + emit sendNotificationRequest(activity._accName, action._link, action._verb, activityIndex); +} + void ActivityListModel::combineActivityLists() { ActivityList resultList; diff --git a/src/gui/tray/ActivityListModel.h b/src/gui/tray/ActivityListModel.h index f09d39d66..29b14e921 100644 --- a/src/gui/tray/ActivityListModel.h +++ b/src/gui/tray/ActivityListModel.h @@ -74,7 +74,8 @@ public: void removeActivityFromActivityList(int row); void removeActivityFromActivityList(Activity activity); - Q_INVOKABLE void triggerActionAtIndex(int id) const; + Q_INVOKABLE void triggerDefaultAction(int activityIndex) const; + Q_INVOKABLE void triggerAction(int activityIndex, int actionIndex); public slots: void slotRefreshActivity(); @@ -86,6 +87,7 @@ private slots: signals: void activityJobStatusCode(int statusCode); + void sendNotificationRequest(const QString &accountName, const QString &link, const QByteArray &verb, int row); protected: QHash roleNames() const override; diff --git a/src/gui/tray/NotificationHandler.cpp b/src/gui/tray/NotificationHandler.cpp index 445d0a88a..98e5c1aa7 100644 --- a/src/gui/tray/NotificationHandler.cpp +++ b/src/gui/tray/NotificationHandler.cpp @@ -128,7 +128,7 @@ void ServerNotificationHandler::slotNotificationsReceived(const QJsonDocument &j al._label = QUrl::fromPercentEncoding(actionJson.value("label").toString().toUtf8()); al._link = actionJson.value("link").toString(); al._verb = actionJson.value("type").toString().toUtf8(); - al._isPrimary = actionJson.value("primary").toBool(); + al._primary = actionJson.value("primary").toBool(); a._links.append(al); } @@ -139,7 +139,7 @@ void ServerNotificationHandler::slotNotificationsReceived(const QJsonDocument &j al._label = tr("Dismiss"); al._link = Utility::concatUrlPath(ai->account()->url(), notificationsPath + "/" + QString::number(a._id)).toString(); al._verb = "DELETE"; - al._isPrimary = false; + al._primary = false; a._links.append(al); list.append(a); diff --git a/src/gui/tray/UserModel.cpp b/src/gui/tray/UserModel.cpp index 9df3dd111..8e83bc936 100644 --- a/src/gui/tray/UserModel.cpp +++ b/src/gui/tray/UserModel.cpp @@ -51,6 +51,8 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) connect(this, &User::guiLog, Logger::instance(), &Logger::guiLog); connect(_account->account().data(), &Account::accountChangedAvatar, this, &User::avatarChanged); + + connect(_activityModel, &ActivityListModel::sendNotificationRequest, this, &User::slotSendNotificationRequest); } void User::slotBuildNotificationDisplay(const ActivityList &list) @@ -329,7 +331,7 @@ void User::slotAddError(const QString &folderAlias, const QString &message, Erro link._label = tr("Retry all uploads"); link._link = folderInstance->path(); link._verb = ""; - link._isPrimary = true; + link._primary = true; activity._links.append(link); } diff --git a/src/gui/tray/Window.qml b/src/gui/tray/Window.qml index a007a9cb3..ac9de333f 100644 --- a/src/gui/tray/Window.qml +++ b/src/gui/tray/Window.qml @@ -108,23 +108,11 @@ Window { id: trayWindowHeaderBackground anchors.left: trayWindowBackground.left + anchors.right: trayWindowBackground.right anchors.top: trayWindowBackground.top height: Style.trayWindowHeaderHeight - width: Style.trayWindowWidth color: Style.ncBlue - // The overlay rectangle below eliminates the rounded corners from the bottom of the header - // as Qt only allows setting the radius for all corners right now, not specific ones - Rectangle { - id: trayWindowHeaderButtomHalfBackground - - anchors.left: trayWindowHeaderBackground.left - anchors.bottom: trayWindowHeaderBackground.bottom - height: Style.trayWindowHeaderHeight / 2 - width: Style.trayWindowWidth - color: Style.ncBlue - } - RowLayout { id: trayWindowHeaderLayout @@ -511,16 +499,17 @@ Window { ListView { id: activityListView - anchors.top: trayWindowHeaderBackground.bottom - anchors.horizontalCenter: trayWindowBackground.horizontalCenter - width: Style.trayWindowWidth - Style.trayWindowBorderWidth - height: Style.trayWindowHeight - Style.trayWindowHeaderHeight + anchors.left: trayWindowBackground.left + anchors.right: trayWindowBackground.right + anchors.bottom: trayWindowBackground.bottom clip: true ScrollBar.vertical: ScrollBar { id: listViewScrollbar } + readonly property int maxActionButtons: 2 + keyNavigationEnabled: true Accessible.role: Accessible.List @@ -531,26 +520,28 @@ Window { delegate: RowLayout { id: activityItem + readonly property variant links: model.links + + readonly property int itemIndex: model.index + width: parent.width height: Style.trayWindowHeaderHeight spacing: 0 Accessible.role: Accessible.ListItem Accessible.name: path !== "" ? qsTr("Open %1 locally").arg(displayPath) - : message + : message Accessible.onPressAction: activityMouseArea.clicked() MouseArea { id: activityMouseArea enabled: (path !== "" || link !== "") anchors.left: activityItem.left - anchors.right: (shareButton.visible) ? shareButton.left - : (replyButton.visible) ? replyButton.left - : activityItem.right + anchors.right: activityActionsLayout.right height: parent.height anchors.margins: 2 hoverEnabled: true - onClicked: activityModel.triggerActionAtIndex(model.index) + onClicked: activityModel.triggerDefaultAction(model.index) Rectangle { anchors.fill: parent @@ -575,13 +566,14 @@ Window { Column { id: activityTextColumn anchors.left: activityIcon.right + anchors.right: activityActionsLayout.left anchors.leftMargin: 8 spacing: 4 Layout.alignment: Qt.AlignLeft Text { id: activityTextTitle text: (type === "Activity" || type === "Notification") ? subject : message - width: Style.activityLabelBaseWidth + ((path === "") ? activityItem.height : 0) + ((link === "") ? activityItem.height : 0) - 8 + width: parent.width elide: Text.ElideRight font.pixelSize: Style.topLinePixelSize color: activityTextTitleColor @@ -594,7 +586,7 @@ Window { : (type === "Notification") ? message : "" height: (text === "") ? 0 : activityTextTitle.height - width: Style.activityLabelBaseWidth + ((path === "") ? activityItem.height : 0) + ((link === "") ? activityItem.height : 0) - 8 + width: parent.width elide: Text.ElideRight font.pixelSize: Style.subLinePixelSize } @@ -603,7 +595,7 @@ Window { id: activityTextDateTime text: dateTime height: (text === "") ? 0 : activityTextTitle.height - width: Style.activityLabelBaseWidth + ((path === "") ? activityItem.height : 0) + ((link === "") ? activityItem.height : 0) - 8 + width: parent.width elide: Text.ElideRight font.pixelSize: Style.subLinePixelSize color: "#808080" @@ -624,56 +616,175 @@ Window { } } } - Button { - id: shareButton + RowLayout { + id: activityActionsLayout anchors.right: activityItem.right - - Layout.preferredWidth: (path === "") ? 0 : parent.height - Layout.preferredHeight: parent.height + spacing: 0 Layout.alignment: Qt.AlignRight - flat: true - hoverEnabled: true - visible: (path === "") ? false : true - display: AbstractButton.IconOnly - icon.source: "qrc:///client/theme/share.svg" - icon.color: "transparent" - background: Rectangle { - color: parent.hovered ? Style.lightHover : "transparent" + + function actionButtonIcon(actionIndex) { + const verb = String(model.links[actionIndex].verb); + if (verb === "WEB" && (model.objectType === "chat" || model.objectType === "call")) { + return "qrc:///client/theme/reply.svg"; + } else if (verb === "DELETE") { + return "qrc:///client/theme/close.svg"; + } + + return "qrc:///client/theme/confirm.svg"; } - ToolTip.visible: hovered - ToolTip.delay: 1000 - ToolTip.text: qsTr("Open share dialog") - onClicked: Systray.openShareDialog(displayPath,absolutePath) - Accessible.role: Accessible.Button - Accessible.name: qsTr("Share %1").arg(displayPath) - Accessible.onPressAction: shareButton.clicked() - } + Repeater { + model: activityItem.links.length > activityListView.maxActionButtons ? 1 : activityItem.links.length - Button { - id: replyButton - anchors.right: activityItem.right + ActivityActionButton { + id: activityActionButton + + readonly property int actionIndex: model.index + readonly property bool primary: model.index === 0 && String(activityItem.links[actionIndex].verb) !== "DELETE" + + height: activityItem.height + + text: !primary ? "" : activityItem.links[actionIndex].label + + imageSource: !primary ? activityActionsLayout.actionButtonIcon(actionIndex) : "" + + textColor: primary ? Style.ncBlue : "black" + textColorHovered: Style.lightHover + + textBorderColor: Style.ncBlue + + textBgColor: "transparent" + textBgColorHovered: Style.ncBlue + + tooltipText: activityItem.links[actionIndex].label + + Layout.minimumWidth: primary ? 80 : -1 + Layout.minimumHeight: parent.height + + Layout.preferredWidth: primary ? -1 : parent.height + + onClicked: activityModel.triggerAction(activityItem.itemIndex, actionIndex) + } - Layout.preferredWidth: (objectType == "chat" || objectType == "call") ? parent.height : 0 - Layout.preferredHeight: parent.height - Layout.alignment: Qt.AlignRight - flat: true - hoverEnabled: true - visible: (objectType == "chat" || objectType == "call") ? true : false - display: AbstractButton.IconOnly - icon.source: "qrc:///client/theme/reply.svg" - icon.color: "transparent" - background: Rectangle { - color: parent.hovered ? Style.lightHover : "transparent" } - ToolTip.visible: hovered - ToolTip.delay: 1000 - ToolTip.text: qsTr("Open Talk") - onClicked: Qt.openUrlExternally(link) - Accessible.role: Accessible.Button - Accessible.name: qsTr("Open Talk %1").arg(link) - Accessible.onPressAction: replyButton.clicked() + Button { + id: moreActionsButton + + Layout.preferredWidth: parent.height + Layout.preferredHeight: parent.height + Layout.alignment: Qt.AlignRight + + flat: true + hoverEnabled: true + visible: activityItem.links.length > activityListView.maxActionButtons + display: AbstractButton.IconOnly + icon.source: "qrc:///client/theme/more.svg" + icon.color: "transparent" + background: Rectangle { + color: parent.hovered ? Style.lightHover : "transparent" + } + ToolTip.visible: hovered + ToolTip.delay: 1000 + ToolTip.text: qsTr("Show more actions") + + Accessible.role: Accessible.Button + Accessible.name: qsTr("Show more actions") + Accessible.onPressAction: moreActionsButton.clicked() + + onClicked: moreActionsButtonContextMenu.popup(); + + Connections { + target: trayWindow + onActiveChanged: { + if (!trayWindow.active) { + moreActionsButtonContextMenu.close(); + } + } + } + + Connections { + target: activityListView + + onMovementStarted: { + moreActionsButtonContextMenu.close(); + } + } + + Container { + id: moreActionsButtonContextMenuContainer + visible: moreActionsButtonContextMenu.opened + + width: moreActionsButtonContextMenu.width + height: moreActionsButtonContextMenu.height + anchors.right: moreActionsButton.right + anchors.top: moreActionsButton.top + + Menu { + id: moreActionsButtonContextMenu + anchors.centerIn: parent + + // transform model to contain indexed actions with primary action filtered out + function actionListToContextMenuList(actionList) { + // early out with non-altered data + if (activityItem.links.length <= activityListView.maxActionButtons) { + return actionList; + } + + // add index to every action and filter 'primary' action out + var reducedActionList = actionList.reduce(function(reduced, action, index) { + if (!action.primary) { + var actionWithIndex = { actionIndex: index, label: action.label }; + reduced.push(actionWithIndex); + } + return reduced; + }, []); + + + return reducedActionList; + } + + Repeater { + id: moreActionsButtonContextMenuRepeater + + model: moreActionsButtonContextMenu.actionListToContextMenuList(activityItem.links) + + delegate: MenuItem { + id: moreActionsButtonContextMenuEntry + readonly property int actionIndex: model.modelData.actionIndex + readonly property string label: model.modelData.label + text: label + onTriggered: activityModel.triggerAction(activityItem.itemIndex, actionIndex) + } + } + } + } + } + + Button { + id: shareButton + + Layout.preferredWidth: (path === "") ? 0 : parent.height + Layout.preferredHeight: parent.height + Layout.alignment: Qt.AlignRight + flat: true + hoverEnabled: true + visible: (path === "") ? false : true + display: AbstractButton.IconOnly + icon.source: "qrc:///client/theme/share.svg" + icon.color: "transparent" + background: Rectangle { + color: parent.hovered ? Style.lightHover : "transparent" + } + ToolTip.visible: hovered + ToolTip.delay: 1000 + ToolTip.text: qsTr("Open share dialog") + onClicked: Systray.openShareDialog(displayPath,absolutePath) + + Accessible.role: Accessible.Button + Accessible.name: qsTr("Share %1").arg(displayPath) + Accessible.onPressAction: shareButton.clicked() + } } }