diff options
author | alex-z <blackslayer4@gmail.com> | 2022-01-04 17:28:26 +0300 |
---|---|---|
committer | alex-z <blackslayer4@gmail.com> | 2022-02-04 18:52:37 +0300 |
commit | ae44dd59787cd4614db1c11fe1376035345f36da (patch) | |
tree | f40cab03c5eca7410f7429bb5502ed09ab4255d7 | |
parent | 5ce8c9bf50f7a9e8753e0fddf471bbc8eb7d6ccc (diff) |
Adjust icons for activity entries in main dialog. Refactor the dialog by splitting it to separate components.
Signed-off-by: alex-z <blackslayer4@gmail.com>
-rw-r--r-- | resources.qrc | 5 | ||||
-rw-r--r-- | src/gui/tray/ActivityActionButton.qml | 116 | ||||
-rw-r--r-- | src/gui/tray/ActivityItem.qml | 298 | ||||
-rw-r--r-- | src/gui/tray/ActivityItemActions.qml | 103 | ||||
-rw-r--r-- | src/gui/tray/ActivityItemContent.qml | 127 | ||||
-rw-r--r-- | src/gui/tray/ActivityItemContextMenu.qml | 25 | ||||
-rw-r--r-- | src/gui/tray/ActivityList.qml | 17 | ||||
-rw-r--r-- | src/gui/tray/CustomButton.qml | 61 | ||||
-rw-r--r-- | src/gui/tray/CustomTextButton.qml | 49 | ||||
-rw-r--r-- | src/gui/tray/FileActivityDialog.qml | 1 | ||||
-rw-r--r-- | src/gui/tray/Window.qml | 2 | ||||
-rw-r--r-- | src/gui/tray/activitydata.cpp | 11 | ||||
-rw-r--r-- | src/gui/tray/activitydata.h | 12 | ||||
-rw-r--r-- | src/gui/tray/activitylistmodel.cpp | 166 | ||||
-rw-r--r-- | src/gui/tray/activitylistmodel.h | 29 | ||||
-rw-r--r-- | src/gui/tray/notificationhandler.cpp | 9 | ||||
-rw-r--r-- | src/gui/tray/usermodel.cpp | 1 | ||||
-rw-r--r-- | test/testactivitylistmodel.cpp | 360 | ||||
-rw-r--r-- | theme/Style/Style.qml | 6 |
19 files changed, 1011 insertions, 387 deletions
diff --git a/resources.qrc b/resources.qrc index 43b277f77..a931e6716 100644 --- a/resources.qrc +++ b/resources.qrc @@ -23,5 +23,10 @@ <file>src/gui/tray/UnifiedSearchResultListItem.qml</file> <file>src/gui/tray/UnifiedSearchResultNothingFound.qml</file> <file>src/gui/tray/UnifiedSearchResultSectionItem.qml</file> + <file>src/gui/tray/CustomButton.qml</file> + <file>src/gui/tray/CustomTextButton.qml</file> + <file>src/gui/tray/ActivityItemContextMenu.qml</file> + <file>src/gui/tray/ActivityItemActions.qml</file> + <file>src/gui/tray/ActivityItemContent.qml</file> </qresource> </RCC> diff --git a/src/gui/tray/ActivityActionButton.qml b/src/gui/tray/ActivityActionButton.qml index 6cf3d3495..4c64e0bc8 100644 --- a/src/gui/tray/ActivityActionButton.qml +++ b/src/gui/tray/ActivityActionButton.qml @@ -1,109 +1,65 @@ -import QtQuick 2.5 +import QtQuick 2.15 import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.15 import Style 1.0 Item { id: root - readonly property bool labelVisible: label.visible - readonly property bool iconVisible: icon.visible - // label value property string text: "" - - // font value - property var font: label.font + property string toolTipText: "" - // 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 + property bool bold: false - // icon background color - property color iconBgColor: "transparent" - property color iconBgColorHovered: Style.lightHover - - // text border color - property color textBorderColor: "transparent" + property string imageSource: "" + property string imageSourceHover: "" - property alias hovered: mouseArea.containsMouse + property color textColor: Style.unifiedSearchResulTitleColor + property color textColorHovered: Style.unifiedSearchResulSublineColor 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 + Loader { + active: root.imageSource === "" anchors.fill: parent - // padding - anchors.topMargin: 10 - anchors.bottomMargin: 10 - - border.color: parent.textBorderColor - border.width: 1 + sourceComponent: CustomTextButton { + anchors.fill: parent + text: root.text + toolTipText: root.toolTipText - color: parent.hovered ? parent.textBgColorHovered : parent.textBgColor + textColor: root.textColor + textColorHovered: root.textColorHovered - radius: 25 + onClicked: root.clicked() + } } - // background with border around the Image - Rectangle { - visible: parent.iconVisible + Loader { + active: root.imageSource !== "" anchors.fill: parent - color: parent.hovered ? parent.iconBgColorHovered : parent.iconBgColor - } + sourceComponent: CustomButton { + anchors.fill: parent + anchors.topMargin: Style.roundedButtonBackgroundVerticalMargins + anchors.bottomMargin: Style.roundedButtonBackgroundVerticalMargins - // 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 - } + text: root.text + toolTipText: root.toolTipText - // icon - Image { - id: icon - visible: parent.imageSource !== "" - anchors.centerIn: parent - source: parent.imageSource - sourceSize.width: visible ? 32 : 0 - sourceSize.height: visible ? 32 : 0 - } + textColor: root.textColor + textColorHovered: root.textColorHovered - MouseArea { - id: mouseArea - anchors.fill: parent - onClicked: parent.clicked() - hoverEnabled: true - } + bold: root.bold + + imageSource: root.imageSource + imageSourceHover: root.imageSourceHover + + bgColor: Style.ncBlue - ToolTip { - text: parent.tooltipText - delay: 1000 - visible: text != "" && parent.hovered + onClicked: root.clicked() + } } } diff --git a/src/gui/tray/ActivityItem.qml b/src/gui/tray/ActivityItem.qml index 598ae3b76..54c272cfb 100644 --- a/src/gui/tray/ActivityItem.qml +++ b/src/gui/tray/ActivityItem.qml @@ -1,264 +1,84 @@ -import QtQml 2.12 -import QtQuick 2.9 -import QtQuick.Controls 2.2 -import QtQuick.Layouts 1.2 +import QtQml 2.15 +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 import Style 1.0 import com.nextcloud.desktopclient 1.0 MouseArea { - id: activityMouseArea + id: root - readonly property int maxActionButtons: 2 property Flickable flickable + property bool isFileActivityList: false + + property bool isChatActivity: model.objectType === "chat" || model.objectType === "room" + signal fileActivityButtonClicked(string absolutePath) - enabled: (path !== "" || link !== "") + enabled: (model.path !== "" || model.link !== "" || model.isCurrentUserFileActivity === true) hoverEnabled: true + height: childrenRect.height + + ToolTip.visible: containsMouse && !activityContent.childHovered && model.displayLocation !== "" + ToolTip.delay: Qt.styleHints.mousePressAndHoldInterval + ToolTip.text: qsTr("In %1").arg(model.displayLocation) + + Accessible.role: Accessible.ListItem + Accessible.name: (model.path !== "" && model.displayPath !== "") ? qsTr("Open %1 locally").arg(model.displayPath) : model.message + Accessible.onPressAction: root.clicked() + Rectangle { + id: activityHover anchors.fill: parent color: (parent.containsMouse ? Style.lightHover : "transparent") } - ToolTip.visible: containsMouse && displayLocation !== "" - ToolTip.delay: Qt.styleHints.mousePressAndHoldInterval - ToolTip.text: qsTr("In %1").arg(displayLocation) - - RowLayout { - id: activityItem - - readonly property variant links: model.links - - readonly property int itemIndex: model.index - - width: activityMouseArea.width - height: Style.trayWindowHeaderHeight + ColumnLayout { + anchors.left: root.left + anchors.right: root.right + anchors.leftMargin: 15 + anchors.rightMargin: 10 + spacing: 0 - - Accessible.role: Accessible.ListItem - Accessible.name: path !== "" ? qsTr("Open %1 locally").arg(displayPath) - : message - Accessible.onPressAction: activityMouseArea.clicked() - - Image { - id: activityIcon - Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter - Layout.leftMargin: 20 - Layout.preferredWidth: shareButton.icon.width - Layout.preferredHeight: shareButton.icon.height - verticalAlignment: Qt.AlignCenter - cache: true - source: icon - sourceSize.height: 64 - sourceSize.width: 64 - } - - Column { - id: activityTextColumn - Layout.leftMargin: 14 - Layout.topMargin: 4 - Layout.bottomMargin: 4 + + ActivityItemContent { + id: activityContent + Layout.fillWidth: true - spacing: 4 - Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter - - Text { - id: activityTextTitle - text: (type === "Activity" || type === "Notification") ? subject : message - width: parent.width - elide: Text.ElideRight - font.pixelSize: Style.topLinePixelSize - color: activityTextTitleColor - } - - Text { - id: activityTextInfo - text: (type === "Sync") ? displayPath - : (type === "File") ? subject - : (type === "Notification") ? message - : "" - height: (text === "") ? 0 : activityTextTitle.height - width: parent.width - elide: Text.ElideRight - font.pixelSize: Style.subLinePixelSize - } - - Text { - id: activityTextDateTime - text: dateTime - height: (text === "") ? 0 : activityTextTitle.height - width: parent.width - elide: Text.ElideRight - font.pixelSize: Style.subLinePixelSize - color: "#808080" - } + + showDismissButton: model.links.length > 0 && model.linksForActionButtons.length === 0 + + activityData: model + + Layout.preferredHeight: Style.trayWindowHeaderHeight + + onShareButtonClicked: Systray.openShareDialog(model.displayPath, model.absolutePath) + onDismissButtonClicked: activityModel.slotTriggerDismiss(model.index) } - - RowLayout { - id: activityActionsLayout - spacing: 0 - Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter - Layout.minimumWidth: 28 + + ActivityItemActions { + id: activityActions + + visible: !root.isFileActivityList && model.linksForActionButtons.length > 0 + + Layout.preferredHeight: Style.trayWindowHeaderHeight * 0.85 Layout.fillWidth: true - - 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"; - } - - Repeater { - model: activityItem.links.length > maxActionButtons ? 1 : activityItem.links.length - - ActivityActionButton { - id: activityActionButton - - readonly property int actionIndex: model.index - readonly property bool primary: model.index === 0 && String(activityItem.links[actionIndex].verb) !== "DELETE" - - Layout.fillHeight: true - - 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.leftMargin: 40 + Layout.bottomMargin: model.links.length > 1 ? 5 : 0 + + displayActions: model.displayActions + objectType: model.objectType + linksForActionButtons: model.linksForActionButtons + linksContextMenu: model.linksContextMenu + + moreActionsButtonColor: activityHover.color + maxActionButtons: activityModel.maxActionButtons - Button { - id: shareButton - - Layout.preferredWidth: parent.height - Layout.fillHeight: true - Layout.alignment: Qt.AlignRight - flat: true - hoverEnabled: true - visible: isShareable - 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: Qt.styleHints.mousePressAndHoldInterval - 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() - } - - Button { - id: moreActionsButton - - Layout.preferredWidth: parent.height - Layout.preferredHeight: parent.height - Layout.alignment: Qt.AlignRight - - flat: true - hoverEnabled: true - visible: displayActions && ((path !== "") || (activityItem.links.length > 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: Qt.styleHints.mousePressAndHoldInterval - 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: flickable - - function onMovementStarted() { - moreActionsButtonContextMenu.close(); - } - } - - Container { - id: moreActionsButtonContextMenuContainer - visible: moreActionsButtonContextMenu.opened - - width: moreActionsButtonContextMenu.width - height: moreActionsButtonContextMenu.height - anchors.right: moreActionsButton.right - anchors.top: moreActionsButton.top - - AutoSizingMenu { - 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 <= 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; - } + flickable: root.flickable - MenuItem { - text: qsTr("View activity") - onClicked: fileActivityButtonClicked(absolutePath) - } - - Repeater { - id: moreActionsButtonContextMenuRepeater - - model: moreActionsButtonContextMenu.actionListToContextMenuList(activityItem.links) - - delegate: MenuItem { - id: moreActionsButtonContextMenuEntry - text: model.modelData.label - onTriggered: activityModel.triggerAction(activityItem.itemIndex, model.modelData.actionIndex) - } - } - } - } - } + onTriggerAction: activityModel.slotTriggerAction(model.index, actionIndex) } } } diff --git a/src/gui/tray/ActivityItemActions.qml b/src/gui/tray/ActivityItemActions.qml new file mode 100644 index 000000000..42875b9d3 --- /dev/null +++ b/src/gui/tray/ActivityItemActions.qml @@ -0,0 +1,103 @@ +import QtQml 2.15 +import QtQuick 2.15 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.2 +import Style 1.0 + +RowLayout { + id: root + + spacing: 20 + + property string objectType: "" + property variant linksForActionButtons: [] + property variant linksContextMenu: [] + property bool displayActions: false + + property color moreActionsButtonColor: "transparent" + + property int maxActionButtons: 0 + + property Flickable flickable + + signal triggerAction(int actionIndex) + + Repeater { + id: actionsRepeater + // a max of maxActionButtons will get dispayed as separate buttons + model: root.linksForActionButtons + + ActivityActionButton { + id: activityActionButton + + readonly property bool primary: model.index === 0 && model.modelData.verb !== "DELETE" + + Layout.minimumWidth: primary ? Style.activityItemActionPrimaryButtonMinWidth : Style.activityItemActionSecondaryButtonMinWidth + Layout.preferredHeight: primary ? parent.height : parent.height * 0.3 + Layout.preferredWidth: primary ? -1 : parent.height + + text: model.modelData.label + toolTipText: model.modelData.label + + imageSource: model.modelData.imageSource + imageSourceHover: model.modelData.imageSourceHovered + + textColor: imageSource !== "" ? Style.ncBlue : Style.unifiedSearchResulSublineColor + textColorHovered: imageSource !== "" ? Style.lightHover : Style.unifiedSearchResulTitleColor + + bold: primary + + onClicked: root.triggerAction(model.index) + } + } + + Loader { + // actions that do not fit maxActionButtons limit, must be put into a context menu + id: moreActionsButtonContainer + + Layout.preferredWidth: parent.height + Layout.topMargin: Style.roundedButtonBackgroundVerticalMargins + Layout.bottomMargin: Style.roundedButtonBackgroundVerticalMargins + Layout.fillHeight: true + + active: root.displayActions && (root.linksContextMenu.length > 0) + + sourceComponent: Button { + id: moreActionsButton + + icon.source: "qrc:///client/theme/more.svg" + + background: Rectangle { + color: parent.hovered ? "white" : root.moreActionsButtonColor + radius: width / 2 + } + + ToolTip.visible: hovered + ToolTip.delay: Qt.styleHints.mousePressAndHoldInterval + ToolTip.text: qsTr("Show more actions") + + Accessible.name: qsTr("Show more actions") + + onClicked: moreActionsButtonContextMenu.popup(moreActionsButton.x, moreActionsButton.y); + + Connections { + target: root.flickable + + function onMovementStarted() { + moreActionsButtonContextMenu.close(); + } + } + + ActivityItemContextMenu { + id: moreActionsButtonContextMenu + + maxActionButtons: root.maxActionButtons + linksContextMenu: root.linksContextMenu + + onMenuEntryTriggered: function(entryIndex) { + root.triggerAction(entryIndex) + } + } + } + } +} diff --git a/src/gui/tray/ActivityItemContent.qml b/src/gui/tray/ActivityItemContent.qml new file mode 100644 index 000000000..c5057d77d --- /dev/null +++ b/src/gui/tray/ActivityItemContent.qml @@ -0,0 +1,127 @@ +import QtQml 2.15 +import QtQuick 2.15 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.2 +import Style 1.0 +import com.nextcloud.desktopclient 1.0 + +RowLayout { + id: root + + property variant activityData: {{}} + + property color activityTextTitleColor: Style.ncTextColor + + property bool showDismissButton: false + + property bool childHovered: shareButton.hovered || dismissActionButton.hovered + + signal dismissButtonClicked() + signal shareButtonClicked() + + spacing: 10 + + Image { + id: activityIcon + + Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + + verticalAlignment: Qt.AlignCenter + source: icon + sourceSize.height: 64 + sourceSize.width: 64 + } + + Column { + id: activityTextColumn + + Layout.topMargin: 4 + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter + + spacing: 4 + + Label { + id: activityTextTitle + text: (root.activityData.type === "Activity" || root.activityData.type === "Notification") ? root.activityData.subject : root.activityData.message + width: parent.width + elide: Text.ElideRight + font.pixelSize: Style.topLinePixelSize + color: root.activityData.activityTextTitleColor + } + + Label { + id: activityTextInfo + text: (root.activityData.type === "Sync") ? root.activityData.displayPath + : (root.activityData.type === "File") ? root.activityData.subject + : (root.activityData.type === "Notification") ? root.activityData.message + : "" + height: (text === "") ? 0 : activityTextTitle.height + width: parent.width + elide: Text.ElideRight + font.pixelSize: Style.subLinePixelSize + } + + Label { + id: activityTextDateTime + text: root.activityData.dateTime + height: (text === "") ? 0 : activityTextTitle.height + width: parent.width + elide: Text.ElideRight + font.pixelSize: Style.subLinePixelSize + color: "#808080" + } + } + + Button { + id: dismissActionButton + + Layout.preferredWidth: parent.height * 0.40 + Layout.preferredHeight: parent.height * 0.40 + + Layout.alignment: Qt.AlignCenter + + Layout.margins: Style.roundButtonBackgroundVerticalMargins + + ToolTip.visible: hovered + ToolTip.delay: Qt.styleHints.mousePressAndHoldInterval + ToolTip.text: qsTr("Dismiss") + + Accessible.name: qsTr("Dismiss") + + visible: root.showDismissButton && !shareButton.visible + + background: Rectangle { + color: "transparent" + } + + contentItem: Image { + anchors.fill: parent + source: parent.hovered ? "image://svgimage-custom-color/clear.svg/black" : "image://svgimage-custom-color/clear.svg/grey" + sourceSize.width: 24 + sourceSize.height: 24 + } + + onClicked: root.dismissButtonClicked() + } + + CustomButton { + id: shareButton + + Layout.preferredWidth: parent.height * 0.70 + Layout.preferredHeight: parent.height * 0.70 + + visible: root.activityData.isShareable + + imageSource: "image://svgimage-custom-color/share.svg" + "/" + Style.ncBlue + imageSourceHover: "image://svgimage-custom-color/share.svg" + "/" + Style.ncTextColor + + toolTipText: qsTr("Open share dialog") + + bgColor: Style.ncBlue + + onClicked: root.shareButtonClicked() + } +} diff --git a/src/gui/tray/ActivityItemContextMenu.qml b/src/gui/tray/ActivityItemContextMenu.qml new file mode 100644 index 000000000..acf9b4267 --- /dev/null +++ b/src/gui/tray/ActivityItemContextMenu.qml @@ -0,0 +1,25 @@ +import QtQml 2.15 +import QtQuick 2.15 +import QtQuick.Controls 2.3 + +AutoSizingMenu { + id: moreActionsButtonContextMenu + + property int maxActionButtons: 0 + + property var linksContextMenu: [] + + signal menuEntryTriggered(int index) + + Repeater { + id: moreActionsButtonContextMenuRepeater + + model: moreActionsButtonContextMenu.linksContextMenu + + delegate: MenuItem { + id: moreActionsButtonContextMenuEntry + text: model.modelData.label + onTriggered: menuEntryTriggered(model.modelData.actionIndex) + } + } +} diff --git a/src/gui/tray/ActivityList.qml b/src/gui/tray/ActivityList.qml index e1ab4f818..ced4ad2d6 100644 --- a/src/gui/tray/ActivityList.qml +++ b/src/gui/tray/ActivityList.qml @@ -1,14 +1,14 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 -import Style 1.0 - import com.nextcloud.desktopclient 1.0 as NC ScrollView { id: controlRoot property alias model: activityList.model + property bool isFileActivityList: false + signal showFileActivity(string displayPath, string absolutePath) signal activityItemClicked(int index) @@ -31,12 +31,19 @@ ScrollView { clip: true + spacing: 10 + delegate: ActivityItem { + isFileActivityList: controlRoot.isFileActivityList width: activityList.contentWidth - height: Style.trayWindowHeaderHeight flickable: activityList - onClicked: activityItemClicked(model.index) - onFileActivityButtonClicked: showFileActivity(displayPath, absolutePath) + onClicked: { + if (model.isCurrentUserFileActivity) { + showFileActivity(model.displayPath, model.absolutePath) + } else { + activityItemClicked(model.index) + } + } } } } diff --git a/src/gui/tray/CustomButton.qml b/src/gui/tray/CustomButton.qml new file mode 100644 index 000000000..7e73740bd --- /dev/null +++ b/src/gui/tray/CustomButton.qml @@ -0,0 +1,61 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.2 + +Button { + id: root + + property string imageSource: "" + property string imageSourceHover: "" + + property string toolTipText: "" + + property color textColor + property color textColorHovered + + property color bgColor: "transparent" + + property bool bold: false + + background: Rectangle { + color: root.bgColor + opacity: parent.hovered ? 1.0 : 0.3 + radius: width / 2 + } + + leftPadding: root.text === "" ? 5 : 10 + rightPadding: root.text === "" ? 5 : 10 + + contentItem: RowLayout { + Image { + id: icon + + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + + source: root.hovered ? root.imageSourceHover : root.imageSource + } + + Label { + Layout.maximumWidth: icon.width > 0 ? parent.width - icon.width - parent.spacing : parent.width + Layout.fillWidth: icon.status !== Image.Ready + + text: root.text + font.bold: root.bold + + visible: root.text !== "" + + color: root.hovered ? root.textColorHovered : root.textColor + + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + + elide: Text.ElideRight + } + } + + ToolTip { + text: root.toolTipText + delay: Qt.styleHints.mousePressAndHoldInterval + visible: root.toolTipText !== "" && root.hovered + } +} diff --git a/src/gui/tray/CustomTextButton.qml b/src/gui/tray/CustomTextButton.qml new file mode 100644 index 000000000..86323a1c0 --- /dev/null +++ b/src/gui/tray/CustomTextButton.qml @@ -0,0 +1,49 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.3 +import Style 1.0 + +Label { + id: root + + property string toolTipText: "" + property Action action: null + property alias acceptedButtons: mouseArea.acceptedButtons + property bool hovered: mouseArea.containsMouse + + height: implicitHeight + + property color textColor: Style.unifiedSearchResulTitleColor + property color textColorHovered: Style.unifiedSearchResulSublineColor + + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: root.clicked(null) + + text: action ? action.text : "" + enabled: !action || action.enabled + onClicked: if (action) action.trigger() + + font.underline: true + color: root.hovered ? root.textColorHovered : root.textColor + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + + signal pressed(QtObject mouse) + signal clicked(QtObject mouse) + + ToolTip { + text: root.toolTipText + delay: Qt.styleHints.mousePressAndHoldInterval + visible: root.toolTipText !== "" && root.hovered + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + + onClicked: root.clicked(mouse) + onPressed: root.pressed(mouse) + } +} diff --git a/src/gui/tray/FileActivityDialog.qml b/src/gui/tray/FileActivityDialog.qml index 7fcd653d1..50452b0f3 100644 --- a/src/gui/tray/FileActivityDialog.qml +++ b/src/gui/tray/FileActivityDialog.qml @@ -15,6 +15,7 @@ Window { height: 500 ActivityList { + isFileActivityList: true anchors.fill: parent model: dialog.model } diff --git a/src/gui/tray/Window.qml b/src/gui/tray/Window.qml index bf4ba5a9a..262e55920 100644 --- a/src/gui/tray/Window.qml +++ b/src/gui/tray/Window.qml @@ -748,7 +748,7 @@ Window { openFileActivityDialog(displayPath, absolutePath)
}
onActivityItemClicked: {
- model.triggerDefaultAction(index)
+ model.slotTriggerDefaultAction(index)
}
}
diff --git a/src/gui/tray/activitydata.cpp b/src/gui/tray/activitydata.cpp index 866c97956..16f1f1c6d 100644 --- a/src/gui/tray/activitydata.cpp +++ b/src/gui/tray/activitydata.cpp @@ -33,4 +33,15 @@ Activity::Identifier Activity::ident() const { return Identifier(_id, _accName); } + +ActivityLink ActivityLink::createFomJsonObject(const QJsonObject &obj) +{ + ActivityLink activityLink; + activityLink._label = QUrl::fromPercentEncoding(obj.value(QStringLiteral("label")).toString().toUtf8()); + activityLink._link = obj.value(QStringLiteral("link")).toString(); + activityLink._verb = obj.value(QStringLiteral("type")).toString().toUtf8(); + activityLink._primary = obj.value(QStringLiteral("primary")).toBool(); + + return activityLink; +} } diff --git a/src/gui/tray/activitydata.h b/src/gui/tray/activitydata.h index 9b7c2ad98..31114e115 100644 --- a/src/gui/tray/activitydata.h +++ b/src/gui/tray/activitydata.h @@ -17,6 +17,7 @@ #include <QtCore> #include <QIcon> +#include <QJsonObject> namespace OCC { /** @@ -28,13 +29,20 @@ namespace OCC { class ActivityLink { Q_GADGET - + + Q_PROPERTY(QString imageSource MEMBER _imageSource) + Q_PROPERTY(QString imageSourceHovered MEMBER _imageSourceHovered) 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: + static ActivityLink createFomJsonObject(const QJsonObject &obj); + +public: + QString _imageSource; + QString _imageSourceHovered; QString _label; QString _link; QByteArray _verb; @@ -80,11 +88,13 @@ public: QString _message; QString _folder; QString _file; + QString _renamedFile; QUrl _link; QDateTime _dateTime; qint64 _expireAtMsecs = -1; QString _accName; QString _icon; + bool _isCurrentUserFileActivity = false; // Stores information about the error int _status; diff --git a/src/gui/tray/activitylistmodel.cpp b/src/gui/tray/activitylistmodel.cpp index 8120612a5..ef0546f38 100644 --- a/src/gui/tray/activitylistmodel.cpp +++ b/src/gui/tray/activitylistmodel.cpp @@ -54,7 +54,7 @@ ActivityListModel::ActivityListModel(AccountState *accountState, QHash<int, QByteArray> ActivityListModel::roleNames() const { - QHash<int, QByteArray> roles; + auto roles = QAbstractListModel::roleNames(); roles[DisplayPathRole] = "displayPath"; roles[PathRole] = "path"; roles[AbsolutePathRole] = "absolutePath"; @@ -65,11 +65,14 @@ QHash<int, QByteArray> ActivityListModel::roleNames() const roles[ActionIconRole] = "icon"; roles[ActionTextRole] = "subject"; roles[ActionsLinksRole] = "links"; + roles[ActionsLinksContextMenuRole] = "linksContextMenu"; + roles[ActionsLinksForActionButtonsRole] = "linksForActionButtons"; roles[ActionTextColorRole] = "activityTextTitleColor"; roles[ObjectTypeRole] = "objectType"; roles[PointInTimeRole] = "dateTime"; roles[DisplayActions] = "displayActions"; roles[ShareableRole] = "isShareable"; + roles[IsCurrentUserFileActivityRole] = "isCurrentUserFileActivity"; return roles; } @@ -78,6 +81,11 @@ void ActivityListModel::setAccountState(AccountState *state) _accountState = state; } +void ActivityListModel::setCurrentItem(const int currentItem) +{ + _currentItem = currentItem; +} + void ActivityListModel::setCurrentlyFetching(bool value) { _currentlyFetching = value; @@ -116,10 +124,11 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const return QVariant(); const auto getFilePath = [&]() { - if (!a._file.isEmpty()) { + const auto fileName = a._fileAction == QStringLiteral("file_renamed") ? a._renamedFile : a._file; + if (!fileName.isEmpty()) { const auto folder = FolderMan::instance()->folder(a._folder); - const QString relPath = folder ? folder->remotePath() + a._file : a._file; + const QString relPath = folder ? folder->remotePath() + fileName : fileName; const auto localFiles = FolderMan::instance()->findFileInLocalFolders(relPath, ast->account()); @@ -130,7 +139,7 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const // If this is an E2EE file or folder, pretend we got no path, hiding the share button which is what we want if (folder) { SyncJournalFileRecord rec; - folder->journalDb()->getFileRecord(a._file.mid(1), &rec); + folder->journalDb()->getFileRecord(fileName.mid(1), &rec); if (rec.isValid() && (rec._isE2eEncrypted || !rec._e2eMangledName.isEmpty())) { return QString(); } @@ -169,7 +178,7 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const case DisplayPathRole: return getDisplayPath(); case PathRole: - return QUrl::fromLocalFile(QFileInfo(getFilePath()).path()); + return QFileInfo(getFilePath()).path(); case AbsolutePathRole: return getFilePath(); case DisplayLocationRole: @@ -181,6 +190,15 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const } return customList; } + + case ActionsLinksContextMenuRole: { + return ActivityListModel::convertLinksToMenuEntries(a); + } + + case ActionsLinksForActionButtonsRole: { + return ActivityListModel::convertLinksToActionButtons(a); + } + case ActionIconRole: { if (a._type == Activity::NotificationType) { return "qrc:///client/theme/black/bell.svg"; @@ -249,7 +267,7 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const if (a._link.isEmpty()) { return ""; } else { - return a._link; + return a._link.toString(); } } case AccountRole: @@ -262,7 +280,9 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const case DisplayActions: return _displayActions; case ShareableRole: - return !data(index, PathRole).toString().isEmpty() && _displayActions && a._fileAction != "file_deleted" && a._status != SyncFileItem::FileIgnored; + return !data(index, PathRole).toString().isEmpty() && a._objectType == QStringLiteral("files") && _displayActions && a._fileAction != "file_deleted" && a._status != SyncFileItem::FileIgnored; + case IsCurrentUserFileActivityRole: + return a._isCurrentUserFileActivity; default: return QVariant(); } @@ -310,6 +330,21 @@ void ActivityListModel::startFetchJob() job->start(); } +void ActivityListModel::setFinalList(const ActivityList &finalList) +{ + _finalList = finalList; +} + +const ActivityList &ActivityListModel::finalList() const +{ + return _finalList; +} + +int ActivityListModel::currentItem() const +{ + return _currentItem; +} + void ActivityListModel::activitiesReceived(const QJsonDocument &json, int statusCode) { auto activities = json.object().value("ocs").toObject().value("data").toArray(); @@ -333,6 +368,7 @@ void ActivityListModel::activitiesReceived(const QJsonDocument &json, int status auto json = activ.toObject(); Activity a; + const auto activityUser = json.value(QStringLiteral("user")).toString(); a._type = Activity::ActivityType; a._objectType = json.value(QStringLiteral("object_type")).toString(); a._accName = ast->account()->displayName(); @@ -344,6 +380,7 @@ void ActivityListModel::activitiesReceived(const QJsonDocument &json, int status a._link = QUrl(json.value(QStringLiteral("link")).toString()); a._dateTime = QDateTime::fromString(json.value(QStringLiteral("datetime")).toString(), Qt::ISODate); a._icon = json.value(QStringLiteral("icon")).toString(); + a._isCurrentUserFileActivity = a._objectType == QStringLiteral("files") && activityUser == ast->account()->davUser(); auto richSubjectData = json.value(QStringLiteral("subject_rich")).toArray(); @@ -395,9 +432,9 @@ void ActivityListModel::activitiesReceived(const QJsonDocument &json, int status _activityLists.append(list); - emit activityJobStatusCode(statusCode); - combineActivityLists(); + + emit activityJobStatusCode(statusCode); } void ActivityListModel::addErrorToActivityList(Activity activity) @@ -486,7 +523,7 @@ void ActivityListModel::removeActivityFromActivityList(Activity activity) } } -void ActivityListModel::triggerDefaultAction(int activityIndex) +void ActivityListModel::slotTriggerDefaultAction(const int activityIndex) { if (activityIndex < 0 || activityIndex >= _finalList.size()) { qCWarning(lcActivity) << "Couldn't trigger default action at index" << activityIndex << "/ final list size:" << _finalList.size(); @@ -494,7 +531,7 @@ void ActivityListModel::triggerDefaultAction(int activityIndex) } const auto modelIndex = index(activityIndex); - const auto path = data(modelIndex, PathRole).toUrl(); + const auto path = data(modelIndex, PathRole).toString(); const auto activity = _finalList.at(activityIndex); if (activity._status == SyncFileItem::Conflict) { @@ -544,15 +581,15 @@ void ActivityListModel::triggerDefaultAction(int activityIndex) return; } - if (path.isValid()) { - QDesktopServices::openUrl(path); + if (!path.isEmpty()) { + QDesktopServices::openUrl(QUrl::fromLocalFile(path)); } else { const auto link = data(modelIndex, LinkRole).toUrl(); Utility::openBrowser(link); } } -void ActivityListModel::triggerAction(int activityIndex, int actionIndex) +void ActivityListModel::slotTriggerAction(const int activityIndex, const int actionIndex) { if (activityIndex < 0 || activityIndex >= _finalList.size()) { qCWarning(lcActivity) << "Couldn't trigger action on activity at index" << activityIndex << "/ final list size:" << _finalList.size(); @@ -576,11 +613,112 @@ void ActivityListModel::triggerAction(int activityIndex, int actionIndex) emit sendNotificationRequest(activity._accName, action._link, action._verb, activityIndex); } +void ActivityListModel::slotTriggerDismiss(const int activityIndex) +{ + 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 activityLinks = _finalList[activityIndex]._links; + + const auto foundActivityLinkIt = std::find_if(std::cbegin(activityLinks), std::cend(activityLinks), [](const ActivityLink &link) { + return link._verb == QStringLiteral("DELETE"); + }); + + if (foundActivityLinkIt == std::cend(activityLinks)) { + qCWarning(lcActivity) << "Couldn't find dismiss action in activity at index" << activityIndex + << " links.size() " << activityLinks.size(); + return; + } + + const auto actionIndex = static_cast<int>(std::distance(activityLinks.begin(), foundActivityLinkIt)); + + if (actionIndex < 0 || actionIndex > activityLinks.size()) { + qCWarning(lcActivity) << "Couldn't find dismiss action in activity at index" << activityIndex + << " actionIndex found " << actionIndex; + return; + } + + slotTriggerAction(activityIndex, actionIndex); +} + AccountState *ActivityListModel::accountState() const { return _accountState; } +QVariantList ActivityListModel::convertLinksToActionButtons(const Activity &activity) +{ + QVariantList customList; + + if (activity._links.size() == 1) { + return customList; + } + + if (static_cast<quint32>(activity._links.size()) > maxActionButtons()) { + customList << ActivityListModel::convertLinkToActionButton(activity, activity._links.first()); + return customList; + } + + for (const auto &activityLink : activity._links) { + if (activityLink._verb == QStringLiteral("DELETE") + || (activity._objectType == QStringLiteral("chat") || activity._objectType == QStringLiteral("call") + || activity._objectType == QStringLiteral("room"))) { + customList << ActivityListModel::convertLinkToActionButton(activity, activityLink); + } + } + + return customList; +} + +QVariant ActivityListModel::convertLinkToActionButton(const OCC::Activity &activity, const OCC::ActivityLink &activityLink) +{ + auto activityLinkCopy = activityLink; + + const auto isReplyIconApplicable = activityLink._verb == QStringLiteral("WEB") + && (activity._objectType == QStringLiteral("chat") || activity._objectType == QStringLiteral("call") + || activity._objectType == QStringLiteral("room")); + + const QString replyButtonPath = QStringLiteral("image://svgimage-custom-color/reply.svg"); + + if (isReplyIconApplicable) { + activityLinkCopy._imageSource = + QString(replyButtonPath + "/" + OCC::Theme::instance()->wizardHeaderBackgroundColor().name()); + activityLinkCopy._imageSourceHovered = + QString(replyButtonPath + "/" + OCC::Theme::instance()->wizardHeaderTitleColor().name()); + } + + const auto isReplyLabelApplicable = activityLink._verb == QStringLiteral("WEB") + && (activity._objectType == QStringLiteral("chat") + || (activity._objectType != QStringLiteral("room") && activity._objectType != QStringLiteral("call"))); + + if (activityLink._verb == QStringLiteral("DELETE")) { + activityLinkCopy._label = QObject::tr("Mark as read"); + } else if (isReplyLabelApplicable) { + activityLinkCopy._label = QObject::tr("Reply"); + } + + return QVariant::fromValue(activityLinkCopy); +} + +QVariantList ActivityListModel::convertLinksToMenuEntries(const Activity &activity) +{ + QVariantList customList; + + if (static_cast<quint32>(activity._links.size()) > maxActionButtons()) { + for (int i = 0; i < activity._links.size(); ++i) { + const auto &activityLink = activity._links[i]; + if (!activityLink._primary) { + customList << QVariantMap{ + {QStringLiteral("actionIndex"), i}, {QStringLiteral("label"), activityLink._label}}; + } + } + } + + return customList; +} + void ActivityListModel::combineActivityLists() { ActivityList resultList; diff --git a/src/gui/tray/activitylistmodel.h b/src/gui/tray/activitylistmodel.h index 66de2e498..34c591c2f 100644 --- a/src/gui/tray/activitylistmodel.h +++ b/src/gui/tray/activitylistmodel.h @@ -40,6 +40,8 @@ class ActivityListModel : public QAbstractListModel { Q_OBJECT + Q_PROPERTY(quint32 maxActionButtons READ maxActionButtons CONSTANT) + Q_PROPERTY(AccountState *accountState READ accountState CONSTANT) public: enum DataRole { @@ -47,6 +49,8 @@ public: AccountRole, ObjectTypeRole, ActionsLinksRole, + ActionsLinksContextMenuRole, + ActionsLinksForActionButtonsRole, ActionTextRole, ActionTextColorRole, ActionRole, @@ -60,6 +64,7 @@ public: AccountConnectedRole, DisplayActions, ShareableRole, + IsCurrentUserFileActivityRole, }; Q_ENUM(DataRole) @@ -84,15 +89,22 @@ public: void removeActivityFromActivityList(int row); void removeActivityFromActivityList(Activity activity); - Q_INVOKABLE void triggerDefaultAction(int activityIndex); - Q_INVOKABLE void triggerAction(int activityIndex, int actionIndex); - AccountState *accountState() const; void setAccountState(AccountState *state); + static constexpr quint32 maxActionButtons() + { + return MaxActionButtons; + } + + void setCurrentItem(const int currentItem); + public slots: void slotRefreshActivity(); void slotRemoveAccount(); + void slotTriggerDefaultAction(const int activityIndex); + void slotTriggerAction(const int activityIndex, const int actionIndex); + void slotTriggerDismiss(const int activityIndex); signals: void activityJobStatusCode(int statusCode); @@ -110,7 +122,16 @@ protected: virtual void startFetchJob(); + // added these for unit tests + void setFinalList(const ActivityList &finalList); + const ActivityList &finalList() const; + int currentItem() const; + // + private: + static QVariantList convertLinksToMenuEntries(const Activity &activity); + static QVariantList convertLinksToActionButtons(const Activity &activity); + static QVariant convertLinkToActionButton(const Activity &activity, const ActivityLink &activityLink); void combineActivityLists(); bool canFetchActivities() const; @@ -137,6 +158,8 @@ private: bool _currentlyFetching = false; bool _doneFetching = false; bool _hideOldActivities = true; + + static constexpr quint32 MaxActionButtons = 2; }; } diff --git a/src/gui/tray/notificationhandler.cpp b/src/gui/tray/notificationhandler.cpp index ccac7b1de..9acd325b9 100644 --- a/src/gui/tray/notificationhandler.cpp +++ b/src/gui/tray/notificationhandler.cpp @@ -121,14 +121,7 @@ void ServerNotificationHandler::slotNotificationsReceived(const QJsonDocument &j auto actions = json.value("actions").toArray(); foreach (auto action, actions) { - auto actionJson = action.toObject(); - ActivityLink al; - al._label = QUrl::fromPercentEncoding(actionJson.value("label").toString().toUtf8()); - al._link = actionJson.value("link").toString(); - al._verb = actionJson.value("type").toString().toUtf8(); - al._primary = actionJson.value("primary").toBool(); - - a._links.append(al); + a._links.append(ActivityLink::createFomJsonObject(action.toObject())); } // Add another action to dismiss notification on server diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index a3200df3b..44e9f1a0a 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -514,6 +514,7 @@ void User::processCompletedSyncItem(const Folder *folder, const SyncFileItemPtr activity._fileAction = "file_created"; } else if (item->_instruction == CSYNC_INSTRUCTION_RENAME) { activity._fileAction = "file_renamed"; + activity._renamedFile = item->_renameTarget; } else { activity._fileAction = "file_changed"; } diff --git a/test/testactivitylistmodel.cpp b/test/testactivitylistmodel.cpp index 8faf21d64..64158822f 100644 --- a/test/testactivitylistmodel.cpp +++ b/test/testactivitylistmodel.cpp @@ -25,7 +25,9 @@ #include <QSignalSpy> #include <QTest> +namespace { constexpr auto startingId = 90000; +} static QByteArray fake404Response = R"( {"ocs":{"meta":{"status":"failure","statuscode":404,"message":"Invalid query, please check the syntax. API specifications are here: http:\/\/www.freedesktop.org\/wiki\/Specifications\/open-collaboration-services.\n"},"data":[]}} @@ -39,28 +41,6 @@ static QByteArray fake500Response = R"( {"ocs":{"meta":{"status":"failure","statuscode":500,"message":"Internal Server Error.\n"},"data":[]}} )"; -class TestingALM : public OCC::ActivityListModel -{ - Q_OBJECT - -public: - TestingALM() = default; - - void startFetchJob() override - { - auto *job = new OCC::JsonApiJob(accountState()->account(), QLatin1String("ocs/v2.php/apps/activity/api/v2/activity"), this); - QObject::connect(job, &OCC::JsonApiJob::jsonReceived, - this, &TestingALM::activitiesReceived); - - QUrlQuery params; - params.addQueryItem(QLatin1String("since"), QString::number(startingId)); - params.addQueryItem(QLatin1String("limit"), QString::number(50)); - job->addQueryParams(params); - - job->start(); - }; -}; - class FakeRemoteActivityStorage { FakeRemoteActivityStorage() = default; @@ -101,8 +81,6 @@ public: { // Insert activity data for (quint32 i = 0; i <= _numItemsToInsert; i++) { - _startingId++; - QJsonObject activity; activity.insert(QStringLiteral("object_type"), "files"); activity.insert(QStringLiteral("activity_id"), _startingId); @@ -114,35 +92,184 @@ public: activity.insert(QStringLiteral("icon"), QStringLiteral("http://example.de/apps/files/img/add-color.svg")); _activityData.push_back(activity); + + _startingId++; } // Insert notification data for (quint32 i = 0; i < _numItemsToInsert; i++) { - _startingId++; QJsonObject activity; activity.insert(QStringLiteral("activity_id"), _startingId); activity.insert(QStringLiteral("object_type"), "calendar"); activity.insert(QStringLiteral("type"), QStringLiteral("calendar-event")); - activity.insert(QStringLiteral("subject"), QStringLiteral("You created event %1 in calendar Events").arg(i)); + activity.insert( + QStringLiteral("subject"), QStringLiteral("You created event %1 in calendar Events").arg(i)); activity.insert(QStringLiteral("message"), QStringLiteral("")); activity.insert(QStringLiteral("object_name"), QStringLiteral("")); activity.insert(QStringLiteral("datetime"), QDateTime::currentDateTime().toString(Qt::ISODate)); activity.insert(QStringLiteral("icon"), QStringLiteral("http://example.de/core/img/places/calendar.svg")); + QJsonArray actionsArray; + + QJsonObject secondaryAction; + secondaryAction.insert(QStringLiteral("label"), QStringLiteral("Dismiss")); + secondaryAction.insert(QStringLiteral("link"), + QString(QStringLiteral("http://cloud.example.de/remote.php/dav") + + QStringLiteral("ocs/v2.php/apps/notifications/api/v2/notifications") + QString::number(i))); + secondaryAction.insert(QStringLiteral("type"), QStringLiteral("DELETE")); + secondaryAction.insert(QStringLiteral("primary"), false); + actionsArray.push_back(secondaryAction); + + _activityData.push_back(activity); + + _startingId++; + } + + // Insert notification data + for (quint32 i = 0; i < _numItemsToInsert; i++) { + QJsonObject activity; + activity.insert(QStringLiteral("activity_id"), _startingId); + activity.insert(QStringLiteral("object_type"), "chat"); + activity.insert(QStringLiteral("type"), QStringLiteral("chat")); + activity.insert(QStringLiteral("subject"), QStringLiteral("You have received %1's message").arg(i)); + activity.insert(QStringLiteral("message"), QStringLiteral("")); + activity.insert(QStringLiteral("object_name"), QStringLiteral("")); + activity.insert(QStringLiteral("datetime"), QDateTime::currentDateTime().toString(Qt::ISODate)); + activity.insert(QStringLiteral("icon"), QStringLiteral("http://example.de/core/img/places/talk.svg")); + + QJsonArray actionsArray; + + QJsonObject primaryAction; + primaryAction.insert(QStringLiteral("label"), QStringLiteral("View chat")); + primaryAction.insert(QStringLiteral("link"), QStringLiteral("http://cloud.example.de/call/9p4vjdzd")); + primaryAction.insert(QStringLiteral("type"), QStringLiteral("WEB")); + primaryAction.insert(QStringLiteral("primary"), true); + actionsArray.push_back(primaryAction); + + QJsonObject secondaryAction; + secondaryAction.insert(QStringLiteral("label"), QStringLiteral("Dismiss")); + secondaryAction.insert(QStringLiteral("link"), + QString(QStringLiteral("http://cloud.example.de/remote.php/dav") + + QStringLiteral("ocs/v2.php/apps/notifications/api/v2/notifications") + QString::number(i))); + secondaryAction.insert(QStringLiteral("type"), QStringLiteral("DELETE")); + secondaryAction.insert(QStringLiteral("primary"), false); + actionsArray.push_back(secondaryAction); + + QJsonObject additionalAction; + additionalAction.insert(QStringLiteral("label"), QStringLiteral("Additional 1")); + additionalAction.insert(QStringLiteral("link"), QStringLiteral("http://cloud.example.de/call/9p4vjdzd")); + additionalAction.insert(QStringLiteral("type"), QStringLiteral("POST")); + additionalAction.insert(QStringLiteral("primary"), false); + actionsArray.push_back(additionalAction); + additionalAction.insert(QStringLiteral("label"), QStringLiteral("Additional 2")); + actionsArray.push_back(additionalAction); + + activity.insert(QStringLiteral("actions"), actionsArray); + + _activityData.push_back(activity); + + _startingId++; + } + + // Insert notification data + for (quint32 i = 0; i < _numItemsToInsert; i++) { + QJsonObject activity; + activity.insert(QStringLiteral("activity_id"), _startingId); + activity.insert(QStringLiteral("object_type"), "room"); + activity.insert(QStringLiteral("type"), QStringLiteral("room")); + activity.insert(QStringLiteral("subject"), QStringLiteral("You have been invited into room%1").arg(i)); + activity.insert(QStringLiteral("message"), QStringLiteral("")); + activity.insert(QStringLiteral("object_name"), QStringLiteral("")); + activity.insert(QStringLiteral("datetime"), QDateTime::currentDateTime().toString(Qt::ISODate)); + activity.insert(QStringLiteral("icon"), QStringLiteral("http://example.de/core/img/places/talk.svg")); + + QJsonArray actionsArray; + + QJsonObject primaryAction; + primaryAction.insert(QStringLiteral("label"), QStringLiteral("View chat")); + primaryAction.insert(QStringLiteral("link"), QStringLiteral("http://cloud.example.de/call/9p4vjdzd")); + primaryAction.insert(QStringLiteral("type"), QStringLiteral("WEB")); + primaryAction.insert(QStringLiteral("primary"), true); + actionsArray.push_back(primaryAction); + + QJsonObject secondaryAction; + secondaryAction.insert(QStringLiteral("label"), QStringLiteral("Dismiss")); + secondaryAction.insert(QStringLiteral("link"), + QString(QStringLiteral("http://cloud.example.de/remote.php/dav") + + QStringLiteral("ocs/v2.php/apps/notifications/api/v2/notifications") + QString::number(i))); + secondaryAction.insert(QStringLiteral("type"), QStringLiteral("DELETE")); + secondaryAction.insert(QStringLiteral("primary"), false); + actionsArray.push_back(secondaryAction); + + activity.insert(QStringLiteral("actions"), actionsArray); + _activityData.push_back(activity); + + _startingId++; } + + // Insert notification data + for (quint32 i = 0; i < _numItemsToInsert; i++) { + QJsonObject activity; + activity.insert(QStringLiteral("activity_id"), _startingId); + activity.insert(QStringLiteral("object_type"), "call"); + activity.insert(QStringLiteral("type"), QStringLiteral("call")); + activity.insert(QStringLiteral("subject"), QStringLiteral("You have missed a %1's call").arg(i)); + activity.insert(QStringLiteral("message"), QStringLiteral("")); + activity.insert(QStringLiteral("object_name"), QStringLiteral("")); + activity.insert(QStringLiteral("datetime"), QDateTime::currentDateTime().toString(Qt::ISODate)); + activity.insert(QStringLiteral("icon"), QStringLiteral("http://example.de/core/img/places/talk.svg")); + + QJsonArray actionsArray; + + QJsonObject primaryAction; + primaryAction.insert(QStringLiteral("label"), QStringLiteral("Call back")); + primaryAction.insert(QStringLiteral("link"), QStringLiteral("http://cloud.example.de/call/9p4vjdzd")); + primaryAction.insert(QStringLiteral("type"), QStringLiteral("WEB")); + primaryAction.insert(QStringLiteral("primary"), true); + actionsArray.push_back(primaryAction); + + QJsonObject secondaryAction; + secondaryAction.insert(QStringLiteral("label"), QStringLiteral("Dismiss")); + secondaryAction.insert(QStringLiteral("link"), + QString(QStringLiteral("http://cloud.example.de/remote.php/dav") + + QStringLiteral("ocs/v2.php/apps/notifications/api/v2/notifications") + QString::number(i))); + secondaryAction.insert(QStringLiteral("type"), QStringLiteral("DELETE")); + secondaryAction.insert(QStringLiteral("primary"), false); + actionsArray.push_back(secondaryAction); + + activity.insert(QStringLiteral("actions"), actionsArray); + + _activityData.push_back(activity); + + _startingId++; + } + + _startingId--; } const QByteArray activityJsonData(int sinceId, int limit) { QJsonArray data; - for(int dataIndex = _activityData.size() - 1, iteration = 0; - dataIndex > 0 && iteration < limit; - --dataIndex, ++iteration) { - - if(_activityData[dataIndex].toObject().value(QStringLiteral("activity_id")).toInt() > sinceId) { - data.append(_activityData[dataIndex]); + const auto itFound = std::find_if( + std::cbegin(_activityData), std::cend(_activityData), [&sinceId](const QJsonValue ¤tActivityValue) { + const auto currentActivityId = + currentActivityValue.toObject().value(QStringLiteral("activity_id")).toInt(); + return currentActivityId == sinceId; + }); + + const int startIndex = itFound != std::cend(_activityData) + ? static_cast<int>(std::distance(std::cbegin(_activityData), itFound)) + : -1; + + if (startIndex > 0) { + for (int dataIndex = startIndex, iteration = 0; dataIndex >= 0 && iteration < limit; + --dataIndex, ++iteration) { + if (_activityData[dataIndex].toObject().value(QStringLiteral("activity_id")).toInt() + > sinceId - limit) { + data.append(_activityData[dataIndex]); + } } } @@ -154,6 +281,24 @@ public: return QJsonDocument(root).toJson(); } + QJsonValue activityById(int id) + { + const auto itFound = std::find_if( + std::cbegin(_activityData), std::cend(_activityData), [&id](const QJsonValue ¤tActivityValue) { + const auto currentActivityId = + currentActivityValue.toObject().value(QStringLiteral("activity_id")).toInt(); + return currentActivityId == id; + }); + + if (itFound != std::cend(_activityData)) { + return (*itFound); + } + + return {}; + } + + int startingIdLast() const { return _startingId; } + private: static FakeRemoteActivityStorage *_instance; QJsonArray _activityData; @@ -164,6 +309,64 @@ private: FakeRemoteActivityStorage *FakeRemoteActivityStorage::_instance = nullptr; +class TestingALM : public OCC::ActivityListModel +{ + Q_OBJECT + +public: + TestingALM() = default; + + void startFetchJob() override + { + auto *job = new OCC::JsonApiJob( + accountState()->account(), QLatin1String("ocs/v2.php/apps/activity/api/v2/activity"), this); + QObject::connect(this, &TestingALM::activityJobStatusCode, this, &TestingALM::slotProcessReceivedActivities); + QObject::connect(job, &OCC::JsonApiJob::jsonReceived, this, &TestingALM::activitiesReceived); + + QUrlQuery params; + params.addQueryItem(QLatin1String("since"), QString::number(currentItem())); + params.addQueryItem(QLatin1String("limit"), QString::number(50)); + job->addQueryParams(params); + + job->start(); + } + +public slots: + void slotProcessReceivedActivities() + { + if (rowCount() > _numRowsPrev) { + auto finalListCopy = finalList(); + for (int i = _numRowsPrev; i < rowCount(); ++i) { + const auto modelIndex = index(i, 0); + auto activity = finalListCopy.at(modelIndex.row()); + if (activity._links.isEmpty()) { + const auto activityJsonObject = FakeRemoteActivityStorage::instance()->activityById(activity._id); + + if (!activityJsonObject.isNull()) { + // because "_links" are normally populated within the notificationhandler.cpp, which we don't run as part of this unit test, we have to fill them here + // TODO: move the logic to populate "_links" to "activitylistmodel.cpp" + auto actions = activityJsonObject.toObject().value("actions").toArray(); + foreach (auto action, actions) { + activity._links.append(OCC::ActivityLink::createFomJsonObject(action.toObject())); + } + + finalListCopy[modelIndex.row()] = activity; + } + } + } + + setFinalList(finalListCopy); + } + _numRowsPrev = rowCount(); + emit activitiesProcessed(); + } +signals: + void activitiesProcessed(); + +private: + int _numRowsPrev = 0; +}; + class TestActivityListModel : public QObject { Q_OBJECT @@ -238,8 +441,9 @@ private slots: QCOMPARE(model.rowCount(), 0); + model.setCurrentItem(FakeRemoteActivityStorage::instance()->startingIdLast()); model.startFetchJob(); - QSignalSpy activitiesJob(&model, &TestingALM::activityJobStatusCode); + QSignalSpy activitiesJob(&model, &TestingALM::activitiesProcessed); QVERIFY(activitiesJob.wait(3000)); QCOMPARE(model.rowCount(), 50); }; @@ -348,8 +552,9 @@ private slots: QCOMPARE(model.rowCount(), 0); + model.setCurrentItem(FakeRemoteActivityStorage::instance()->startingIdLast()); model.startFetchJob(); - QSignalSpy activitiesJob(&model, &TestingALM::activityJobStatusCode); + QSignalSpy activitiesJob(&model, &TestingALM::activitiesProcessed); QVERIFY(activitiesJob.wait(3000)); QCOMPARE(model.rowCount(), 50); @@ -385,8 +590,10 @@ private slots: for (int i = 0; i < model.rowCount(); i++) { const auto index = model.index(i, 0); - QVERIFY(index.data(OCC::ActivityListModel::ObjectTypeRole).canConvert<int>()); - const auto type = index.data(OCC::ActivityListModel::ObjectTypeRole).toInt(); + auto text = index.data(OCC::ActivityListModel::ActionTextRole).toString(); + + QVERIFY(index.data(OCC::ActivityListModel::ActionRole).canConvert<int>()); + const auto type = index.data(OCC::ActivityListModel::ActionRole).toInt(); QVERIFY(type >= OCC::Activity::ActivityType); QVERIFY(!index.data(OCC::ActivityListModel::ObjectTypeRole).toInt()); @@ -407,6 +614,87 @@ private slots: } }; + void tesActivityActionstData() + { + TestingALM model; + model.setAccountState(accountState.data()); + QAbstractItemModelTester modelTester(&model); + + QCOMPARE(model.rowCount(), 0); + model.setCurrentItem(FakeRemoteActivityStorage::instance()->startingIdLast()); + + int prevModelRowCount = model.rowCount(); + + do { + prevModelRowCount = model.rowCount(); + model.startFetchJob(); + QSignalSpy activitiesJob(&model, &TestingALM::activitiesProcessed); + QVERIFY(activitiesJob.wait(3000)); + + + for (int i = prevModelRowCount; i < model.rowCount(); i++) { + const auto index = model.index(i, 0); + + const auto actionsLinks = index.data(OCC::ActivityListModel::ActionsLinksRole).toList(); + if (!actionsLinks.isEmpty()) { + const auto actionsLinksContextMenu = + index.data(OCC::ActivityListModel::ActionsLinksContextMenuRole).toList(); + + // context menu must be shorter than total action links + QVERIFY(actionsLinks.isEmpty() || actionsLinksContextMenu.size() < actionsLinks.size()); + + // context menu must not contain the primary action + QVERIFY(std::find_if(std::begin(actionsLinksContextMenu), std::end(actionsLinksContextMenu), + [](const QVariant &entry) { return entry.value<OCC::ActivityLink>()._primary; }) + == std::end(actionsLinksContextMenu)); + + const auto objectType = index.data(OCC::ActivityListModel::ObjectTypeRole).toString(); + + if ((objectType == QStringLiteral("chat") || objectType == QStringLiteral("call") + || objectType == QStringLiteral("room"))) { + const auto actionButtonsLinks = + index.data(OCC::ActivityListModel::ActionsLinksForActionButtonsRole).toList(); + + // both action links and buttons must contain a "WEB" verb element at the beginning + QVERIFY(actionsLinks[0].value<OCC::ActivityLink>()._verb == QStringLiteral("WEB")); + QVERIFY(actionButtonsLinks[0].value<OCC::ActivityLink>()._verb == QStringLiteral("WEB")); + + // the first action button for chat must have image set + QVERIFY(!actionButtonsLinks[0].value<OCC::ActivityLink>()._imageSource.isEmpty()); + QVERIFY(!actionButtonsLinks[0].value<OCC::ActivityLink>()._imageSourceHovered.isEmpty()); + + // logic for "chat" and other types of activities with multiple actions + if ((objectType == QStringLiteral("chat") + || (objectType != QStringLiteral("room") && objectType != QStringLiteral("call")))) { + + // button's label for "chat" must be renamed to "Reply" + QVERIFY(actionButtonsLinks[0].value<OCC::ActivityLink>()._label == QObject::tr("Reply")); + + if (static_cast<quint32>(actionsLinks.size()) > OCC::ActivityListModel::maxActionButtons()) { + // in case total actions is longer than ActivityListModel::maxActionButtons, only one button must be present in a list of action buttons + QVERIFY(actionButtonsLinks.size() == 1); + const auto actionButtonsAndContextMenuEntries = actionButtonsLinks + actionsLinksContextMenu; + // in case total actions is longer than ActivityListModel::maxActionButtons, then a sum of action buttons and action menu entries must be equal to a total of action links + QVERIFY(actionButtonsLinks.size() + actionsLinksContextMenu.size() == actionsLinks.size()); + } else { + // in case a total of actions is less or equal to than ActivityListModel::maxActionButtons, then the length of action buttons must be greater than 1 and should contain "Mark as read" button at the end + QVERIFY(actionButtonsLinks.size() > 1); + QVERIFY(actionButtonsLinks[1].value<OCC::ActivityLink>()._label + == QObject::tr("Mark as read")); + } + } else if ((objectType == QStringLiteral("call"))) { + QVERIFY( + actionButtonsLinks[0].value<OCC::ActivityLink>()._label == QStringLiteral("Call back")); + } + } else { + QVERIFY(actionsLinks[0].value<OCC::ActivityLink>()._label == QStringLiteral("Dismiss")); + } + } + } + + } while (prevModelRowCount < model.rowCount()); + }; + }; QTEST_MAIN(TestActivityListModel) diff --git a/theme/Style/Style.qml b/theme/Style/Style.qml index 421132b55..1425de189 100644 --- a/theme/Style/Style.qml +++ b/theme/Style/Style.qml @@ -50,6 +50,12 @@ QtObject { property int headerButtonIconSize: 32
property int activityLabelBaseWidth: 240
+
+ property int activityItemActionPrimaryButtonMinWidth: 100
+ property int activityItemActionSecondaryButtonMinWidth: 80
+
+ property int roundButtonBackgroundVerticalMargins: 10
+ property int roundedButtonBackgroundVerticalMargins: 5
property int userStatusEmojiSize: 8
property int userStatusSpacing: 6
|