From c1dab7e4cb254bf492b458c8c0c1bab8f55b17b0 Mon Sep 17 00:00:00 2001 From: alex-z Date: Thu, 9 Sep 2021 14:33:57 +0300 Subject: [PATCH 1/2] Unified Search via Tray window Signed-off-by: alex-z --- resources.qrc | 8 + src/gui/CMakeLists.txt | 11 + src/gui/ErrorBox.qml | 12 +- src/gui/main.cpp | 4 + src/gui/systray.cpp | 2 + src/gui/tray/UnifiedSearchInputContainer.qml | 110 +++ .../UnifiedSearchResultFetchMoreTrigger.qml | 42 ++ src/gui/tray/UnifiedSearchResultItem.qml | 107 +++ .../tray/UnifiedSearchResultItemSkeleton.qml | 58 ++ ...ifiedSearchResultItemSkeletonContainer.qml | 49 ++ src/gui/tray/UnifiedSearchResultListItem.qml | 87 +++ .../tray/UnifiedSearchResultNothingFound.qml | 47 ++ .../tray/UnifiedSearchResultSectionItem.qml | 20 + src/gui/tray/UserModel.cpp | 9 +- src/gui/tray/UserModel.h | 4 + src/gui/tray/Window.qml | 176 ++++- src/gui/tray/unifiedsearchresult.cpp | 36 + src/gui/tray/unifiedsearchresult.h | 48 ++ .../tray/unifiedsearchresultimageprovider.cpp | 131 ++++ .../tray/unifiedsearchresultimageprovider.h | 33 + .../tray/unifiedsearchresultslistmodel.cpp | 708 ++++++++++++++++++ src/gui/tray/unifiedsearchresultslistmodel.h | 129 ++++ src/libsync/theme.cpp | 15 + src/libsync/theme.h | 13 + test/CMakeLists.txt | 1 + test/syncenginetestutils.cpp | 10 +- test/syncenginetestutils.h | 5 + test/testunifiedsearchlistmodel.cpp | 640 ++++++++++++++++ theme.qrc.in | 8 + theme/Style/Style.qml | 16 + theme/black/calendar.svg | 1 + theme/black/clear.svg | 1 + theme/black/comment.svg | 1 + theme/black/deck.svg | 8 + theme/black/search.svg | 1 + 35 files changed, 2528 insertions(+), 23 deletions(-) create mode 100644 src/gui/tray/UnifiedSearchInputContainer.qml create mode 100644 src/gui/tray/UnifiedSearchResultFetchMoreTrigger.qml create mode 100644 src/gui/tray/UnifiedSearchResultItem.qml create mode 100644 src/gui/tray/UnifiedSearchResultItemSkeleton.qml create mode 100644 src/gui/tray/UnifiedSearchResultItemSkeletonContainer.qml create mode 100644 src/gui/tray/UnifiedSearchResultListItem.qml create mode 100644 src/gui/tray/UnifiedSearchResultNothingFound.qml create mode 100644 src/gui/tray/UnifiedSearchResultSectionItem.qml create mode 100644 src/gui/tray/unifiedsearchresult.cpp create mode 100644 src/gui/tray/unifiedsearchresult.h create mode 100644 src/gui/tray/unifiedsearchresultimageprovider.cpp create mode 100644 src/gui/tray/unifiedsearchresultimageprovider.h create mode 100644 src/gui/tray/unifiedsearchresultslistmodel.cpp create mode 100644 src/gui/tray/unifiedsearchresultslistmodel.h create mode 100644 test/testunifiedsearchlistmodel.cpp create mode 100644 theme/black/calendar.svg create mode 100644 theme/black/clear.svg create mode 100644 theme/black/comment.svg create mode 100644 theme/black/deck.svg create mode 100644 theme/black/search.svg diff --git a/resources.qrc b/resources.qrc index 134c11cc91..43b277f77a 100644 --- a/resources.qrc +++ b/resources.qrc @@ -15,5 +15,13 @@ src/gui/tray/AutoSizingMenu.qml src/gui/tray/ActivityList.qml src/gui/tray/FileActivityDialog.qml + src/gui/tray/UnifiedSearchInputContainer.qml + src/gui/tray/UnifiedSearchResultFetchMoreTrigger.qml + src/gui/tray/UnifiedSearchResultItem.qml + src/gui/tray/UnifiedSearchResultItemSkeleton.qml + src/gui/tray/UnifiedSearchResultItemSkeletonContainer.qml + src/gui/tray/UnifiedSearchResultListItem.qml + src/gui/tray/UnifiedSearchResultNothingFound.qml + src/gui/tray/UnifiedSearchResultSectionItem.qml diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index bbc6663749..3c2d790eaf 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -43,6 +43,14 @@ set(client_UI_SRCS tray/ActivityList.qml tray/Window.qml tray/UserLine.qml + tray/UnifiedSearchInputContainer.qml + tray/UnifiedSearchResultFetchMoreTrigger.qml + tray/UnifiedSearchResultItem.qml + tray/UnifiedSearchResultItemSkeleton.qml + tray/UnifiedSearchResultItemSkeletonContainer.qml + tray/UnifiedSearchResultListItem.qml + tray/UnifiedSearchResultNothingFound.qml + tray/UnifiedSearchResultSectionItem.qml wizard/flow2authwidget.ui wizard/owncloudadvancedsetuppage.ui wizard/owncloudconnectionmethoddialog.ui @@ -116,6 +124,9 @@ set(client_SRCS tray/syncstatussummary.cpp tray/ActivityData.cpp tray/ActivityListModel.cpp + tray/unifiedsearchresult.cpp + tray/unifiedsearchresultimageprovider.cpp + tray/unifiedsearchresultslistmodel.cpp tray/UserModel.cpp tray/NotificationHandler.cpp tray/NotificationCache.cpp diff --git a/src/gui/ErrorBox.qml b/src/gui/ErrorBox.qml index 5b0d102ad7..8ef5d053c8 100644 --- a/src/gui/ErrorBox.qml +++ b/src/gui/ErrorBox.qml @@ -1,16 +1,22 @@ import QtQuick 2.15 +import Style 1.0 + Item { id: errorBox property var text: "" + + property color color: Style.errorBoxTextColor + property color backgroundColor: Style.errorBoxBackgroundColor + property color borderColor: Style.errorBoxBorderColor implicitHeight: errorMessage.implicitHeight + 2 * 8 Rectangle { anchors.fill: parent - color: "red" - border.color: "black" + color: errorBox.backgroundColor + border.color: errorBox.borderColor } Text { @@ -19,7 +25,7 @@ Item { anchors.fill: parent anchors.margins: 8 width: parent.width - color: "white" + color: errorBox.color wrapMode: Text.WordWrap text: errorBox.text } diff --git a/src/gui/main.cpp b/src/gui/main.cpp index df7279308c..bf78b4c076 100644 --- a/src/gui/main.cpp +++ b/src/gui/main.cpp @@ -31,6 +31,7 @@ #include "userstatusselectormodel.h" #include "emojimodel.h" #include "tray/syncstatussummary.h" +#include "tray/unifiedsearchresultslistmodel.h" #if defined(BUILD_UPDATER) #include "updater/updater.h" @@ -68,6 +69,9 @@ int main(int argc, char **argv) qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "UserStatusSelectorModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "ActivityListModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "FileActivityListModel"); + qmlRegisterUncreatableType( + "com.nextcloud.desktopclient", 1, 0, "UnifiedSearchResultsListModel", "UnifiedSearchResultsListModel"); + qRegisterMetaType("UnifiedSearchResultsListModel*"); qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "UserStatus", "Access to Status enum"); diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index 1f4760b355..dafb0dfc1a 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -18,6 +18,7 @@ #include "config.h" #include "common/utility.h" #include "tray/UserModel.h" +#include "tray/unifiedsearchresultimageprovider.h" #include "configfile.h" #include @@ -58,6 +59,7 @@ void Systray::setTrayEngine(QQmlApplicationEngine *trayEngine) _trayEngine->addImportPath("qrc:/qml/theme"); _trayEngine->addImageProvider("avatars", new ImageProvider); + _trayEngine->addImageProvider(QLatin1String("unified-search-result-icon"), new UnifiedSearchResultImageProvider); } Systray::Systray() diff --git a/src/gui/tray/UnifiedSearchInputContainer.qml b/src/gui/tray/UnifiedSearchInputContainer.qml new file mode 100644 index 0000000000..eda93a11d8 --- /dev/null +++ b/src/gui/tray/UnifiedSearchInputContainer.qml @@ -0,0 +1,110 @@ +import QtQml 2.15 +import QtQuick 2.15 +import QtQuick.Controls 2.3 +import QtGraphicalEffects 1.0 +import Style 1.0 + +TextField { + id: trayWindowUnifiedSearchTextField + + property bool isSearchInProgress: false + + readonly property color textFieldIconsColor: Style.menuBorder + + readonly property int textFieldIconsOffset: 10 + + readonly property double textFieldIconsScaleFactor: 0.6 + + readonly property int textFieldHorizontalPaddingOffset: 14 + + leftPadding: trayWindowUnifiedSearchTextFieldSearchIcon.width + trayWindowUnifiedSearchTextFieldSearchIcon.anchors.leftMargin + textFieldHorizontalPaddingOffset + rightPadding: trayWindowUnifiedSearchTextFieldClearTextButton.width + trayWindowUnifiedSearchTextFieldClearTextButton.anchors.rightMargin + textFieldHorizontalPaddingOffset + + placeholderText: qsTr("Search files, messages, events...") + + selectByMouse: true + + background: Rectangle { + radius: 5 + border.color: parent.activeFocus ? Style.ncBlue : Style.menuBorder + border.width: 1 + } + + Image { + id: trayWindowUnifiedSearchTextFieldSearchIcon + + anchors { + left: parent.left + leftMargin: parent.textFieldIconsOffset + verticalCenter: parent.verticalCenter + } + + visible: !trayWindowUnifiedSearchTextField.isSearchInProgress + + smooth: true; + antialiasing: true + mipmap: true + + source: "qrc:///client/theme/black/search.svg" + sourceSize: Qt.size(parent.height * parent.textFieldIconsScaleFactor, parent.height * parent.textFieldIconsScaleFactor) + + ColorOverlay { + anchors.fill: parent + source: parent + cached: true + color: parent.parent.textFieldIconsColor + } + } + + BusyIndicator { + id: trayWindowUnifiedSearchTextFieldIconInProgress + running: visible + visible: trayWindowUnifiedSearchTextField.isSearchInProgress + anchors { + left: trayWindowUnifiedSearchTextField.left + bottom: trayWindowUnifiedSearchTextField.bottom + leftMargin: trayWindowUnifiedSearchTextField.textFieldIconsOffset - 4 + topMargin: 4 + bottomMargin: 4 + verticalCenter: trayWindowUnifiedSearchTextField.verticalCenter + } + width: height + } + + Image { + id: trayWindowUnifiedSearchTextFieldClearTextButton + + anchors { + right: parent.right + rightMargin: parent.textFieldIconsOffset + verticalCenter: parent.verticalCenter + } + + smooth: true; + antialiasing: true + mipmap: true + + visible: parent.text + + source: "qrc:///client/theme/black/clear.svg" + sourceSize: Qt.size(parent.height * parent.textFieldIconsScaleFactor, parent.height * parent.textFieldIconsScaleFactor) + + ColorOverlay { + anchors.fill: parent + cached: true + source: parent + color: parent.parent.textFieldIconsColor + } + + MouseArea { + id: trayWindowUnifiedSearchTextFieldClearTextButtonMouseArea + + anchors.fill: parent + + onClicked: { + trayWindowUnifiedSearchTextField.text = "" + trayWindowUnifiedSearchTextField.onTextEdited() + } + } + } +} diff --git a/src/gui/tray/UnifiedSearchResultFetchMoreTrigger.qml b/src/gui/tray/UnifiedSearchResultFetchMoreTrigger.qml new file mode 100644 index 0000000000..1af2d42ca7 --- /dev/null +++ b/src/gui/tray/UnifiedSearchResultFetchMoreTrigger.qml @@ -0,0 +1,42 @@ +import QtQml 2.15 +import QtQuick 2.15 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.2 +import Style 1.0 + +ColumnLayout { + id: unifiedSearchResultItemFetchMore + + property bool isFetchMoreInProgress: false + + property bool isWihinViewPort: false + + property int fontSize: Style.topLinePixelSize + + property string textColor: "grey" + + Accessible.role: Accessible.ListItem + Accessible.name: unifiedSearchResultItemFetchMoreText.text + Accessible.onPressAction: unifiedSearchResultMouseArea.clicked() + + Label { + id: unifiedSearchResultItemFetchMoreText + text: qsTr("Load more results") + visible: !unifiedSearchResultItemFetchMore.isFetchMoreInProgress + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + Layout.fillWidth: true + Layout.fillHeight: true + font.pixelSize: unifiedSearchResultItemFetchMore.fontSize + color: unifiedSearchResultItemFetchMore.textColor + } + + BusyIndicator { + id: unifiedSearchResultItemFetchMoreIconInProgress + running: visible + visible: unifiedSearchResultItemFetchMore.isFetchMoreInProgress && unifiedSearchResultItemFetchMore.isWihinViewPort + Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter + Layout.preferredWidth: parent.height * 0.70 + Layout.preferredHeight: parent.height * 0.70 + } +} diff --git a/src/gui/tray/UnifiedSearchResultItem.qml b/src/gui/tray/UnifiedSearchResultItem.qml new file mode 100644 index 0000000000..0241ed28e7 --- /dev/null +++ b/src/gui/tray/UnifiedSearchResultItem.qml @@ -0,0 +1,107 @@ +import QtQml 2.15 +import QtQuick 2.9 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.2 +import Style 1.0 +import QtGraphicalEffects 1.0 + +RowLayout { + id: unifiedSearchResultItemDetails + + property string title: "" + property string subline: "" + property string icons: "" + property string iconPlaceholder: "" + property bool isRounded: false + + + property int textLeftMargin: 18 + property int textRightMargin: 16 + property int iconWidth: 24 + property int iconLeftMargin: 12 + + property int titleFontSize: Style.topLinePixelSize + property int sublineFontSize: Style.subLinePixelSize + + property string titleColor: "black" + property string sublineColor: "grey" + + Accessible.role: Accessible.ListItem + Accessible.name: resultTitle + Accessible.onPressAction: unifiedSearchResultMouseArea.clicked() + + ColumnLayout { + id: unifiedSearchResultImageContainer + visible: true + Layout.preferredWidth: unifiedSearchResultItemDetails.iconWidth + 10 + Layout.preferredHeight: unifiedSearchResultItemDetails.height + Image { + id: unifiedSearchResultThumbnail + visible: false + asynchronous: true + source: "image://unified-search-result-icon/" + icons + cache: true + sourceSize.width: imageData.width + sourceSize.height: imageData.height + width: imageData.width + height: imageData.height + } + Rectangle { + id: mask + visible: false + radius: isRounded ? width / 2 : 0 + width: imageData.width + height: imageData.height + } + OpacityMask { + id: imageData + visible: !unifiedSearchResultThumbnailPlaceholder.visible && icons + Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter + Layout.leftMargin: iconLeftMargin + Layout.preferredWidth: unifiedSearchResultItemDetails.iconWidth + Layout.preferredHeight: unifiedSearchResultItemDetails.iconWidth + source: unifiedSearchResultThumbnail + maskSource: mask + } + Image { + id: unifiedSearchResultThumbnailPlaceholder + visible: icons && iconPlaceholder && unifiedSearchResultThumbnail.status !== Image.Ready + Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter + Layout.leftMargin: iconLeftMargin + verticalAlignment: Qt.AlignCenter + cache: true + source: iconPlaceholder + sourceSize.height: unifiedSearchResultItemDetails.iconWidth + sourceSize.width: unifiedSearchResultItemDetails.iconWidth + Layout.preferredWidth: unifiedSearchResultItemDetails.iconWidth + Layout.preferredHeight: unifiedSearchResultItemDetails.iconWidth + } + } + + ColumnLayout { + id: unifiedSearchResultTextContainer + Layout.fillWidth: true + + Label { + id: unifiedSearchResultTitleText + text: title.replace(/[\r\n]+/g, " ") + Layout.leftMargin: textLeftMargin + Layout.rightMargin: textRightMargin + Layout.fillWidth: true + elide: Text.ElideRight + font.pixelSize: unifiedSearchResultItemDetails.titleFontSize + color: unifiedSearchResultItemDetails.titleColor + } + Label { + id: unifiedSearchResultTextSubline + text: subline.replace(/[\r\n]+/g, " ") + elide: Text.ElideRight + font.pixelSize: unifiedSearchResultItemDetails.sublineFontSize + Layout.leftMargin: textLeftMargin + Layout.rightMargin: textRightMargin + Layout.fillWidth: true + color: unifiedSearchResultItemDetails.sublineColor + } + } + +} diff --git a/src/gui/tray/UnifiedSearchResultItemSkeleton.qml b/src/gui/tray/UnifiedSearchResultItemSkeleton.qml new file mode 100644 index 0000000000..3f86c4b9f8 --- /dev/null +++ b/src/gui/tray/UnifiedSearchResultItemSkeleton.qml @@ -0,0 +1,58 @@ +import QtQml 2.15 +import QtQuick 2.15 +import QtQuick.Layouts 1.2 +import Style 1.0 + +RowLayout { + id: unifiedSearchResultSkeletonItemDetails + + property int textLeftMargin: 18 + property int textRightMargin: 16 + property int iconWidth: 24 + property int iconLeftMargin: 12 + + property int titleFontSize: Style.topLinePixelSize + property int sublineFontSize: Style.subLinePixelSize + + property string titleColor: "black" + property string sublineColor: "grey" + + property string iconColor: "#afafaf" + + property int index: 0 + + Accessible.role: Accessible.ListItem + Accessible.name: qsTr("Search result skeleton.").arg(index) + + Rectangle { + id: unifiedSearchResultSkeletonThumbnail + color: unifiedSearchResultSkeletonItemDetails.iconColor + Layout.preferredWidth: unifiedSearchResultSkeletonItemDetails.iconWidth + Layout.preferredHeight: unifiedSearchResultSkeletonItemDetails.iconWidth + Layout.leftMargin: unifiedSearchResultSkeletonItemDetails.iconLeftMargin + Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter + } + + ColumnLayout { + id: unifiedSearchResultSkeletonTextContainer + Layout.fillWidth: true + + Rectangle { + id: unifiedSearchResultSkeletonTitleText + color: unifiedSearchResultSkeletonItemDetails.titleColor + Layout.preferredHeight: unifiedSearchResultSkeletonItemDetails.titleFontSize + Layout.leftMargin: unifiedSearchResultSkeletonItemDetails.textLeftMargin + Layout.rightMargin: unifiedSearchResultSkeletonItemDetails.textRightMargin + Layout.fillWidth: true + } + + Rectangle { + id: unifiedSearchResultSkeletonTextSubline + color: unifiedSearchResultSkeletonItemDetails.sublineColor + Layout.preferredHeight: unifiedSearchResultSkeletonItemDetails.sublineFontSize + Layout.leftMargin: unifiedSearchResultSkeletonItemDetails.textLeftMargin + Layout.rightMargin: unifiedSearchResultSkeletonItemDetails.textRightMargin + Layout.fillWidth: true + } + } +} diff --git a/src/gui/tray/UnifiedSearchResultItemSkeletonContainer.qml b/src/gui/tray/UnifiedSearchResultItemSkeletonContainer.qml new file mode 100644 index 0000000000..1b9f8c262a --- /dev/null +++ b/src/gui/tray/UnifiedSearchResultItemSkeletonContainer.qml @@ -0,0 +1,49 @@ +import QtQml 2.15 +import QtQuick 2.15 +import QtQuick.Controls 2.3 +import Style 1.0 + +Column { + id: unifiedSearchResultsListViewSkeletonColumn + + property int textLeftMargin: 18 + property int textRightMargin: 16 + property int iconWidth: 24 + property int iconLeftMargin: 12 + property int itemHeight: Style.trayWindowHeaderHeight + property int titleFontSize: Style.topLinePixelSize + property int sublineFontSize: Style.subLinePixelSize + property string titleColor: "black" + property string sublineColor: "grey" + property string iconColor: "#afafaf" + + Repeater { + model: 10 + UnifiedSearchResultItemSkeleton { + textLeftMargin: unifiedSearchResultsListViewSkeletonColumn.textLeftMargin + textRightMargin: unifiedSearchResultsListViewSkeletonColumn.textRightMargin + iconWidth: unifiedSearchResultsListViewSkeletonColumn.iconWidth + iconLeftMargin: unifiedSearchResultsListViewSkeletonColumn.iconLeftMargin + width: unifiedSearchResultsListViewSkeletonColumn.width + height: unifiedSearchResultsListViewSkeletonColumn.itemHeight + index: model.index + titleFontSize: unifiedSearchResultsListViewSkeletonColumn.titleFontSize + sublineFontSize: unifiedSearchResultsListViewSkeletonColumn.sublineFontSize + titleColor: unifiedSearchResultsListViewSkeletonColumn.titleColor + sublineColor: unifiedSearchResultsListViewSkeletonColumn.sublineColor + iconColor: unifiedSearchResultsListViewSkeletonColumn.iconColor + } + } + + OpacityAnimator { + target: unifiedSearchResultsListViewSkeletonColumn; + from: 0.5; + to: 1; + duration: 800 + running: unifiedSearchResultsListViewSkeletonColumn.visible + loops: Animation.Infinite; + easing { + type: Easing.InOutBounce; + } + } +} diff --git a/src/gui/tray/UnifiedSearchResultListItem.qml b/src/gui/tray/UnifiedSearchResultListItem.qml new file mode 100644 index 0000000000..33a05f2d56 --- /dev/null +++ b/src/gui/tray/UnifiedSearchResultListItem.qml @@ -0,0 +1,87 @@ +import QtQml 2.15 +import QtQuick 2.15 +import QtQuick.Controls 2.3 +import Style 1.0 + +MouseArea { + id: unifiedSearchResultMouseArea + + property int textLeftMargin: 18 + property int textRightMargin: 16 + property int iconWidth: 24 + property int iconLeftMargin: 12 + + property int titleFontSize: Style.topLinePixelSize + property int sublineFontSize: Style.subLinePixelSize + + property string titleColor: "black" + property string sublineColor: "grey" + + property string currentFetchMoreInProgressProviderId: "" + + readonly property bool isFetchMoreTrigger: model.typeAsString === "FetchMoreTrigger" + + property bool isFetchMoreInProgress: currentFetchMoreInProgressProviderId === model.providerId + property bool isSearchInProgress: false + + property bool isPooled: false + + property var fetchMoreTriggerClicked: function(){} + property var resultClicked: function(){} + + enabled: !isFetchMoreTrigger || !isSearchInProgress + hoverEnabled: enabled + + ToolTip { + visible: unifiedSearchResultMouseArea.containsMouse + text: isFetchMoreTrigger ? qsTr("Load more results") : model.resultTitle + "\n\n" + model.subline + delay: Qt.styleHints.mousePressAndHoldInterval + } + + Rectangle { + id: unifiedSearchResultHoverBackground + anchors.fill: parent + color: (parent.containsMouse ? Style.lightHover : "transparent") + } + + Loader { + active: !isFetchMoreTrigger + sourceComponent: UnifiedSearchResultItem { + width: unifiedSearchResultMouseArea.width + height: unifiedSearchResultMouseArea.height + title: model.resultTitle + subline: model.subline + icons: model.icons + iconPlaceholder: model.imagePlaceholder + isRounded: model.isRounded + textLeftMargin: unifiedSearchResultMouseArea.textLeftMargin + textRightMargin: unifiedSearchResultMouseArea.textRightMargin + iconWidth: unifiedSearchResultMouseArea.iconWidth + iconLeftMargin: unifiedSearchResultMouseArea.iconLeftMargin + titleFontSize: unifiedSearchResultMouseArea.titleFontSize + sublineFontSize: unifiedSearchResultMouseArea.sublineFontSize + titleColor: unifiedSearchResultMouseArea.titleColor + sublineColor: unifiedSearchResultMouseArea.sublineColor + } + } + + Loader { + active: isFetchMoreTrigger + sourceComponent: UnifiedSearchResultFetchMoreTrigger { + isFetchMoreInProgress: unifiedSearchResultMouseArea.isFetchMoreInProgress + width: unifiedSearchResultMouseArea.width + height: unifiedSearchResultMouseArea.height + isWihinViewPort: !unifiedSearchResultMouseArea.isPooled + fontSize: unifiedSearchResultMouseArea.titleFontSize + textColor: unifiedSearchResultMouseArea.sublineColor + } + } + + onClicked: { + if (isFetchMoreTrigger) { + unifiedSearchResultMouseArea.fetchMoreTriggerClicked(model.providerId) + } else { + unifiedSearchResultMouseArea.resultClicked(model.providerId, model.resourceUrlRole) + } + } +} diff --git a/src/gui/tray/UnifiedSearchResultNothingFound.qml b/src/gui/tray/UnifiedSearchResultNothingFound.qml new file mode 100644 index 0000000000..36cea93a63 --- /dev/null +++ b/src/gui/tray/UnifiedSearchResultNothingFound.qml @@ -0,0 +1,47 @@ +import QtQml 2.15 +import QtQuick 2.15 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.2 +import Style 1.0 + +ColumnLayout { + id: unifiedSearchResultNothingFoundContainer + + required property string text + + spacing: 8 + anchors.leftMargin: 10 + anchors.rightMargin: 10 + + Image { + id: unifiedSearchResultsNoResultsLabelIcon + source: "qrc:///client/theme/magnifying-glass.svg" + sourceSize.width: Style.trayWindowHeaderHeight / 2 + sourceSize.height: Style.trayWindowHeaderHeight / 2 + Layout.alignment: Qt.AlignHCenter + } + + Label { + id: unifiedSearchResultsNoResultsLabel + text: qsTr("No results for") + color: Style.menuBorder + font.pixelSize: Style.subLinePixelSize * 1.25 + wrapMode: Text.Wrap + Layout.fillWidth: true + Layout.preferredHeight: Style.trayWindowHeaderHeight / 2 + horizontalAlignment: Text.AlignHCenter + } + + Label { + id: unifiedSearchResultsNoResultsLabelDetails + text: unifiedSearchResultNothingFoundContainer.text + color: "black" + font.pixelSize: Style.topLinePixelSize * 1.25 + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + Layout.fillWidth: true + Layout.preferredHeight: Style.trayWindowHeaderHeight / 2 + horizontalAlignment: Text.AlignHCenter + } +} diff --git a/src/gui/tray/UnifiedSearchResultSectionItem.qml b/src/gui/tray/UnifiedSearchResultSectionItem.qml new file mode 100644 index 0000000000..a6f9fd591b --- /dev/null +++ b/src/gui/tray/UnifiedSearchResultSectionItem.qml @@ -0,0 +1,20 @@ +import QtQml 2.15 +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.2 +import Style 1.0 + +Label { + required property string section + + topPadding: 8 + bottomPadding: 8 + leftPadding: 16 + + text: section + font.pixelSize: Style.topLinePixelSize + color: Style.ncBlue + + Accessible.role: Accessible.Separator + Accessible.name: qsTr("Search results section %1").arg(section) +} diff --git a/src/gui/tray/UserModel.cpp b/src/gui/tray/UserModel.cpp index c9e3d92b57..3624dd6522 100644 --- a/src/gui/tray/UserModel.cpp +++ b/src/gui/tray/UserModel.cpp @@ -14,6 +14,7 @@ #include "syncfileitem.h" #include "tray/ActivityListModel.h" #include "tray/NotificationCache.h" +#include "tray/unifiedsearchresultslistmodel.h" #include "userstatusconnector.h" #include @@ -38,7 +39,8 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) : QObject(parent) , _account(account) , _isCurrentUser(isCurrent) - , _activityModel(new ActivityListModel(_account.data())) + , _activityModel(new ActivityListModel(_account.data(), this)) + , _unifiedSearchResultsModel(new UnifiedSearchResultsListModel(_account.data(), this)) , _notificationRequestsRunning(0) { connect(ProgressDispatcher::instance(), &ProgressDispatcher::progressInfo, @@ -589,6 +591,11 @@ ActivityListModel *User::getActivityModel() return _activityModel; } +UnifiedSearchResultsListModel *User::getUnifiedSearchResultsListModel() const +{ + return _unifiedSearchResultsModel; +} + void User::openLocalFolder() { const auto folder = getFolder(); diff --git a/src/gui/tray/UserModel.h b/src/gui/tray/UserModel.h index eb3be91365..88dc78a25d 100644 --- a/src/gui/tray/UserModel.h +++ b/src/gui/tray/UserModel.h @@ -18,6 +18,7 @@ #include namespace OCC { +class UnifiedSearchResultsListModel; class User : public QObject { @@ -33,6 +34,7 @@ class User : public QObject Q_PROPERTY(bool serverHasTalk READ serverHasTalk NOTIFY serverHasTalkChanged) Q_PROPERTY(QString avatar READ avatarUrl NOTIFY avatarChanged) Q_PROPERTY(bool isConnected READ isConnected NOTIFY accountStateChanged) + Q_PROPERTY(UnifiedSearchResultsListModel* unifiedSearchResultsListModel READ getUnifiedSearchResultsListModel CONSTANT) public: User(AccountStatePtr &account, const bool &isCurrent = false, QObject *parent = nullptr); @@ -44,6 +46,7 @@ public: void setCurrentUser(const bool &isCurrent); Folder *getFolder() const; ActivityListModel *getActivityModel(); + UnifiedSearchResultsListModel *getUnifiedSearchResultsListModel() const; void openLocalFolder(); QString name() const; QString server(bool shortened = true) const; @@ -113,6 +116,7 @@ private: AccountStatePtr _account; bool _isCurrentUser; ActivityListModel *_activityModel; + UnifiedSearchResultsListModel *_unifiedSearchResultsModel; ActivityList _blacklistedNotifications; QTimer _expiredActivitiesCheckTimer; diff --git a/src/gui/tray/Window.qml b/src/gui/tray/Window.qml index 5bda9242da..d85615adb8 100644 --- a/src/gui/tray/Window.qml +++ b/src/gui/tray/Window.qml @@ -1,10 +1,11 @@ import QtQml 2.12 import QtQml.Models 2.1 -import QtQuick 2.9 +import QtQuick 2.15 import QtQuick.Window 2.3 import QtQuick.Controls 2.3 import QtQuick.Layouts 1.2 import QtGraphicalEffects 1.0 +import "../" // Custom qml modules are in /theme (and included by resources.qrc) import Style 1.0 @@ -101,6 +102,11 @@ Window { Rectangle { id: trayWindowBackground + property bool isUnifiedSearchActive: unifiedSearchResultsListViewSkeleton.visible + || unifiedSearchResultNothingFound.visible + || unifiedSearchResultsErrorLabel.visible + || unifiedSearchResultsListView.visible + anchors.fill: parent radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius border.width: Style.trayWindowBorderWidth @@ -420,7 +426,7 @@ Window { visible: UserModel.currentUser.statusMessage !== "" width: Style.currentAccountLabelWidth text: UserModel.currentUser.statusMessage !== "" - ? UserModel.currentUser.statusMessage + ? UserModel.currentUser.statusMessage : UserModel.currentUser.server elide: Text.ElideRight color: Style.ncTextColor @@ -452,20 +458,20 @@ Window { Item { Layout.fillWidth: true } - + RowLayout { id: openLocalFolderRowLayout spacing: 0 Layout.preferredWidth: Style.trayWindowHeaderHeight Layout.preferredHeight: Style.trayWindowHeaderHeight Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter - + HeaderButton { id: openLocalFolderButton visible: UserModel.currentUser.hasLocalFolder icon.source: "qrc:///client/theme/white/folder.svg" onClicked: UserModel.openCurrentAccountLocalFolder() - + Rectangle { id: folderStateIndicatorBackground width: Style.folderStateIndicatorSize @@ -476,7 +482,7 @@ Window { radius: width*0.5 z: 1 } - + Image { id: folderStateIndicator visible: UserModel.currentUser.hasLocalFolder @@ -484,19 +490,17 @@ Window { ? Style.stateOnlineImageSource : Style.stateOfflineImageSource cache: false - + anchors.top: openLocalFolderButton.verticalCenter - anchors.left: openLocalFolderButton.horizontalCenter + anchors.left: openLocalFolderButton.horizontalCenter sourceSize.width: Style.folderStateIndicatorSize sourceSize.height: Style.folderStateIndicatorSize - + Accessible.role: Accessible.Indicator Accessible.name: UserModel.currentUser.isConnected ? qsTr("Connected") : qsTr("Disconnected") z: 2 } } - - Accessible.role: Accessible.Button Accessible.name: qsTr("Open local folder of current account") @@ -504,11 +508,11 @@ Window { HeaderButton { id: trayWindowTalkButton - + visible: UserModel.currentUser.serverHasTalk icon.source: "qrc:///client/theme/white/talk-app.svg" onClicked: UserModel.openCurrentAccountTalk() - + Accessible.role: Accessible.Button Accessible.name: qsTr("Open Nextcloud Talk in browser") Accessible.onPressAction: trayWindowTalkButton.clicked() @@ -517,7 +521,7 @@ Window { HeaderButton { id: trayWindowAppsButton icon.source: "qrc:///client/theme/white/more-apps.svg" - + onClicked: { if(appsMenu.count <= 0) { UserModel.openCurrentAccountServer() @@ -566,20 +570,158 @@ Window { } } // Rectangle trayWindowHeaderBackground + UnifiedSearchInputContainer { + id: trayWindowUnifiedSearchInputContainer + height: Style.trayWindowHeaderHeight * 0.65 + + anchors { + top: trayWindowHeaderBackground.bottom + left: trayWindowBackground.left + right: trayWindowBackground.right + + margins: { + top: 10 + } + } + + text: UserModel.currentUser.unifiedSearchResultsListModel.searchTerm + readOnly: !UserModel.currentUser.isConnected || UserModel.currentUser.unifiedSearchResultsListModel.currentFetchMoreInProgressProviderId + isSearchInProgress: UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress + onTextEdited: { UserModel.currentUser.unifiedSearchResultsListModel.searchTerm = trayWindowUnifiedSearchInputContainer.text } + } + + ErrorBox { + id: unifiedSearchResultsErrorLabel + visible: UserModel.currentUser.unifiedSearchResultsListModel.errorString && !unifiedSearchResultsListView.visible && ! UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress && ! UserModel.currentUser.unifiedSearchResultsListModel.currentFetchMoreInProgressProviderId + text: UserModel.currentUser.unifiedSearchResultsListModel.errorString + color: Style.errorBoxBackgroundColor + backgroundColor: Style.errorBoxTextColor + borderColor: "transparent" + anchors.top: trayWindowUnifiedSearchInputContainer.bottom + anchors.left: trayWindowBackground.left + anchors.right: trayWindowBackground.right + anchors.margins: 10 + } + + UnifiedSearchResultNothingFound { + id: unifiedSearchResultNothingFound + visible: false + anchors.top: trayWindowUnifiedSearchInputContainer.bottom + anchors.left: trayWindowBackground.left + anchors.right: trayWindowBackground.right + anchors.topMargin: 10 + + text: UserModel.currentUser.unifiedSearchResultsListModel.searchTerm + + property bool isSearchRunning: UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress + property bool isSearchResultsEmpty: unifiedSearchResultsListView.count === 0 + property bool nothingFound: text && isSearchResultsEmpty && !UserModel.currentUser.unifiedSearchResultsListModel.errorString + + onIsSearchRunningChanged: { + if (unifiedSearchResultNothingFound.isSearchRunning) { + visible = false; + } else { + if (nothingFound) { + visible = true; + } + } + } + + onTextChanged: { + visible = false; + } + + onIsSearchResultsEmptyChanged: { + if (!unifiedSearchResultNothingFound.isSearchResultsEmpty) { + visible = false; + } + } + } + + UnifiedSearchResultItemSkeletonContainer { + id: unifiedSearchResultsListViewSkeleton + visible: !unifiedSearchResultNothingFound.visible && !unifiedSearchResultsListView.visible && ! UserModel.currentUser.unifiedSearchResultsListModel.errorString && UserModel.currentUser.unifiedSearchResultsListModel.searchTerm + anchors.top: trayWindowUnifiedSearchInputContainer.bottom + anchors.left: trayWindowBackground.left + anchors.right: trayWindowBackground.right + anchors.bottom: trayWindowBackground.bottom + textLeftMargin: trayWindowBackground.Style.unifiedSearchResultTextLeftMargin + textRightMargin: trayWindowBackground.Style.unifiedSearchResultTextRightMargin + iconWidth: trayWindowBackground.Style.unifiedSearchResulIconWidth + iconLeftMargin: trayWindowBackground.Style.unifiedSearchResulIconLeftMargin + itemHeight: trayWindowBackground.Style.unifiedSearchItemHeight + titleFontSize: trayWindowBackground.Style.unifiedSearchResulTitleFontSize + sublineFontSize: trayWindowBackground.Style.unifiedSearchResulSublineFontSize + titleColor: trayWindowBackground.Style.unifiedSearchResulTitleColor + sublineColor: trayWindowBackground.Style.unifiedSearchResulSublineColor + iconColor: "#afafaf" + } + + ListView { + id: unifiedSearchResultsListView + anchors.top: trayWindowUnifiedSearchInputContainer.bottom + anchors.left: trayWindowBackground.left + anchors.right: trayWindowBackground.right + anchors.bottom: trayWindowBackground.bottom + spacing: 4 + visible: count > 0 + clip: true + ScrollBar.vertical: ScrollBar { + id: unifiedSearchResultsListViewScrollbar + } + + keyNavigationEnabled: true + + reuseItems: true + + Accessible.role: Accessible.List + Accessible.name: qsTr("Unified search results list") + + model: UserModel.currentUser.unifiedSearchResultsListModel + + delegate: UnifiedSearchResultListItem { + width: unifiedSearchResultsListView.width + height: trayWindowBackground.Style.unifiedSearchItemHeight + isSearchInProgress: unifiedSearchResultsListView.model.isSearchInProgress + textLeftMargin: trayWindowBackground.Style.unifiedSearchResultTextLeftMargin + textRightMargin: trayWindowBackground.Style.unifiedSearchResultTextRightMargin + iconWidth: trayWindowBackground.Style.unifiedSearchResulIconWidth + iconLeftMargin: trayWindowBackground.Style.unifiedSearchResulIconLeftMargin + titleFontSize: trayWindowBackground.Style.unifiedSearchResulTitleFontSize + sublineFontSize: trayWindowBackground.Style.unifiedSearchResulSublineFontSize + titleColor: trayWindowBackground.Style.unifiedSearchResulTitleColor + sublineColor: trayWindowBackground.Style.unifiedSearchResulSublineColor + currentFetchMoreInProgressProviderId: unifiedSearchResultsListView.model.currentFetchMoreInProgressProviderId + fetchMoreTriggerClicked: unifiedSearchResultsListView.model.fetchMoreTriggerClicked + resultClicked: unifiedSearchResultsListView.model.resultClicked + ListView.onPooled: isPooled = true + ListView.onReused: isPooled = false + } + + section.property: "providerName" + section.criteria: ViewSection.FullString + section.delegate: UnifiedSearchResultSectionItem { + width: unifiedSearchResultsListView.width + } + } + SyncStatus { id: syncStatus - anchors.top: trayWindowHeaderBackground.bottom + visible: !trayWindowBackground.isUnifiedSearchActive + + anchors.top: trayWindowUnifiedSearchInputContainer.bottom anchors.left: trayWindowBackground.left anchors.right: trayWindowBackground.right } ActivityList { + visible: !trayWindowBackground.isUnifiedSearchActive anchors.top: syncStatus.bottom anchors.left: trayWindowBackground.left anchors.right: trayWindowBackground.right anchors.bottom: trayWindowBackground.bottom - + model: activityModel onShowFileActivity: { openFileActivityDialog(displayPath, absolutePath) @@ -598,7 +740,7 @@ Window { function refresh() { active = true item.model.load(activityModel.accountState, absolutePath) - item.show() + item.show() } active: false diff --git a/src/gui/tray/unifiedsearchresult.cpp b/src/gui/tray/unifiedsearchresult.cpp new file mode 100644 index 0000000000..768ff3148c --- /dev/null +++ b/src/gui/tray/unifiedsearchresult.cpp @@ -0,0 +1,36 @@ +/* + * Copyright (C) by Oleksandr Zolotov + * + * 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) any later version. + * + * 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. + */ + +#include + +#include "unifiedsearchresult.h" + +namespace OCC { + +QString UnifiedSearchResult::typeAsString(UnifiedSearchResult::Type type) +{ + QString result; + + switch (type) { + case Default: + result = QStringLiteral("Default"); + break; + + case FetchMoreTrigger: + result = QStringLiteral("FetchMoreTrigger"); + break; + } + return result; +} +} diff --git a/src/gui/tray/unifiedsearchresult.h b/src/gui/tray/unifiedsearchresult.h new file mode 100644 index 0000000000..bae3158d63 --- /dev/null +++ b/src/gui/tray/unifiedsearchresult.h @@ -0,0 +1,48 @@ +/* + * Copyright (C) by Oleksandr Zolotov + * + * 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) any later version. + * + * 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. + */ + +#pragma once + +#include + +#include + +namespace OCC { + +/** + * @brief The UnifiedSearchResult class + * @ingroup gui + * Simple data structure that represents single Unified Search result + */ + +struct UnifiedSearchResult +{ + enum Type : quint8 { + Default, + FetchMoreTrigger, + }; + + static QString typeAsString(UnifiedSearchResult::Type type); + + QString _title; + QString _subline; + QString _providerId; + QString _providerName; + bool _isRounded = false; + qint32 _order = std::numeric_limits::max(); + QUrl _resourceUrl; + QString _icons; + Type _type = Type::Default; +}; +} diff --git a/src/gui/tray/unifiedsearchresultimageprovider.cpp b/src/gui/tray/unifiedsearchresultimageprovider.cpp new file mode 100644 index 0000000000..f0bba8efef --- /dev/null +++ b/src/gui/tray/unifiedsearchresultimageprovider.cpp @@ -0,0 +1,131 @@ +/* + * Copyright (C) by Oleksandr Zolotov + * + * 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) any later version. + * + * 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. + */ + +#include "unifiedsearchresultimageprovider.h" + +#include "UserModel.h" + +#include +#include +#include + +namespace { +class AsyncImageResponse : public QQuickImageResponse +{ +public: + AsyncImageResponse(const QString &id, const QSize &requestedSize) + { + if (id.isEmpty()) { + setImageAndEmitFinished(); + return; + } + + _imagePaths = id.split(QLatin1Char(';'), Qt::SkipEmptyParts); + _requestedImageSize = requestedSize; + + if (_imagePaths.isEmpty()) { + setImageAndEmitFinished(); + } else { + processNextImage(); + } + } + + void setImageAndEmitFinished(const QImage &image = {}) + { + _image = image; + emit finished(); + } + + QQuickTextureFactory *textureFactory() const override + { + return QQuickTextureFactory::textureFactoryForImage(_image); + } + +private: + void processNextImage() + { + if (_index < 0 || _index >= _imagePaths.size()) { + setImageAndEmitFinished(); + return; + } + + if (_imagePaths.at(_index).startsWith(QStringLiteral(":/client"))) { + setImageAndEmitFinished(QIcon(_imagePaths.at(_index)).pixmap(_requestedImageSize).toImage()); + return; + } + + const auto currentUser = OCC::UserModel::instance()->currentUser(); + if (currentUser && currentUser->account()) { + const QUrl iconUrl(_imagePaths.at(_index)); + if (iconUrl.isValid() && !iconUrl.scheme().isEmpty()) { + // fetch the remote resource + const auto reply = currentUser->account()->sendRawRequest(QByteArrayLiteral("GET"), iconUrl); + connect(reply, &QNetworkReply::finished, this, &AsyncImageResponse::slotProcessNetworkReply); + ++_index; + return; + } + } + + setImageAndEmitFinished(); + } + +private slots: + void slotProcessNetworkReply() + { + const auto reply = qobject_cast(sender()); + if (!reply) { + setImageAndEmitFinished(); + return; + } + + const QByteArray imageData = reply->readAll(); + // server returns "[]" for some some file previews (have no idea why), so, we use another image + // from the list if available + if (imageData.isEmpty() || imageData == QByteArrayLiteral("[]")) { + processNextImage(); + } else { + if (imageData.startsWith(QByteArrayLiteral(" + * + * 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) any later version. + * + * 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. + */ + +#pragma once + +#include +#include + +namespace OCC { + +/** + * @brief The UnifiedSearchResultImageProvider + * @ingroup gui + * Allows to fetch Unified Search result icon from the server or used a local resource + */ + +class UnifiedSearchResultImageProvider : public QQuickAsyncImageProvider +{ +public: + QQuickImageResponse *requestImageResponse(const QString &id, const QSize &requestedSize) override; +}; +} diff --git a/src/gui/tray/unifiedsearchresultslistmodel.cpp b/src/gui/tray/unifiedsearchresultslistmodel.cpp new file mode 100644 index 0000000000..a6cc8569d1 --- /dev/null +++ b/src/gui/tray/unifiedsearchresultslistmodel.cpp @@ -0,0 +1,708 @@ +/* + * Copyright (C) by Oleksandr Zolotov + * + * 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) any later version. + * + * 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. + */ + +#include "unifiedsearchresultslistmodel.h" + +#include "account.h" +#include "accountstate.h" +#include "guiutility.h" +#include "folderman.h" +#include "networkjobs.h" + +#include + +#include +#include + +namespace { +QString imagePlaceholderUrlForProviderId(const QString &providerId) +{ + if (providerId.contains(QStringLiteral("message"), Qt::CaseInsensitive) + || providerId.contains(QStringLiteral("talk"), Qt::CaseInsensitive)) { + return QStringLiteral("qrc:///client/theme/black/wizard-talk.svg"); + } else if (providerId.contains(QStringLiteral("file"), Qt::CaseInsensitive)) { + return QStringLiteral("qrc:///client/theme/black/edit.svg"); + } else if (providerId.contains(QStringLiteral("deck"), Qt::CaseInsensitive)) { + return QStringLiteral("qrc:///client/theme/black/deck.svg"); + } else if (providerId.contains(QStringLiteral("calendar"), Qt::CaseInsensitive)) { + return QStringLiteral("qrc:///client/theme/black/calendar.svg"); + } else if (providerId.contains(QStringLiteral("mail"), Qt::CaseInsensitive)) { + return QStringLiteral("qrc:///client/theme/black/email.svg"); + } else if (providerId.contains(QStringLiteral("comment"), Qt::CaseInsensitive)) { + return QStringLiteral("qrc:///client/theme/black/comment.svg"); + } + + return QStringLiteral("qrc:///client/theme/change.svg"); +} + +QString localIconPathFromIconPrefix(const QString &iconNameWithPrefix) +{ + if (iconNameWithPrefix.contains(QStringLiteral("message"), Qt::CaseInsensitive) + || iconNameWithPrefix.contains(QStringLiteral("talk"), Qt::CaseInsensitive)) { + return QStringLiteral(":/client/theme/black/wizard-talk.svg"); + } else if (iconNameWithPrefix.contains(QStringLiteral("folder"), Qt::CaseInsensitive)) { + return QStringLiteral(":/client/theme/black/folder.svg"); + } else if (iconNameWithPrefix.contains(QStringLiteral("deck"), Qt::CaseInsensitive)) { + return QStringLiteral(":/client/theme/black/deck.svg"); + } else if (iconNameWithPrefix.contains(QStringLiteral("contacts"), Qt::CaseInsensitive)) { + return QStringLiteral(":/client/theme/black/wizard-groupware.svg"); + } else if (iconNameWithPrefix.contains(QStringLiteral("calendar"), Qt::CaseInsensitive)) { + return QStringLiteral(":/client/theme/black/calendar.svg"); + } else if (iconNameWithPrefix.contains(QStringLiteral("mail"), Qt::CaseInsensitive)) { + return QStringLiteral(":/client/theme/black/email.svg"); + } + + return QStringLiteral(":/client/theme/change.svg"); +} + +QString iconUrlForDefaultIconName(const QString &defaultIconName) +{ + const QUrl urlForIcon{defaultIconName}; + + if (urlForIcon.isValid() && !urlForIcon.scheme().isEmpty()) { + return defaultIconName; + } + + if (defaultIconName.startsWith(QStringLiteral("icon-"))) { + const auto parts = defaultIconName.split(QLatin1Char('-')); + + if (parts.size() > 1) { + const QString iconFilePath = QStringLiteral(":/client/theme/") + parts[1] + QStringLiteral(".svg"); + + if (QFile::exists(iconFilePath)) { + return iconFilePath; + } + + const QString blackIconFilePath = QStringLiteral(":/client/theme/black/") + parts[1] + QStringLiteral(".svg"); + + if (QFile::exists(blackIconFilePath)) { + return blackIconFilePath; + } + } + + const auto iconNameFromIconPrefix = localIconPathFromIconPrefix(defaultIconName); + + if (!iconNameFromIconPrefix.isEmpty()) { + return iconNameFromIconPrefix; + } + } + + return QStringLiteral(":/client/theme/change.svg"); +} + +QString generateUrlForThumbnail(const QString &thumbnailUrl, const QUrl &serverUrl) +{ + auto serverUrlCopy = serverUrl; + auto thumbnailUrlCopy = thumbnailUrl; + + if (thumbnailUrlCopy.startsWith(QLatin1Char('/')) || thumbnailUrlCopy.startsWith(QLatin1Char('\\'))) { + // relative image resource URL, just needs some concatenation with current server URL + // some icons may contain parameters after (?) + const QStringList thumbnailUrlCopySplitted = thumbnailUrlCopy.contains(QLatin1Char('?')) + ? thumbnailUrlCopy.split(QLatin1Char('?'), Qt::SkipEmptyParts) + : QStringList{thumbnailUrlCopy}; + Q_ASSERT(!thumbnailUrlCopySplitted.isEmpty()); + serverUrlCopy.setPath(thumbnailUrlCopySplitted[0]); + thumbnailUrlCopy = serverUrlCopy.toString(); + if (thumbnailUrlCopySplitted.size() > 1) { + thumbnailUrlCopy += QLatin1Char('?') + thumbnailUrlCopySplitted[1]; + } + } + + return thumbnailUrlCopy; +} + +QString generateUrlForIcon(const QString &fallackIcon, const QUrl &serverUrl) +{ + auto serverUrlCopy = serverUrl; + + auto fallackIconCopy = fallackIcon; + + if (fallackIconCopy.startsWith(QLatin1Char('/')) || fallackIconCopy.startsWith(QLatin1Char('\\'))) { + // relative image resource URL, just needs some concatenation with current server URL + // some icons may contain parameters after (?) + const QStringList fallackIconPathSplitted = + fallackIconCopy.contains(QLatin1Char('?')) ? fallackIconCopy.split(QLatin1Char('?')) : QStringList{fallackIconCopy}; + Q_ASSERT(!fallackIconPathSplitted.isEmpty()); + serverUrlCopy.setPath(fallackIconPathSplitted[0]); + fallackIconCopy = serverUrlCopy.toString(); + if (fallackIconPathSplitted.size() > 1) { + fallackIconCopy += QLatin1Char('?') + fallackIconPathSplitted[1]; + } + } else if (!fallackIconCopy.isEmpty()) { + // could be one of names for standard icons (e.g. icon-mail) + const auto defaultIconUrl = iconUrlForDefaultIconName(fallackIconCopy); + if (!defaultIconUrl.isEmpty()) { + fallackIconCopy = defaultIconUrl; + } + } + + return fallackIconCopy; +} + +QString iconsFromThumbnailAndFallbackIcon(const QString &thumbnailUrl, const QString &fallackIcon, const QUrl &serverUrl) +{ + if (thumbnailUrl.isEmpty() && fallackIcon.isEmpty()) { + return {}; + } + + if (serverUrl.isEmpty()) { + const QStringList listImages = {thumbnailUrl, fallackIcon}; + return listImages.join(QLatin1Char(';')); + } + + const auto urlForThumbnail = generateUrlForThumbnail(thumbnailUrl, serverUrl); + const auto urlForFallackIcon = generateUrlForIcon(fallackIcon, serverUrl); + + if (urlForThumbnail.isEmpty() && !urlForFallackIcon.isEmpty()) { + return urlForFallackIcon; + } + + if (!urlForThumbnail.isEmpty() && urlForFallackIcon.isEmpty()) { + return urlForThumbnail; + } + + const QStringList listImages{urlForThumbnail, urlForFallackIcon}; + return listImages.join(QLatin1Char(';')); +} + +constexpr int searchTermEditingFinishedSearchStartDelay = 800; + +// server-side bug of returning the cursor > 0 and isPaginated == 'true', using '5' as it is done on Android client's end now +constexpr int minimumEntresNumberToShowLoadMore = 5; +} +namespace OCC { +Q_LOGGING_CATEGORY(lcUnifiedSearch, "nextcloud.gui.unifiedsearch", QtInfoMsg) + +UnifiedSearchResultsListModel::UnifiedSearchResultsListModel(AccountState *accountState, QObject *parent) + : QAbstractListModel(parent) + , _accountState(accountState) +{ +} + +QVariant UnifiedSearchResultsListModel::data(const QModelIndex &index, int role) const +{ + Q_ASSERT(checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)); + + switch (role) { + case ProviderNameRole: + return _results.at(index.row())._providerName; + case ProviderIdRole: + return _results.at(index.row())._providerId; + case ImagePlaceholderRole: + return imagePlaceholderUrlForProviderId(_results.at(index.row())._providerId); + case IconsRole: + return _results.at(index.row())._icons; + case TitleRole: + return _results.at(index.row())._title; + case SublineRole: + return _results.at(index.row())._subline; + case ResourceUrlRole: + return _results.at(index.row())._resourceUrl; + case RoundedRole: + return _results.at(index.row())._isRounded; + case TypeRole: + return _results.at(index.row())._type; + case TypeAsStringRole: + return UnifiedSearchResult::typeAsString(_results.at(index.row())._type); + } + + return {}; +} + +int UnifiedSearchResultsListModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + + return _results.size(); +} + +QHash UnifiedSearchResultsListModel::roleNames() const +{ + auto roles = QAbstractListModel::roleNames(); + roles[ProviderNameRole] = "providerName"; + roles[ProviderIdRole] = "providerId"; + roles[IconsRole] = "icons"; + roles[ImagePlaceholderRole] = "imagePlaceholder"; + roles[TitleRole] = "resultTitle"; + roles[SublineRole] = "subline"; + roles[ResourceUrlRole] = "resourceUrlRole"; + roles[TypeRole] = "type"; + roles[TypeAsStringRole] = "typeAsString"; + roles[RoundedRole] = "isRounded"; + return roles; +} + +QString UnifiedSearchResultsListModel::searchTerm() const +{ + return _searchTerm; +} + +QString UnifiedSearchResultsListModel::errorString() const +{ + return _errorString; +} + +QString UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderId() const +{ + return _currentFetchMoreInProgressProviderId; +} + +void UnifiedSearchResultsListModel::setSearchTerm(const QString &term) +{ + if (term == _searchTerm) { + return; + } + + _searchTerm = term; + emit searchTermChanged(); + + if (!_errorString.isEmpty()) { + _errorString.clear(); + emit errorStringChanged(); + } + + disconnectAndClearSearchJobs(); + + clearCurrentFetchMoreInProgressProviderId(); + + disconnect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this, + &UnifiedSearchResultsListModel::slotSearchTermEditingFinished); + + if (_unifiedSearchTextEditingFinishedTimer.isActive()) { + _unifiedSearchTextEditingFinishedTimer.stop(); + } + + if (!_searchTerm.isEmpty()) { + _unifiedSearchTextEditingFinishedTimer.setInterval(searchTermEditingFinishedSearchStartDelay); + connect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this, + &UnifiedSearchResultsListModel::slotSearchTermEditingFinished); + _unifiedSearchTextEditingFinishedTimer.start(); + } + + if (!_results.isEmpty()) { + beginResetModel(); + _results.clear(); + endResetModel(); + } +} + +bool UnifiedSearchResultsListModel::isSearchInProgress() const +{ + return !_searchJobConnections.isEmpty(); +} + +void UnifiedSearchResultsListModel::resultClicked(const QString &providerId, const QUrl &resourceUrl) const +{ + const QUrlQuery urlQuery{resourceUrl}; + const auto dir = urlQuery.queryItemValue(QStringLiteral("dir"), QUrl::ComponentFormattingOption::FullyDecoded); + const auto fileName = + urlQuery.queryItemValue(QStringLiteral("scrollto"), QUrl::ComponentFormattingOption::FullyDecoded); + + if (providerId.contains(QStringLiteral("file"), Qt::CaseInsensitive) && !dir.isEmpty() && !fileName.isEmpty()) { + if (!_accountState || !_accountState->account()) { + return; + } + + const QString relativePath = dir + QLatin1Char('/') + fileName; + const auto localFiles = + FolderMan::instance()->findFileInLocalFolders(QFileInfo(relativePath).path(), _accountState->account()); + + if (!localFiles.isEmpty()) { + QDesktopServices::openUrl(localFiles.constFirst()); + return; + } + } + Utility::openBrowser(resourceUrl); +} + +void UnifiedSearchResultsListModel::fetchMoreTriggerClicked(const QString &providerId) +{ + if (isSearchInProgress() || !_currentFetchMoreInProgressProviderId.isEmpty()) { + return; + } + + const auto providerInfo = _providers.value(providerId, {}); + + if (!providerInfo._id.isEmpty() && providerInfo._id == providerId && providerInfo._isPaginated) { + // Load more items + _currentFetchMoreInProgressProviderId = providerId; + emit currentFetchMoreInProgressProviderIdChanged(); + startSearchForProvider(providerId, providerInfo._cursor); + } +} + +void UnifiedSearchResultsListModel::slotSearchTermEditingFinished() +{ + disconnect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this, + &UnifiedSearchResultsListModel::slotSearchTermEditingFinished); + + if (!_accountState || !_accountState->account()) { + qCCritical(lcUnifiedSearch) << QString("Account state is invalid. Could not start search!"); + return; + } + + if (_providers.isEmpty()) { + auto job = new JsonApiJob(_accountState->account(), QLatin1String("ocs/v2.php/search/providers")); + QObject::connect(job, &JsonApiJob::jsonReceived, this, &UnifiedSearchResultsListModel::slotFetchProvidersFinished); + job->start(); + } else { + startSearch(); + } +} + +void UnifiedSearchResultsListModel::slotFetchProvidersFinished(const QJsonDocument &json, int statusCode) +{ + const auto job = qobject_cast(sender()); + + if (!job) { + qCCritical(lcUnifiedSearch) << QString("Failed to fetch providers.").arg(_searchTerm); + _errorString += tr("Failed to fetch providers.") + QLatin1Char('\n'); + emit errorStringChanged(); + return; + } + + if (statusCode != 200) { + qCCritical(lcUnifiedSearch) << QString("%1: Failed to fetch search providers for '%2'. Error: %3") + .arg(statusCode) + .arg(_searchTerm) + .arg(job->errorString()); + _errorString += + tr("Failed to fetch search providers for '%1'. Error: %2").arg(_searchTerm).arg(job->errorString()) + + QLatin1Char('\n'); + emit errorStringChanged(); + return; + } + const auto providerList = + json.object().value(QStringLiteral("ocs")).toObject().value(QStringLiteral("data")).toVariant().toList(); + + for (const auto &provider : providerList) { + const auto providerMap = provider.toMap(); + const auto id = providerMap[QStringLiteral("id")].toString(); + const auto name = providerMap[QStringLiteral("name")].toString(); + if (!name.isEmpty() && id != QStringLiteral("talk-message-current")) { + UnifiedSearchProvider newProvider; + newProvider._name = name; + newProvider._id = id; + newProvider._order = providerMap[QStringLiteral("order")].toInt(); + _providers.insert(newProvider._id, newProvider); + } + } + + if (!_providers.empty()) { + startSearch(); + } +} + +void UnifiedSearchResultsListModel::slotSearchForProviderFinished(const QJsonDocument &json, int statusCode) +{ + Q_ASSERT(_accountState && _accountState->account()); + + const auto job = qobject_cast(sender()); + + if (!job) { + qCCritical(lcUnifiedSearch) << QString("Search has failed for '%2'.").arg(_searchTerm); + _errorString += tr("Search has failed for '%2'.").arg(_searchTerm) + QLatin1Char('\n'); + emit errorStringChanged(); + return; + } + + const auto providerId = job->property("providerId").toString(); + + if (providerId.isEmpty()) { + return; + } + + if (!_searchJobConnections.isEmpty()) { + _searchJobConnections.remove(providerId); + + if (_searchJobConnections.isEmpty()) { + emit isSearchInProgressChanged(); + } + } + + if (providerId == _currentFetchMoreInProgressProviderId) { + clearCurrentFetchMoreInProgressProviderId(); + } + + if (statusCode != 200) { + qCCritical(lcUnifiedSearch) << QString("%1: Search has failed for '%2'. Error: %3") + .arg(statusCode) + .arg(_searchTerm) + .arg(job->errorString()); + _errorString += + tr("Search has failed for '%1'. Error: %2").arg(_searchTerm).arg(job->errorString()) + QLatin1Char('\n'); + emit errorStringChanged(); + return; + } + + const auto data = json.object().value(QStringLiteral("ocs")).toObject().value(QStringLiteral("data")).toObject(); + if (!data.isEmpty()) { + parseResultsForProvider(data, providerId, job->property("appendResults").toBool()); + } +} + +void UnifiedSearchResultsListModel::startSearch() +{ + Q_ASSERT(_accountState && _accountState->account()); + + disconnectAndClearSearchJobs(); + + if (!_accountState || !_accountState->account()) { + return; + } + + if (!_results.isEmpty()) { + beginResetModel(); + _results.clear(); + endResetModel(); + } + + for (const auto &provider : _providers) { + startSearchForProvider(provider._id); + } +} + +void UnifiedSearchResultsListModel::startSearchForProvider(const QString &providerId, qint32 cursor) +{ + Q_ASSERT(_accountState && _accountState->account()); + + if (!_accountState || !_accountState->account()) { + return; + } + + auto job = new JsonApiJob(_accountState->account(), + QLatin1String("ocs/v2.php/search/providers/%1/search").arg(providerId)); + + QUrlQuery params; + params.addQueryItem(QStringLiteral("term"), _searchTerm); + if (cursor > 0) { + params.addQueryItem(QStringLiteral("cursor"), QString::number(cursor)); + job->setProperty("appendResults", true); + } + job->setProperty("providerId", providerId); + job->addQueryParams(params); + const auto wasSearchInProgress = isSearchInProgress(); + _searchJobConnections.insert(providerId, + QObject::connect( + job, &JsonApiJob::jsonReceived, this, &UnifiedSearchResultsListModel::slotSearchForProviderFinished)); + if (isSearchInProgress() && !wasSearchInProgress) { + emit isSearchInProgressChanged(); + } + job->start(); +} + +void UnifiedSearchResultsListModel::parseResultsForProvider(const QJsonObject &data, const QString &providerId, bool fetchedMore) +{ + const auto cursor = data.value(QStringLiteral("cursor")).toInt(); + const auto entries = data.value(QStringLiteral("entries")).toVariant().toList(); + + auto &provider = _providers[providerId]; + + if (provider._id.isEmpty() && fetchedMore) { + _providers.remove(providerId); + return; + } + + if (entries.isEmpty()) { + // we may have received false pagination information from the server, such as, we expect more + // results available via pagination, but, there are no more left, so, we need to stop paginating for + // this provider + provider._isPaginated = false; + + if (fetchedMore) { + removeFetchMoreTrigger(provider._id); + } + + return; + } + + provider._isPaginated = data.value(QStringLiteral("isPaginated")).toBool(); + provider._cursor = cursor; + + if (provider._pageSize == -1) { + provider._pageSize = cursor; + } + + if ((provider._pageSize != -1 && entries.size() < provider._pageSize) + || entries.size() < minimumEntresNumberToShowLoadMore) { + // for some providers we are still getting a non-null cursor and isPaginated true even thought + // there are no more results to paginate + provider._isPaginated = false; + } + + QVector newEntries; + + const auto makeResourceUrl = [](const QString &resourceUrl, const QUrl &accountUrl) { + QUrl finalResurceUrl(resourceUrl); + if (finalResurceUrl.scheme().isEmpty() && accountUrl.scheme().isEmpty()) { + finalResurceUrl = accountUrl; + finalResurceUrl.setPath(resourceUrl); + } + return finalResurceUrl; + }; + + for (const auto &entry : entries) { + const auto entryMap = entry.toMap(); + if (entryMap.isEmpty()) { + continue; + } + UnifiedSearchResult result; + result._providerId = provider._id; + result._order = provider._order; + result._providerName = provider._name; + result._isRounded = entryMap.value(QStringLiteral("rounded")).toBool(); + result._title = entryMap.value(QStringLiteral("title")).toString(); + result._subline = entryMap.value(QStringLiteral("subline")).toString(); + + const auto resourceUrl = entryMap.value(QStringLiteral("resourceUrl")).toString(); + const auto accountUrl = (_accountState && _accountState->account()) ? _accountState->account()->url() : QUrl(); + + result._resourceUrl = makeResourceUrl(resourceUrl, accountUrl); + result._icons = iconsFromThumbnailAndFallbackIcon(entryMap.value(QStringLiteral("thumbnailUrl")).toString(), + entryMap.value(QStringLiteral("icon")).toString(), accountUrl); + + newEntries.push_back(result); + } + + if (fetchedMore) { + appendResultsToProvider(newEntries, provider); + } else { + appendResults(newEntries, provider); + } +} + +void UnifiedSearchResultsListModel::appendResults(QVector results, const UnifiedSearchProvider &provider) +{ + if (provider._cursor > 0 && provider._isPaginated) { + UnifiedSearchResult fetchMoreTrigger; + fetchMoreTrigger._providerId = provider._id; + fetchMoreTrigger._providerName = provider._name; + fetchMoreTrigger._order = provider._order; + fetchMoreTrigger._type = UnifiedSearchResult::Type::FetchMoreTrigger; + results.push_back(fetchMoreTrigger); + } + + + if (_results.isEmpty()) { + beginInsertRows({}, 0, results.size() - 1); + _results = results; + endInsertRows(); + return; + } + + // insertion is done with sorting (first -> by order, then -> by name) + const auto itToInsertTo = std::find_if(std::begin(_results), std::end(_results), + [&provider](const UnifiedSearchResult ¤t) { + // insert before other results of higher order when possible + if (current._order > provider._order) { + return true; + } else { + if (current._order == provider._order) { + // insert before results of higher QString value when possible + return current._providerName > provider._name; + } + + return false; + } + }); + + const auto first = static_cast(std::distance(std::begin(_results), itToInsertTo)); + const auto last = first + results.size() - 1; + + beginInsertRows({}, first, last); + std::copy(std::begin(results), std::end(results), std::inserter(_results, itToInsertTo)); + endInsertRows(); +} + +void UnifiedSearchResultsListModel::appendResultsToProvider(const QVector &results, const UnifiedSearchProvider &provider) +{ + if (results.isEmpty()) { + return; + } + + const auto providerId = provider._id; + /* we need to find the last result that is not a fetch-more-trigger or category-separator for the current + provider */ + const auto itLastResultForProviderReverse = + std::find_if(std::rbegin(_results), std::rend(_results), [&providerId](const UnifiedSearchResult &result) { + return result._providerId == providerId && result._type == UnifiedSearchResult::Type::Default; + }); + + if (itLastResultForProviderReverse != std::rend(_results)) { + // #1 Insert rows + // convert reverse_iterator to iterator + const auto itLastResultForProvider = (itLastResultForProviderReverse + 1).base(); + const auto first = static_cast(std::distance(std::begin(_results), itLastResultForProvider + 1)); + const auto last = first + results.size() - 1; + beginInsertRows({}, first, last); + std::copy(std::begin(results), std::end(results), std::inserter(_results, itLastResultForProvider + 1)); + endInsertRows(); + + // #2 Remove the FetchMoreTrigger item if there are no more results to load for this provider + if (!provider._isPaginated) { + removeFetchMoreTrigger(providerId); + } + } +} + +void UnifiedSearchResultsListModel::removeFetchMoreTrigger(const QString &providerId) +{ + const auto itFetchMoreTriggerForProviderReverse = std::find_if( + std::rbegin(_results), + std::rend(_results), + [providerId](const UnifiedSearchResult &result) { + return result._providerId == providerId && result._type == UnifiedSearchResult::Type::FetchMoreTrigger; + }); + + if (itFetchMoreTriggerForProviderReverse != std::rend(_results)) { + // convert reverse_iterator to iterator + const auto itFetchMoreTriggerForProvider = (itFetchMoreTriggerForProviderReverse + 1).base(); + + if (itFetchMoreTriggerForProvider != std::end(_results) + && itFetchMoreTriggerForProvider != std::begin(_results)) { + const auto eraseIndex = static_cast(std::distance(std::begin(_results), itFetchMoreTriggerForProvider)); + Q_ASSERT(eraseIndex >= 0 && eraseIndex < static_cast(_results.size())); + beginRemoveRows({}, eraseIndex, eraseIndex); + _results.erase(itFetchMoreTriggerForProvider); + endRemoveRows(); + } + } +} + +void UnifiedSearchResultsListModel::disconnectAndClearSearchJobs() +{ + for (const auto &connection : _searchJobConnections) { + if (connection) { + QObject::disconnect(connection); + } + } + + if (!_searchJobConnections.isEmpty()) { + _searchJobConnections.clear(); + emit isSearchInProgressChanged(); + } +} + +void UnifiedSearchResultsListModel::clearCurrentFetchMoreInProgressProviderId() +{ + if (!_currentFetchMoreInProgressProviderId.isEmpty()) { + _currentFetchMoreInProgressProviderId.clear(); + emit currentFetchMoreInProgressProviderIdChanged(); + } +} + +} diff --git a/src/gui/tray/unifiedsearchresultslistmodel.h b/src/gui/tray/unifiedsearchresultslistmodel.h new file mode 100644 index 0000000000..5ae811f205 --- /dev/null +++ b/src/gui/tray/unifiedsearchresultslistmodel.h @@ -0,0 +1,129 @@ +/* + * Copyright (C) by Oleksandr Zolotov + * + * 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) any later version. + * + * 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. + */ + +#pragma once + +#include "unifiedsearchresult.h" + +#include + +#include + +namespace OCC { +class AccountState; + +/** + * @brief The UnifiedSearchResultsListModel + * @ingroup gui + * Simple list model to provide the list view with data for the Unified Search results. + */ + +class UnifiedSearchResultsListModel : public QAbstractListModel +{ + Q_OBJECT + + Q_PROPERTY(bool isSearchInProgress READ isSearchInProgress NOTIFY isSearchInProgressChanged) + Q_PROPERTY(QString currentFetchMoreInProgressProviderId READ currentFetchMoreInProgressProviderId NOTIFY + currentFetchMoreInProgressProviderIdChanged) + Q_PROPERTY(QString errorString READ errorString NOTIFY errorStringChanged) + Q_PROPERTY(QString searchTerm READ searchTerm WRITE setSearchTerm NOTIFY searchTermChanged) + + struct UnifiedSearchProvider + { + QString _id; + QString _name; + qint32 _cursor = -1; // current pagination value + qint32 _pageSize = -1; // how many max items per step of pagination + bool _isPaginated = false; + qint32 _order = std::numeric_limits::max(); // sorting order (smaller number has bigger priority) + }; + +public: + enum DataRole { + ProviderNameRole = Qt::UserRole + 1, + ProviderIdRole, + ImagePlaceholderRole, + IconsRole, + TitleRole, + SublineRole, + ResourceUrlRole, + RoundedRole, + TypeRole, + TypeAsStringRole, + }; + + explicit UnifiedSearchResultsListModel(AccountState *accountState, QObject *parent = nullptr); + + QVariant data(const QModelIndex &index, int role) const override; + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + bool isSearchInProgress() const; + + QString currentFetchMoreInProgressProviderId() const; + QString searchTerm() const; + QString errorString() const; + + Q_INVOKABLE void resultClicked(const QString &providerId, const QUrl &resourceUrl) const; + Q_INVOKABLE void fetchMoreTriggerClicked(const QString &providerId); + + QHash roleNames() const override; + +private: + void startSearch(); + void startSearchForProvider(const QString &providerId, qint32 cursor = -1); + + void parseResultsForProvider(const QJsonObject &data, const QString &providerId, bool fetchedMore = false); + + // append initial search results to the list + void appendResults(QVector results, const UnifiedSearchProvider &provider); + + // append pagination results to existing results from the initial search + void appendResultsToProvider(const QVector &results, const UnifiedSearchProvider &provider); + + void removeFetchMoreTrigger(const QString &providerId); + + void disconnectAndClearSearchJobs(); + + void clearCurrentFetchMoreInProgressProviderId(); + +signals: + void currentFetchMoreInProgressProviderIdChanged(); + void isSearchInProgressChanged(); + void errorStringChanged(); + void searchTermChanged(); + +public slots: + void setSearchTerm(const QString &term); + +private slots: + void slotSearchTermEditingFinished(); + void slotFetchProvidersFinished(const QJsonDocument &json, int statusCode); + void slotSearchForProviderFinished(const QJsonDocument &json, int statusCode); + +private: + QMap _providers; + QVector _results; + + QString _searchTerm; + QString _errorString; + + QString _currentFetchMoreInProgressProviderId; + + QMap _searchJobConnections; + + QTimer _unifiedSearchTextEditingFinishedTimer; + + AccountState *_accountState = nullptr; +}; +} diff --git a/src/libsync/theme.cpp b/src/libsync/theme.cpp index 699951130b..d8fdd609e0 100644 --- a/src/libsync/theme.cpp +++ b/src/libsync/theme.cpp @@ -841,4 +841,19 @@ bool Theme::showVirtualFilesOption() const return ConfigFile().showExperimentalOptions() || vfsMode == Vfs::WindowsCfApi; } +QColor Theme::errorBoxTextColor() const +{ + return QColor{"white"}; +} + +QColor Theme::errorBoxBackgroundColor() const +{ + return QColor{"red"}; +} + +QColor Theme::errorBoxBorderColor() const +{ + return QColor{"black"}; +} + } // end namespace client diff --git a/src/libsync/theme.h b/src/libsync/theme.h index 37e3c52c1d..ec9a5dd42a 100644 --- a/src/libsync/theme.h +++ b/src/libsync/theme.h @@ -61,6 +61,10 @@ class OWNCLOUDSYNC_EXPORT Theme : public QObject Q_PROPERTY(QColor wizardHeaderBackgroundColor READ wizardHeaderBackgroundColor CONSTANT) #endif Q_PROPERTY(QString updateCheckUrl READ updateCheckUrl CONSTANT) + + Q_PROPERTY(QColor errorBoxTextColor READ errorBoxTextColor CONSTANT) + Q_PROPERTY(QColor errorBoxBackgroundColor READ errorBoxBackgroundColor CONSTANT) + Q_PROPERTY(QColor errorBoxBorderColor READ errorBoxBorderColor CONSTANT) public: enum CustomMediaType { oCSetupTop, // ownCloud connect page @@ -547,6 +551,15 @@ public: */ virtual bool showVirtualFilesOption() const; + /** @return color for the ErrorBox text. */ + virtual QColor errorBoxTextColor() const; + + /** @return color for the ErrorBox background. */ + virtual QColor errorBoxBackgroundColor() const; + + /** @return color for the ErrorBox border. */ + virtual QColor errorBoxBorderColor() const; + static constexpr const char *themePrefix = ":/client/theme/"; protected: diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 9e118c3c81..987fb8c9f4 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -60,6 +60,7 @@ nextcloud_add_test(Theme) nextcloud_add_test(IconUtils) nextcloud_add_test(NotificationCache) nextcloud_add_test(SetUserStatusDialog) +nextcloud_add_test(UnifiedSearchListmodel) if( UNIX AND NOT APPLE ) nextcloud_add_test(InotifyWatcher) diff --git a/test/syncenginetestutils.cpp b/test/syncenginetestutils.cpp index 7b5caa94a1..dcf641aa88 100644 --- a/test/syncenginetestutils.cpp +++ b/test/syncenginetestutils.cpp @@ -709,14 +709,20 @@ void FakeChunkMoveReply::abort() } FakePayloadReply::FakePayloadReply(QNetworkAccessManager::Operation op, const QNetworkRequest &request, const QByteArray &body, QObject *parent) - : FakeReply { parent } + : FakePayloadReply(op, request, body, FakePayloadReply::defaultDelay, parent) +{ +} + +FakePayloadReply::FakePayloadReply( + QNetworkAccessManager::Operation op, const QNetworkRequest &request, const QByteArray &body, int delay, QObject *parent) + : FakeReply{parent} , _body(body) { setRequest(request); setUrl(request.url()); setOperation(op); open(QIODevice::ReadOnly); - QTimer::singleShot(10, this, &FakePayloadReply::respond); + QTimer::singleShot(delay, this, &FakePayloadReply::respond); } void FakePayloadReply::respond() diff --git a/test/syncenginetestutils.h b/test/syncenginetestutils.h index 6b170866aa..e5624cd3cd 100644 --- a/test/syncenginetestutils.h +++ b/test/syncenginetestutils.h @@ -316,12 +316,17 @@ public: FakePayloadReply(QNetworkAccessManager::Operation op, const QNetworkRequest &request, const QByteArray &body, QObject *parent); + FakePayloadReply(QNetworkAccessManager::Operation op, const QNetworkRequest &request, + const QByteArray &body, int delay, QObject *parent); + void respond(); void abort() override {} qint64 readData(char *buf, qint64 max) override; qint64 bytesAvailable() const override; QByteArray _body; + + static const int defaultDelay = 10; }; diff --git a/test/testunifiedsearchlistmodel.cpp b/test/testunifiedsearchlistmodel.cpp new file mode 100644 index 0000000000..ea7de04bad --- /dev/null +++ b/test/testunifiedsearchlistmodel.cpp @@ -0,0 +1,640 @@ +/* + * Copyright (C) by Oleksandr Zolotov + * + * 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) any later version. + * + * 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. + */ + +#include "gui/tray/unifiedsearchresultslistmodel.h" + +#include "account.h" +#include "accountstate.h" +#include "syncenginetestutils.h" + +#include +#include +#include +#include + +namespace { +/** + * @brief The FakeDesktopServicesUrlHandler + * overrides QDesktopServices::openUrl + **/ +class FakeDesktopServicesUrlHandler : public QObject +{ + Q_OBJECT + +public: + FakeDesktopServicesUrlHandler(QObject *parent = nullptr) + : QObject(parent) + {} + +public: +signals: + void resultClicked(const QUrl &url); +}; + +/** + * @brief The FakeProvider + * is a simple structure that represents initial list of providers and their properties + **/ +class FakeProvider +{ +public: + QString _id; + QString _name; + qint32 _order = std::numeric_limits::max(); + quint32 _numItemsToInsert = 5; // how many fake resuls to insert +}; + +// this will be used when initializing fake search results data for each provider +static const QVector fakeProvidersInitInfo = { + {QStringLiteral("settings_apps"), QStringLiteral("Apps"), -50, 10}, + {QStringLiteral("talk-message"), QStringLiteral("Messages"), -2, 17}, + {QStringLiteral("files"), QStringLiteral("Files"), 5, 3}, + {QStringLiteral("deck"), QStringLiteral("Deck"), 10, 5}, + {QStringLiteral("comments"), QStringLiteral("Comments"), 10, 2}, + {QStringLiteral("mail"), QStringLiteral("Mails"), 10, 15}, + {QStringLiteral("calendar"), QStringLiteral("Events"), 30, 11} +}; + +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":[]}} +)"; + +static QByteArray fake400Response = R"( +{"ocs":{"meta":{"status":"failure","statuscode":400,"message":"Parameter is incorrect.\n"},"data":[]}} +)"; + +static QByteArray fake500Response = R"( +{"ocs":{"meta":{"status":"failure","statuscode":500,"message":"Internal Server Error.\n"},"data":[]}} +)"; + +/** + * @brief The FakeSearchResultsStorage + * emulates the real server storage that contains all the results that UnifiedSearchListmodel will search for + **/ +class FakeSearchResultsStorage +{ + class Provider + { + public: + class SearchResult + { + public: + QString _thumbnailUrl; + QString _title; + QString _subline; + QString _resourceUrl; + QString _icon; + bool _rounded; + }; + + QString _id; + QString _name; + qint32 _order = std::numeric_limits::max(); + qint32 _cursor = 0; + bool _isPaginated = false; + QVector _results; + }; + + FakeSearchResultsStorage() = default; + +public: + static FakeSearchResultsStorage *instance() + { + if (!_instance) { + _instance = new FakeSearchResultsStorage(); + _instance->init(); + } + + return _instance; + }; + + static void destroy() + { + if (_instance) { + delete _instance; + } + + _instance = nullptr; + } + + void init() + { + if (!_searchResultsData.isEmpty()) { + return; + } + + _metaSuccess = {{QStringLiteral("status"), QStringLiteral("ok")}, {QStringLiteral("statuscode"), 200}, + {QStringLiteral("message"), QStringLiteral("OK")}}; + + initProvidersResponse(); + + initSearchResultsData(); + } + + // initialize the JSON response containing the fake list of providers and their properties + void initProvidersResponse() + { + QList providersList; + + for (const auto &fakeProviderInitInfo : fakeProvidersInitInfo) { + providersList.push_back(QVariantMap{ + {QStringLiteral("id"), fakeProviderInitInfo._id}, + {QStringLiteral("name"), fakeProviderInitInfo._name}, + {QStringLiteral("order"), fakeProviderInitInfo._order}, + }); + } + + const QVariantMap ocsMap = { + {QStringLiteral("meta"), _metaSuccess}, + {QStringLiteral("data"), providersList} + }; + + _providersResponse = + QJsonDocument::fromVariant(QVariantMap{{QStringLiteral("ocs"), ocsMap}}).toJson(QJsonDocument::Compact); + } + + // init the map of fake search results for each provider + void initSearchResultsData() + { + for (const auto &fakeProvider : fakeProvidersInitInfo) { + auto &providerData = _searchResultsData[fakeProvider._id]; + providerData._id = fakeProvider._id; + providerData._name = fakeProvider._name; + providerData._order = fakeProvider._order; + if (fakeProvider._numItemsToInsert > pageSize) { + providerData._isPaginated = true; + } + for (quint32 i = 0; i < fakeProvider._numItemsToInsert; ++i) { + providerData._results.push_back( + {"http://example.de/avatar/john/64", QString(QStringLiteral("John Doe in ") + fakeProvider._name), + QString(QStringLiteral("We a discussion about ") + fakeProvider._name + + QStringLiteral(" already. But, let's have a follow up tomorrow afternoon.")), + "http://example.de/call/abcde12345#message_12345", QStringLiteral("icon-talk"), true}); + } + } + } + + const QList resultsForProvider(const QString &providerId, int cursor) + { + QList list; + + const auto results = resultsForProviderAsVector(providerId, cursor); + + if (results.isEmpty()) { + return list; + } + + for (const auto &result : results) { + list.push_back(QVariantMap{ + {"thumbnailUrl", result._thumbnailUrl}, + {"title", result._title}, + {"subline", result._subline}, + {"resourceUrl", result._resourceUrl}, + {"icon", result._icon}, + {"rounded", result._rounded} + }); + } + + return list; + } + + const QVector resultsForProviderAsVector(const QString &providerId, int cursor) + { + QVector results; + + const auto provider = _searchResultsData.value(providerId, Provider()); + + if (provider._id.isEmpty() || cursor > provider._results.size()) { + return results; + } + + const int n = cursor + pageSize > provider._results.size() + ? 0 + : cursor + pageSize; + + for (int i = cursor; i < n; ++i) { + results.push_back(provider._results[i]); + } + + return results; + } + + const QByteArray queryProvider(const QString &providerId, const QString &searchTerm, int cursor) + { + if (!_searchResultsData.contains(providerId)) { + return fake404Response; + } + + if (searchTerm == QStringLiteral("[HTTP500]")) { + return fake500Response; + } + + if (searchTerm == QStringLiteral("[empty]")) { + const QVariantMap dataMap = {{QStringLiteral("name"), _searchResultsData[providerId]._name}, + {QStringLiteral("isPaginated"), false}, {QStringLiteral("cursor"), 0}, + {QStringLiteral("entries"), QVariantList{}}}; + + const QVariantMap ocsMap = {{QStringLiteral("meta"), _metaSuccess}, {QStringLiteral("data"), dataMap}}; + + return QJsonDocument::fromVariant(QVariantMap{{QStringLiteral("ocs"), ocsMap}}) + .toJson(QJsonDocument::Compact); + } + + const auto provider = _searchResultsData.value(providerId, Provider()); + + const auto nextCursor = cursor + pageSize; + + const QVariantMap dataMap = {{QStringLiteral("name"), _searchResultsData[providerId]._name}, + {QStringLiteral("isPaginated"), _searchResultsData[providerId]._isPaginated}, + {QStringLiteral("cursor"), nextCursor}, + {QStringLiteral("entries"), resultsForProvider(providerId, cursor)}}; + + const QVariantMap ocsMap = {{QStringLiteral("meta"), _metaSuccess}, {QStringLiteral("data"), dataMap}}; + + return QJsonDocument::fromVariant(QVariantMap{{QStringLiteral("ocs"), ocsMap}}).toJson(QJsonDocument::Compact); + } + + const QByteArray &fakeProvidersResponseJson() const { return _providersResponse; } + +private: + static FakeSearchResultsStorage *_instance; + + static const int pageSize = 5; + + QMap _searchResultsData; + + QByteArray _providersResponse = fake404Response; + + QVariantMap _metaSuccess; +}; + +FakeSearchResultsStorage *FakeSearchResultsStorage::_instance = nullptr; + +} + +class TestUnifiedSearchListmodel : public QObject +{ + Q_OBJECT + +public: + TestUnifiedSearchListmodel() = default; + + QScopedPointer fakeQnam; + OCC::AccountPtr account; + QScopedPointer accountState; + QScopedPointer model; + QScopedPointer modelTester; + + QScopedPointer fakeDesktopServicesUrlHandler; + + static const int searchResultsReplyDelay = 100; + +private slots: + void initTestCase() + { + fakeQnam.reset(new FakeQNAM({})); + account = OCC::Account::create(); + account->setCredentials(new FakeCredentials{fakeQnam.data()}); + account->setUrl(QUrl(("http://example.de"))); + + accountState.reset(new OCC::AccountState(account)); + + fakeQnam->setOverride([this](QNetworkAccessManager::Operation op, const QNetworkRequest &req, QIODevice *device) { + Q_UNUSED(device); + QNetworkReply *reply = nullptr; + + const auto urlQuery = QUrlQuery(req.url()); + const auto format = urlQuery.queryItemValue(QStringLiteral("format")); + const auto cursor = urlQuery.queryItemValue(QStringLiteral("cursor")).toInt(); + const auto searchTerm = urlQuery.queryItemValue(QStringLiteral("term")); + const auto path = req.url().path(); + + if (!req.url().toString().startsWith(accountState->account()->url().toString())) { + reply = new FakeErrorReply(op, req, this, 404, fake404Response); + } + if (format != QStringLiteral("json")) { + reply = new FakeErrorReply(op, req, this, 400, fake400Response); + } + + // handle fetch of providers list + if (path.startsWith(QStringLiteral("/ocs/v2.php/search/providers")) && searchTerm.isEmpty()) { + reply = new FakePayloadReply(op, req, + FakeSearchResultsStorage::instance()->fakeProvidersResponseJson(), fakeQnam.data()); + // handle search for provider + } else if (path.startsWith(QStringLiteral("/ocs/v2.php/search/providers")) && !searchTerm.isEmpty()) { + const auto pathSplit = path.mid(QString(QStringLiteral("/ocs/v2.php/search/providers")).size()) + .split(QLatin1Char('/'), Qt::SkipEmptyParts); + + if (!pathSplit.isEmpty() && path.contains(pathSplit.first())) { + reply = new FakePayloadReply(op, req, + FakeSearchResultsStorage::instance()->queryProvider(pathSplit.first(), searchTerm, cursor), + searchResultsReplyDelay, fakeQnam.data()); + } + } + + if (!reply) { + return qobject_cast(new FakeErrorReply(op, req, this, 404, QByteArrayLiteral("{error: \"Not found!\"}"))); + } + + return reply; + }); + + model.reset(new OCC::UnifiedSearchResultsListModel(accountState.data())); + + modelTester.reset(new QAbstractItemModelTester(model.data())); + + fakeDesktopServicesUrlHandler.reset(new FakeDesktopServicesUrlHandler); + } + void testSetSearchTermStartStopSearch() + { + // make sure the model is empty + model->setSearchTerm(QStringLiteral("")); + QVERIFY(model->rowCount() == 0); + + // #1 test setSearchTerm actually sets the search term and the signal is emitted + QSignalSpy searhTermChanged(model.data(), &OCC::UnifiedSearchResultsListModel::searchTermChanged); + model->setSearchTerm(QStringLiteral("dis")); + QCOMPARE(searhTermChanged.count(), 1); + QCOMPARE(model->searchTerm(), QStringLiteral("dis")); + + // #2 test setSearchTerm actually sets the search term and the signal is emitted + searhTermChanged.clear(); + model->setSearchTerm(model->searchTerm() + QStringLiteral("cuss")); + QCOMPARE(model->searchTerm(), QStringLiteral("discuss")); + QCOMPARE(searhTermChanged.count(), 1); + + // #3 test that model has not started search yet + QVERIFY(!model->isSearchInProgress()); + + + // #4 test that model has started the search after specific delay + QSignalSpy searchInProgressChanged(model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged); + // allow search jobs to get created within the model + QVERIFY(searchInProgressChanged.wait()); + QCOMPARE(searchInProgressChanged.count(), 1); + QVERIFY(model->isSearchInProgress()); + + // #5 test that model has stopped the search after setting empty search term + model->setSearchTerm(QStringLiteral("")); + QVERIFY(!model->isSearchInProgress()); + } + + void testSetSearchTermResultsFound() + { + // make sure the model is empty + model->setSearchTerm(QStringLiteral("")); + QVERIFY(model->rowCount() == 0); + + // test that search term gets set, search gets started and enough results get returned + model->setSearchTerm(model->searchTerm() + QStringLiteral("discuss")); + + QSignalSpy searchInProgressChanged( + model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged); + + QVERIFY(searchInProgressChanged.wait()); + + // make sure search has started + QCOMPARE(searchInProgressChanged.count(), 1); + QVERIFY(model->isSearchInProgress()); + + QVERIFY(searchInProgressChanged.wait()); + + // make sure search has finished + QVERIFY(!model->isSearchInProgress()); + + QVERIFY(model->rowCount() > 0); + } + + void testSetSearchTermResultsNotFound() + { + // make sure the model is empty + model->setSearchTerm(QStringLiteral("")); + QVERIFY(model->rowCount() == 0); + + // test that search term gets set, search gets started and enough results get returned + model->setSearchTerm(model->searchTerm() + QStringLiteral("[empty]")); + + QSignalSpy searchInProgressChanged( + model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged); + + QVERIFY(searchInProgressChanged.wait()); + + // make sure search has started + QCOMPARE(searchInProgressChanged.count(), 1); + QVERIFY(model->isSearchInProgress()); + + QVERIFY(searchInProgressChanged.wait()); + + // make sure search has finished + QVERIFY(!model->isSearchInProgress()); + + QVERIFY(model->rowCount() == 0); + } + + void testFetchMoreClicked() + { + // make sure the model is empty + model->setSearchTerm(QStringLiteral("")); + QVERIFY(model->rowCount() == 0); + + QSignalSpy searchInProgressChanged( + model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged); + + // test that search term gets set, search gets started and enough results get returned + model->setSearchTerm(model->searchTerm() + QStringLiteral("whatever")); + + QVERIFY(searchInProgressChanged.wait()); + + // make sure search has started + QVERIFY(model->isSearchInProgress()); + + QVERIFY(searchInProgressChanged.wait()); + + // make sure search has finished + QVERIFY(!model->isSearchInProgress()); + + const auto numRowsInModelPrev = model->rowCount(); + + // test fetch more results + QSignalSpy currentFetchMoreInProgressProviderIdChanged( + model.data(), &OCC::UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderIdChanged); + QSignalSpy rowsInserted(model.data(), &OCC::UnifiedSearchResultsListModel::rowsInserted); + for (int i = 0; i < model->rowCount(); ++i) { + const auto type = model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::TypeRole); + + if (type == OCC::UnifiedSearchResult::Type::FetchMoreTrigger) { + const auto providerId = + model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::ProviderIdRole) + .toString(); + model->fetchMoreTriggerClicked(providerId); + break; + } + } + + // make sure the currentFetchMoreInProgressProviderId was set back and forth and correct number fows has been inserted + QCOMPARE(currentFetchMoreInProgressProviderIdChanged.count(), 1); + + const auto providerIdFetchMoreTriggered = model->currentFetchMoreInProgressProviderId(); + + QVERIFY(!providerIdFetchMoreTriggered.isEmpty()); + + QVERIFY(currentFetchMoreInProgressProviderIdChanged.wait()); + + QVERIFY(model->currentFetchMoreInProgressProviderId().isEmpty()); + + QCOMPARE(rowsInserted.count(), 1); + + const auto arguments = rowsInserted.takeFirst(); + + QVERIFY(arguments.size() > 0); + + const auto first = arguments.at(0).toInt(); + const auto last = arguments.at(1).toInt(); + + const int numInsertedExpected = last - first; + + QCOMPARE(model->rowCount() - numRowsInModelPrev, numInsertedExpected); + + // make sure the FetchMoreTrigger gets removed when no more results available + if (!providerIdFetchMoreTriggered.isEmpty()) { + currentFetchMoreInProgressProviderIdChanged.clear(); + rowsInserted.clear(); + + QSignalSpy rowsRemoved(model.data(), &OCC::UnifiedSearchResultsListModel::rowsRemoved); + + for (int i = 0; i < 10; ++i) { + model->fetchMoreTriggerClicked(providerIdFetchMoreTriggered); + + QVERIFY(currentFetchMoreInProgressProviderIdChanged.wait()); + + if (rowsRemoved.count() > 0) { + break; + } + } + + QCOMPARE(rowsRemoved.count(), 1); + + bool isFetchMoreTriggerFound = false; + + for (int i = 0; i < model->rowCount(); ++i) { + const auto type = model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::TypeRole); + const auto providerId = model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::ProviderIdRole) + .toString(); + if (type == OCC::UnifiedSearchResult::Type::FetchMoreTrigger + && providerId == providerIdFetchMoreTriggered) { + isFetchMoreTriggerFound = true; + break; + } + } + + QVERIFY(!isFetchMoreTriggerFound); + } + } + + void testSearchResultlicked() + { + // make sure the model is empty + model->setSearchTerm(QStringLiteral("")); + QVERIFY(model->rowCount() == 0); + + // test that search term gets set, search gets started and enough results get returned + model->setSearchTerm(model->searchTerm() + QStringLiteral("discuss")); + + QSignalSpy searchInProgressChanged( + model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged); + + QVERIFY(searchInProgressChanged.wait()); + + // make sure search has started + QCOMPARE(searchInProgressChanged.count(), 1); + QVERIFY(model->isSearchInProgress()); + + QVERIFY(searchInProgressChanged.wait()); + + // make sure search has finished and some results has been received + QVERIFY(!model->isSearchInProgress()); + + QVERIFY(model->rowCount() != 0); + + QDesktopServices::setUrlHandler("http", fakeDesktopServicesUrlHandler.data(), "resultClicked"); + QDesktopServices::setUrlHandler("https", fakeDesktopServicesUrlHandler.data(), "resultClicked"); + + QSignalSpy resultClicked(fakeDesktopServicesUrlHandler.data(), &FakeDesktopServicesUrlHandler::resultClicked); + + // test click on a result item + QString urlForClickedResult; + + for (int i = 0; i < model->rowCount(); ++i) { + const auto type = model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::TypeRole); + + if (type == OCC::UnifiedSearchResult::Type::Default) { + const auto providerId = + model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::ProviderIdRole) + .toString(); + urlForClickedResult = model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::ResourceUrlRole).toString(); + + if (!providerId.isEmpty() && !urlForClickedResult.isEmpty()) { + model->resultClicked(providerId, QUrl(urlForClickedResult)); + break; + } + } + } + + QCOMPARE(resultClicked.count(), 1); + + const auto arguments = resultClicked.takeFirst(); + + const auto urlOpenTriggeredViaDesktopServices = arguments.at(0).toString(); + + QCOMPARE(urlOpenTriggeredViaDesktopServices, urlForClickedResult); + } + + void testSetSearchTermResultsError() + { + // make sure the model is empty + model->setSearchTerm(QStringLiteral("")); + QVERIFY(model->rowCount() == 0); + + QSignalSpy errorStringChanged(model.data(), &OCC::UnifiedSearchResultsListModel::errorStringChanged); + QSignalSpy searchInProgressChanged( + model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged); + + model->setSearchTerm(model->searchTerm() + QStringLiteral("[HTTP500]")); + + QVERIFY(searchInProgressChanged.wait()); + + // make sure search has started + QVERIFY(model->isSearchInProgress()); + + QVERIFY(searchInProgressChanged.wait()); + + // make sure search has finished + QVERIFY(!model->isSearchInProgress()); + + // make sure the model is empty and an error string has been set + QVERIFY(model->rowCount() == 0); + + QVERIFY(errorStringChanged.count() > 0); + + QVERIFY(!model->errorString().isEmpty()); + } + + void cleanupTestCase() + { + FakeSearchResultsStorage::destroy(); + } +}; + +QTEST_MAIN(TestUnifiedSearchListmodel) +#include "testunifiedsearchlistmodel.moc" diff --git a/theme.qrc.in b/theme.qrc.in index c377967342..aba4639b4f 100644 --- a/theme.qrc.in +++ b/theme.qrc.in @@ -44,6 +44,9 @@ theme/white/state-sync-64.png theme/white/state-sync-128.png theme/white/state-sync-256.png + theme/black/clear.svg + theme/black/comment.svg + theme/black/search.svg theme/black/state-error-32.png theme/black/state-error-64.png theme/black/state-error-128.png @@ -81,6 +84,7 @@ theme/colored/state-warning-128.png theme/colored/state-warning-256.png theme/black/folder.png + theme/black/folder.svg theme/black/folder@2x.png theme/white/folder.png theme/white/folder@2x.png @@ -147,6 +151,7 @@ theme/black/wizard-talk.png theme/black/wizard-talk@2x.png theme/black/wizard-files.png + theme/black/wizard-groupware.svg theme/colored/wizard-files.png theme/colored/wizard-files@2x.png theme/colored/wizard-groupware.png @@ -173,6 +178,9 @@ theme/black/add.svg theme/black/activity.svg theme/black/bell.svg + theme/black/wizard-talk.svg + theme/black/calendar.svg + theme/black/deck.svg theme/black/state-info.svg theme/close.svg theme/files.svg diff --git a/theme/Style/Style.qml b/theme/Style/Style.qml index ea5aabde02..b29db7aafb 100644 --- a/theme/Style/Style.qml +++ b/theme/Style/Style.qml @@ -12,6 +12,11 @@ QtObject { property color lightHover: "#f7f7f7" property color menuBorder: "#bdbdbd" + // ErrorBox colors + property color errorBoxTextColor: Theme.errorBoxTextColor + property color errorBoxBackgroundColor: Theme.errorBoxBackgroundColor + property color errorBoxBorderColor: Theme.errorBoxBorderColor + // Fonts // We are using pixel size because this is cross platform comparable, point size isn't property int topLinePixelSize: 12 @@ -56,4 +61,15 @@ QtObject { // Visual behaviour property bool hoverEffectsEnabled: true + + // unified search constants + readonly property int unifiedSearchItemHeight: trayWindowHeaderHeight + readonly property int unifiedSearchResultTextLeftMargin: 18 + readonly property int unifiedSearchResultTextRightMargin: 16 + readonly property int unifiedSearchResulIconWidth: 24 + readonly property int unifiedSearchResulIconLeftMargin: 12 + readonly property int unifiedSearchResulTitleFontSize: topLinePixelSize + readonly property int unifiedSearchResulSublineFontSize: subLinePixelSize + readonly property string unifiedSearchResulTitleColor: "black" + readonly property string unifiedSearchResulSublineColor: "grey" } diff --git a/theme/black/calendar.svg b/theme/black/calendar.svg new file mode 100644 index 0000000000..4ea05fefe9 --- /dev/null +++ b/theme/black/calendar.svg @@ -0,0 +1 @@ + diff --git a/theme/black/clear.svg b/theme/black/clear.svg new file mode 100644 index 0000000000..3cf9469e0a --- /dev/null +++ b/theme/black/clear.svg @@ -0,0 +1 @@ + diff --git a/theme/black/comment.svg b/theme/black/comment.svg new file mode 100644 index 0000000000..67c3fee585 --- /dev/null +++ b/theme/black/comment.svg @@ -0,0 +1 @@ + diff --git a/theme/black/deck.svg b/theme/black/deck.svg new file mode 100644 index 0000000000..1227624968 --- /dev/null +++ b/theme/black/deck.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/theme/black/search.svg b/theme/black/search.svg new file mode 100644 index 0000000000..58e7264c60 --- /dev/null +++ b/theme/black/search.svg @@ -0,0 +1 @@ + From 1b8448402a1792cd211b661497e65bc741c6007d Mon Sep 17 00:00:00 2001 From: alex-z Date: Thu, 7 Oct 2021 16:43:54 +0300 Subject: [PATCH 2/2] Fix JsonApiJob incorrect 'statuscode' parsing. Signed-off-by: alex-z --- src/libsync/networkjobs.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libsync/networkjobs.cpp b/src/libsync/networkjobs.cpp index 96739c906b..5bd60f8d40 100644 --- a/src/libsync/networkjobs.cpp +++ b/src/libsync/networkjobs.cpp @@ -901,7 +901,7 @@ bool JsonApiJob::finished() qCWarning(lcJsonApiJob) << "Nothing changed so nothing to retrieve - status code: " << httpStatusCode; statusCode = httpStatusCode; } else { - QRegExp rex(R"("statuscode":(\d+),)"); + QRegExp rex(R"("statuscode":(\d+))"); // example: "{"ocs":{"meta":{"status":"ok","statuscode":100,"message":null},"data":{"version":{"major":8,"minor":"... (504) if (jsonStr.contains(rex)) { statusCode = rex.cap(1).toInt();