Переглянути джерело

Unified Search via Tray window

Signed-off-by: alex-z <blackslayer4@gmail.com>
alex-z 4 роки тому
батько
коміт
c1dab7e4cb
35 змінених файлів з 2527 додано та 23 видалено
  1. 8 0
      resources.qrc
  2. 11 0
      src/gui/CMakeLists.txt
  3. 9 3
      src/gui/ErrorBox.qml
  4. 4 0
      src/gui/main.cpp
  5. 2 0
      src/gui/systray.cpp
  6. 110 0
      src/gui/tray/UnifiedSearchInputContainer.qml
  7. 42 0
      src/gui/tray/UnifiedSearchResultFetchMoreTrigger.qml
  8. 107 0
      src/gui/tray/UnifiedSearchResultItem.qml
  9. 58 0
      src/gui/tray/UnifiedSearchResultItemSkeleton.qml
  10. 49 0
      src/gui/tray/UnifiedSearchResultItemSkeletonContainer.qml
  11. 87 0
      src/gui/tray/UnifiedSearchResultListItem.qml
  12. 47 0
      src/gui/tray/UnifiedSearchResultNothingFound.qml
  13. 20 0
      src/gui/tray/UnifiedSearchResultSectionItem.qml
  14. 8 1
      src/gui/tray/UserModel.cpp
  15. 4 0
      src/gui/tray/UserModel.h
  16. 159 17
      src/gui/tray/Window.qml
  17. 36 0
      src/gui/tray/unifiedsearchresult.cpp
  18. 48 0
      src/gui/tray/unifiedsearchresult.h
  19. 131 0
      src/gui/tray/unifiedsearchresultimageprovider.cpp
  20. 33 0
      src/gui/tray/unifiedsearchresultimageprovider.h
  21. 708 0
      src/gui/tray/unifiedsearchresultslistmodel.cpp
  22. 129 0
      src/gui/tray/unifiedsearchresultslistmodel.h
  23. 15 0
      src/libsync/theme.cpp
  24. 13 0
      src/libsync/theme.h
  25. 1 0
      test/CMakeLists.txt
  26. 8 2
      test/syncenginetestutils.cpp
  27. 5 0
      test/syncenginetestutils.h
  28. 640 0
      test/testunifiedsearchlistmodel.cpp
  29. 8 0
      theme.qrc.in
  30. 16 0
      theme/Style/Style.qml
  31. 0 0
      theme/black/calendar.svg
  32. 1 0
      theme/black/clear.svg
  33. 1 0
      theme/black/comment.svg
  34. 8 0
      theme/black/deck.svg
  35. 1 0
      theme/black/search.svg

+ 8 - 0
resources.qrc

@@ -15,5 +15,13 @@
         <file>src/gui/tray/AutoSizingMenu.qml</file>
         <file>src/gui/tray/ActivityList.qml</file>
         <file>src/gui/tray/FileActivityDialog.qml</file>
+        <file>src/gui/tray/UnifiedSearchInputContainer.qml</file>
+        <file>src/gui/tray/UnifiedSearchResultFetchMoreTrigger.qml</file>
+        <file>src/gui/tray/UnifiedSearchResultItem.qml</file>
+        <file>src/gui/tray/UnifiedSearchResultItemSkeleton.qml</file>
+        <file>src/gui/tray/UnifiedSearchResultItemSkeletonContainer.qml</file>
+        <file>src/gui/tray/UnifiedSearchResultListItem.qml</file>
+        <file>src/gui/tray/UnifiedSearchResultNothingFound.qml</file>
+        <file>src/gui/tray/UnifiedSearchResultSectionItem.qml</file>
     </qresource>
 </RCC>

+ 11 - 0
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

+ 9 - 3
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
     }

+ 4 - 0
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<UserStatusSelectorModel>("com.nextcloud.desktopclient", 1, 0, "UserStatusSelectorModel");
     qmlRegisterType<OCC::ActivityListModel>("com.nextcloud.desktopclient", 1, 0, "ActivityListModel");
     qmlRegisterType<OCC::FileActivityListModel>("com.nextcloud.desktopclient", 1, 0, "FileActivityListModel");
+    qmlRegisterUncreatableType<OCC::UnifiedSearchResultsListModel>(
+        "com.nextcloud.desktopclient", 1, 0, "UnifiedSearchResultsListModel", "UnifiedSearchResultsListModel");
+    qRegisterMetaType<UnifiedSearchResultsListModel *>("UnifiedSearchResultsListModel*");
 
     qmlRegisterUncreatableType<OCC::UserStatus>("com.nextcloud.desktopclient", 1, 0, "UserStatus", "Access to Status enum");
 

+ 2 - 0
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 <QCursor>
@@ -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()

+ 110 - 0
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()
+            }
+        }
+    }
+}

+ 42 - 0
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
+    }
+}

+ 107 - 0
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
+        }
+    }
+
+}

+ 58 - 0
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
+        }
+    }
+}

+ 49 - 0
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;
+        }
+    }
+}

+ 87 - 0
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)
+        }
+    }
+}

+ 47 - 0
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
+    }
+}

+ 20 - 0
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)
+}

+ 8 - 1
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 <QDesktopServices>
@@ -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();

+ 4 - 0
src/gui/tray/UserModel.h

@@ -18,6 +18,7 @@
 #include <chrono>
 
 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;

+ 159 - 17
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

+ 36 - 0
src/gui/tray/unifiedsearchresult.cpp

@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
+ *
+ * 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 <QtCore>
+
+#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;
+}
+}

+ 48 - 0
src/gui/tray/unifiedsearchresult.h

@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
+ *
+ * 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 <limits>
+
+#include <QtCore>
+
+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<qint32>::max();
+    QUrl _resourceUrl;
+    QString _icons;
+    Type _type = Type::Default;
+};
+}

+ 131 - 0
src/gui/tray/unifiedsearchresultimageprovider.cpp

@@ -0,0 +1,131 @@
+/*
+ * Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
+ *
+ * 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 <QImage>
+#include <QPainter>
+#include <QSvgRenderer>
+
+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<QNetworkReply *>(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("<svg"))) {
+                // SVG image needs proper scaling, let's do it with QPainter and QSvgRenderer
+                QSvgRenderer svgRenderer;
+                if (svgRenderer.load(imageData)) {
+                    QImage scaledSvg(_requestedImageSize, QImage::Format_ARGB32);
+                    scaledSvg.fill("transparent");
+                    QPainter painterForSvg(&scaledSvg);
+                    svgRenderer.render(&painterForSvg);
+                    setImageAndEmitFinished(scaledSvg);
+                    return;
+                } else {
+                    processNextImage();
+                }
+            } else {
+                setImageAndEmitFinished(QImage::fromData(imageData));
+            }
+        }
+    }
+
+    QImage _image;
+    QStringList _imagePaths;
+    QSize _requestedImageSize;
+    int _index = 0;
+};
+}
+
+namespace OCC {
+
+QQuickImageResponse *UnifiedSearchResultImageProvider::requestImageResponse(const QString &id, const QSize &requestedSize)
+{
+    return new AsyncImageResponse(id, requestedSize);
+}
+
+}

+ 33 - 0
src/gui/tray/unifiedsearchresultimageprovider.h

@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
+ *
+ * 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 <QtCore>
+#include <QQuickImageProvider>
+
+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;
+};
+}

+ 708 - 0
src/gui/tray/unifiedsearchresultslistmodel.cpp

@@ -0,0 +1,708 @@
+/*
+ * Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
+ *
+ * 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 <algorithm>
+
+#include <QAbstractListModel>
+#include <QDesktopServices>
+
+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<int, QByteArray> 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<JsonApiJob *>(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<JsonApiJob *>(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<UnifiedSearchResult> 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<UnifiedSearchResult> 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 &current) {
+            // 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<int>(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<UnifiedSearchResult> &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<int>(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<int>(std::distance(std::begin(_results), itFetchMoreTriggerForProvider));
+            Q_ASSERT(eraseIndex >= 0 && eraseIndex < static_cast<int>(_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();
+    }
+}
+
+}

+ 129 - 0
src/gui/tray/unifiedsearchresultslistmodel.h

@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
+ *
+ * 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 <limits>
+
+#include <QtCore>
+
+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<qint32>::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<int, QByteArray> 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<UnifiedSearchResult> results, const UnifiedSearchProvider &provider);
+
+    // append pagination results to existing results from the initial search
+    void appendResultsToProvider(const QVector<UnifiedSearchResult> &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<QString, UnifiedSearchProvider> _providers;
+    QVector<UnifiedSearchResult> _results;
+
+    QString _searchTerm;
+    QString _errorString;
+
+    QString _currentFetchMoreInProgressProviderId;
+
+    QMap<QString, QMetaObject::Connection> _searchJobConnections;
+
+    QTimer _unifiedSearchTextEditingFinishedTimer;
+
+    AccountState *_accountState = nullptr;
+};
+}

+ 15 - 0
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

+ 13 - 0
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:

+ 1 - 0
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)

+ 8 - 2
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()

+ 5 - 0
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;
 };
 
 

+ 640 - 0
test/testunifiedsearchlistmodel.cpp

@@ -0,0 +1,640 @@
+/*
+ * Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
+ *
+ * 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 <QAbstractItemModelTester>
+#include <QDesktopServices>
+#include <QSignalSpy>
+#include <QTest>
+
+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<qint32>::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<FakeProvider> 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<qint32>::max();
+        qint32 _cursor = 0;
+        bool _isPaginated = false;
+        QVector<SearchResult> _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<QVariant> 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<QVariant> resultsForProvider(const QString &providerId, int cursor)
+    {
+        QList<QVariant> 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<Provider::SearchResult> resultsForProviderAsVector(const QString &providerId, int cursor)
+    {
+        QVector<Provider::SearchResult> 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<QString, Provider> _searchResultsData;
+
+    QByteArray _providersResponse = fake404Response;
+
+    QVariantMap _metaSuccess;
+};
+
+FakeSearchResultsStorage *FakeSearchResultsStorage::_instance = nullptr;
+
+}
+
+class TestUnifiedSearchListmodel : public QObject
+{
+    Q_OBJECT
+
+public:
+    TestUnifiedSearchListmodel() = default;
+
+    QScopedPointer<FakeQNAM> fakeQnam;
+    OCC::AccountPtr account;
+    QScopedPointer<OCC::AccountState> accountState;
+    QScopedPointer<OCC::UnifiedSearchResultsListModel> model;
+    QScopedPointer<QAbstractItemModelTester> modelTester;
+
+    QScopedPointer<FakeDesktopServicesUrlHandler> 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<QNetworkReply*>(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"

+ 8 - 0
theme.qrc.in

@@ -44,6 +44,9 @@
         <file>theme/white/state-sync-64.png</file>
         <file>theme/white/state-sync-128.png</file>
         <file>theme/white/state-sync-256.png</file>
+        <file>theme/black/clear.svg</file>
+        <file>theme/black/comment.svg</file>
+        <file>theme/black/search.svg</file>
         <file>theme/black/state-error-32.png</file>
         <file>theme/black/state-error-64.png</file>
         <file>theme/black/state-error-128.png</file>
@@ -81,6 +84,7 @@
         <file>theme/colored/state-warning-128.png</file>
         <file>theme/colored/state-warning-256.png</file>
         <file>theme/black/folder.png</file>
+        <file>theme/black/folder.svg</file>
         <file>theme/black/folder@2x.png</file>
         <file>theme/white/folder.png</file>
         <file>theme/white/folder@2x.png</file>
@@ -147,6 +151,7 @@
         <file>theme/black/wizard-talk.png</file>
         <file>theme/black/wizard-talk@2x.png</file>
         <file>theme/black/wizard-files.png</file>
+        <file>theme/black/wizard-groupware.svg</file>
         <file>theme/colored/wizard-files.png</file>
         <file>theme/colored/wizard-files@2x.png</file>
         <file>theme/colored/wizard-groupware.png</file>
@@ -173,6 +178,9 @@
         <file>theme/black/add.svg</file>
         <file>theme/black/activity.svg</file>
         <file>theme/black/bell.svg</file>
+        <file>theme/black/wizard-talk.svg</file>
+        <file>theme/black/calendar.svg</file>
+        <file>theme/black/deck.svg</file>
         <file>theme/black/state-info.svg</file>
         <file>theme/close.svg</file>
         <file>theme/files.svg</file>

+ 16 - 0
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"
 }

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
theme/black/calendar.svg


+ 1 - 0
theme/black/clear.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>

+ 1 - 0
theme/black/comment.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18zM20 4v13.17L18.83 16H4V4h16zM6 12h12v2H6zm0-3h12v2H6zm0-3h12v2H6z"/></svg>

+ 8 - 0
theme/black/deck.svg

@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" version="1.1" viewBox="0 0 16 16">
+    <g fill="#000000">
+        <rect ry="1" height="8" width="14" y="7" x="1"/>
+        <rect ry=".5" height="1" width="12" y="5" x="2"/>
+        <rect ry=".5" height="1" width="10" y="3" x="3"/>
+        <rect ry=".5" height="1" width="8" y="1" x="4"/>
+    </g>
+</svg>

+ 1 - 0
theme/black/search.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>

Деякі файли не було показано, через те що забагато файлів було змінено