Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/desktop.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoralex-z <blackslayer4@gmail.com>2021-09-09 14:33:57 +0300
committeralex-z <blackslayer4@gmail.com>2021-10-25 14:45:05 +0300
commitc1dab7e4cb254bf492b458c8c0c1bab8f55b17b0 (patch)
tree66681ba48f072166942867ee9021a7934d633e6f
parentb8e2dc24f3c1d67424dd363af85f9311a702dd78 (diff)
Unified Search via Tray window
Signed-off-by: alex-z <blackslayer4@gmail.com>
-rw-r--r--resources.qrc8
-rw-r--r--src/gui/CMakeLists.txt11
-rw-r--r--src/gui/ErrorBox.qml12
-rw-r--r--src/gui/main.cpp4
-rw-r--r--src/gui/systray.cpp2
-rw-r--r--src/gui/tray/UnifiedSearchInputContainer.qml110
-rw-r--r--src/gui/tray/UnifiedSearchResultFetchMoreTrigger.qml42
-rw-r--r--src/gui/tray/UnifiedSearchResultItem.qml107
-rw-r--r--src/gui/tray/UnifiedSearchResultItemSkeleton.qml58
-rw-r--r--src/gui/tray/UnifiedSearchResultItemSkeletonContainer.qml49
-rw-r--r--src/gui/tray/UnifiedSearchResultListItem.qml87
-rw-r--r--src/gui/tray/UnifiedSearchResultNothingFound.qml47
-rw-r--r--src/gui/tray/UnifiedSearchResultSectionItem.qml20
-rw-r--r--src/gui/tray/UserModel.cpp9
-rw-r--r--src/gui/tray/UserModel.h4
-rw-r--r--src/gui/tray/Window.qml176
-rw-r--r--src/gui/tray/unifiedsearchresult.cpp36
-rw-r--r--src/gui/tray/unifiedsearchresult.h48
-rw-r--r--src/gui/tray/unifiedsearchresultimageprovider.cpp131
-rw-r--r--src/gui/tray/unifiedsearchresultimageprovider.h33
-rw-r--r--src/gui/tray/unifiedsearchresultslistmodel.cpp708
-rw-r--r--src/gui/tray/unifiedsearchresultslistmodel.h129
-rw-r--r--src/libsync/theme.cpp15
-rw-r--r--src/libsync/theme.h13
-rw-r--r--test/CMakeLists.txt1
-rw-r--r--test/syncenginetestutils.cpp10
-rw-r--r--test/syncenginetestutils.h5
-rw-r--r--test/testunifiedsearchlistmodel.cpp640
-rw-r--r--theme.qrc.in8
-rw-r--r--theme/Style/Style.qml16
-rw-r--r--theme/black/calendar.svg1
-rw-r--r--theme/black/clear.svg1
-rw-r--r--theme/black/comment.svg1
-rw-r--r--theme/black/deck.svg8
-rw-r--r--theme/black/search.svg1
35 files changed, 2528 insertions, 23 deletions
diff --git a/resources.qrc b/resources.qrc
index 134c11cc9..43b277f77 100644
--- a/resources.qrc
+++ b/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>
diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt
index bbc666374..3c2d790ea 100644
--- a/src/gui/CMakeLists.txt
+++ b/src/gui/CMakeLists.txt
@@ -43,6 +43,14 @@ set(client_UI_SRCS
tray/ActivityList.qml
tray/Window.qml
tray/UserLine.qml
+ tray/UnifiedSearchInputContainer.qml
+ tray/UnifiedSearchResultFetchMoreTrigger.qml
+ tray/UnifiedSearchResultItem.qml
+ tray/UnifiedSearchResultItemSkeleton.qml
+ tray/UnifiedSearchResultItemSkeletonContainer.qml
+ tray/UnifiedSearchResultListItem.qml
+ tray/UnifiedSearchResultNothingFound.qml
+ tray/UnifiedSearchResultSectionItem.qml
wizard/flow2authwidget.ui
wizard/owncloudadvancedsetuppage.ui
wizard/owncloudconnectionmethoddialog.ui
@@ -116,6 +124,9 @@ set(client_SRCS
tray/syncstatussummary.cpp
tray/ActivityData.cpp
tray/ActivityListModel.cpp
+ tray/unifiedsearchresult.cpp
+ tray/unifiedsearchresultimageprovider.cpp
+ tray/unifiedsearchresultslistmodel.cpp
tray/UserModel.cpp
tray/NotificationHandler.cpp
tray/NotificationCache.cpp
diff --git a/src/gui/ErrorBox.qml b/src/gui/ErrorBox.qml
index 5b0d102ad..8ef5d053c 100644
--- a/src/gui/ErrorBox.qml
+++ b/src/gui/ErrorBox.qml
@@ -1,16 +1,22 @@
import QtQuick 2.15
+import Style 1.0
+
Item {
id: errorBox
property var text: ""
+
+ property color color: Style.errorBoxTextColor
+ property color backgroundColor: Style.errorBoxBackgroundColor
+ property color borderColor: Style.errorBoxBorderColor
implicitHeight: errorMessage.implicitHeight + 2 * 8
Rectangle {
anchors.fill: parent
- color: "red"
- border.color: "black"
+ color: errorBox.backgroundColor
+ border.color: errorBox.borderColor
}
Text {
@@ -19,7 +25,7 @@ Item {
anchors.fill: parent
anchors.margins: 8
width: parent.width
- color: "white"
+ color: errorBox.color
wrapMode: Text.WordWrap
text: errorBox.text
}
diff --git a/src/gui/main.cpp b/src/gui/main.cpp
index df7279308..bf78b4c07 100644
--- a/src/gui/main.cpp
+++ b/src/gui/main.cpp
@@ -31,6 +31,7 @@
#include "userstatusselectormodel.h"
#include "emojimodel.h"
#include "tray/syncstatussummary.h"
+#include "tray/unifiedsearchresultslistmodel.h"
#if defined(BUILD_UPDATER)
#include "updater/updater.h"
@@ -68,6 +69,9 @@ int main(int argc, char **argv)
qmlRegisterType<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");
diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp
index 1f4760b35..dafb0dfc1 100644
--- a/src/gui/systray.cpp
+++ b/src/gui/systray.cpp
@@ -18,6 +18,7 @@
#include "config.h"
#include "common/utility.h"
#include "tray/UserModel.h"
+#include "tray/unifiedsearchresultimageprovider.h"
#include "configfile.h"
#include <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()
diff --git a/src/gui/tray/UnifiedSearchInputContainer.qml b/src/gui/tray/UnifiedSearchInputContainer.qml
new file mode 100644
index 000000000..eda93a11d
--- /dev/null
+++ b/src/gui/tray/UnifiedSearchInputContainer.qml
@@ -0,0 +1,110 @@
+import QtQml 2.15
+import QtQuick 2.15
+import QtQuick.Controls 2.3
+import QtGraphicalEffects 1.0
+import Style 1.0
+
+TextField {
+ id: trayWindowUnifiedSearchTextField
+
+ property bool isSearchInProgress: false
+
+ readonly property color textFieldIconsColor: Style.menuBorder
+
+ readonly property int textFieldIconsOffset: 10
+
+ readonly property double textFieldIconsScaleFactor: 0.6
+
+ readonly property int textFieldHorizontalPaddingOffset: 14
+
+ leftPadding: trayWindowUnifiedSearchTextFieldSearchIcon.width + trayWindowUnifiedSearchTextFieldSearchIcon.anchors.leftMargin + textFieldHorizontalPaddingOffset
+ rightPadding: trayWindowUnifiedSearchTextFieldClearTextButton.width + trayWindowUnifiedSearchTextFieldClearTextButton.anchors.rightMargin + textFieldHorizontalPaddingOffset
+
+ placeholderText: qsTr("Search files, messages, events...")
+
+ selectByMouse: true
+
+ background: Rectangle {
+ radius: 5
+ border.color: parent.activeFocus ? Style.ncBlue : Style.menuBorder
+ border.width: 1
+ }
+
+ Image {
+ id: trayWindowUnifiedSearchTextFieldSearchIcon
+
+ anchors {
+ left: parent.left
+ leftMargin: parent.textFieldIconsOffset
+ verticalCenter: parent.verticalCenter
+ }
+
+ visible: !trayWindowUnifiedSearchTextField.isSearchInProgress
+
+ smooth: true;
+ antialiasing: true
+ mipmap: true
+
+ source: "qrc:///client/theme/black/search.svg"
+ sourceSize: Qt.size(parent.height * parent.textFieldIconsScaleFactor, parent.height * parent.textFieldIconsScaleFactor)
+
+ ColorOverlay {
+ anchors.fill: parent
+ source: parent
+ cached: true
+ color: parent.parent.textFieldIconsColor
+ }
+ }
+
+ BusyIndicator {
+ id: trayWindowUnifiedSearchTextFieldIconInProgress
+ running: visible
+ visible: trayWindowUnifiedSearchTextField.isSearchInProgress
+ anchors {
+ left: trayWindowUnifiedSearchTextField.left
+ bottom: trayWindowUnifiedSearchTextField.bottom
+ leftMargin: trayWindowUnifiedSearchTextField.textFieldIconsOffset - 4
+ topMargin: 4
+ bottomMargin: 4
+ verticalCenter: trayWindowUnifiedSearchTextField.verticalCenter
+ }
+ width: height
+ }
+
+ Image {
+ id: trayWindowUnifiedSearchTextFieldClearTextButton
+
+ anchors {
+ right: parent.right
+ rightMargin: parent.textFieldIconsOffset
+ verticalCenter: parent.verticalCenter
+ }
+
+ smooth: true;
+ antialiasing: true
+ mipmap: true
+
+ visible: parent.text
+
+ source: "qrc:///client/theme/black/clear.svg"
+ sourceSize: Qt.size(parent.height * parent.textFieldIconsScaleFactor, parent.height * parent.textFieldIconsScaleFactor)
+
+ ColorOverlay {
+ anchors.fill: parent
+ cached: true
+ source: parent
+ color: parent.parent.textFieldIconsColor
+ }
+
+ MouseArea {
+ id: trayWindowUnifiedSearchTextFieldClearTextButtonMouseArea
+
+ anchors.fill: parent
+
+ onClicked: {
+ trayWindowUnifiedSearchTextField.text = ""
+ trayWindowUnifiedSearchTextField.onTextEdited()
+ }
+ }
+ }
+}
diff --git a/src/gui/tray/UnifiedSearchResultFetchMoreTrigger.qml b/src/gui/tray/UnifiedSearchResultFetchMoreTrigger.qml
new file mode 100644
index 000000000..1af2d42ca
--- /dev/null
+++ b/src/gui/tray/UnifiedSearchResultFetchMoreTrigger.qml
@@ -0,0 +1,42 @@
+import QtQml 2.15
+import QtQuick 2.15
+import QtQuick.Controls 2.3
+import QtQuick.Layouts 1.2
+import Style 1.0
+
+ColumnLayout {
+ id: unifiedSearchResultItemFetchMore
+
+ property bool isFetchMoreInProgress: false
+
+ property bool isWihinViewPort: false
+
+ property int fontSize: Style.topLinePixelSize
+
+ property string textColor: "grey"
+
+ Accessible.role: Accessible.ListItem
+ Accessible.name: unifiedSearchResultItemFetchMoreText.text
+ Accessible.onPressAction: unifiedSearchResultMouseArea.clicked()
+
+ Label {
+ id: unifiedSearchResultItemFetchMoreText
+ text: qsTr("Load more results")
+ visible: !unifiedSearchResultItemFetchMore.isFetchMoreInProgress
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ font.pixelSize: unifiedSearchResultItemFetchMore.fontSize
+ color: unifiedSearchResultItemFetchMore.textColor
+ }
+
+ BusyIndicator {
+ id: unifiedSearchResultItemFetchMoreIconInProgress
+ running: visible
+ visible: unifiedSearchResultItemFetchMore.isFetchMoreInProgress && unifiedSearchResultItemFetchMore.isWihinViewPort
+ Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
+ Layout.preferredWidth: parent.height * 0.70
+ Layout.preferredHeight: parent.height * 0.70
+ }
+}
diff --git a/src/gui/tray/UnifiedSearchResultItem.qml b/src/gui/tray/UnifiedSearchResultItem.qml
new file mode 100644
index 000000000..0241ed28e
--- /dev/null
+++ b/src/gui/tray/UnifiedSearchResultItem.qml
@@ -0,0 +1,107 @@
+import QtQml 2.15
+import QtQuick 2.9
+import QtQuick.Controls 2.3
+import QtQuick.Layouts 1.2
+import Style 1.0
+import QtGraphicalEffects 1.0
+
+RowLayout {
+ id: unifiedSearchResultItemDetails
+
+ property string title: ""
+ property string subline: ""
+ property string icons: ""
+ property string iconPlaceholder: ""
+ property bool isRounded: false
+
+
+ property int textLeftMargin: 18
+ property int textRightMargin: 16
+ property int iconWidth: 24
+ property int iconLeftMargin: 12
+
+ property int titleFontSize: Style.topLinePixelSize
+ property int sublineFontSize: Style.subLinePixelSize
+
+ property string titleColor: "black"
+ property string sublineColor: "grey"
+
+ Accessible.role: Accessible.ListItem
+ Accessible.name: resultTitle
+ Accessible.onPressAction: unifiedSearchResultMouseArea.clicked()
+
+ ColumnLayout {
+ id: unifiedSearchResultImageContainer
+ visible: true
+ Layout.preferredWidth: unifiedSearchResultItemDetails.iconWidth + 10
+ Layout.preferredHeight: unifiedSearchResultItemDetails.height
+ Image {
+ id: unifiedSearchResultThumbnail
+ visible: false
+ asynchronous: true
+ source: "image://unified-search-result-icon/" + icons
+ cache: true
+ sourceSize.width: imageData.width
+ sourceSize.height: imageData.height
+ width: imageData.width
+ height: imageData.height
+ }
+ Rectangle {
+ id: mask
+ visible: false
+ radius: isRounded ? width / 2 : 0
+ width: imageData.width
+ height: imageData.height
+ }
+ OpacityMask {
+ id: imageData
+ visible: !unifiedSearchResultThumbnailPlaceholder.visible && icons
+ Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
+ Layout.leftMargin: iconLeftMargin
+ Layout.preferredWidth: unifiedSearchResultItemDetails.iconWidth
+ Layout.preferredHeight: unifiedSearchResultItemDetails.iconWidth
+ source: unifiedSearchResultThumbnail
+ maskSource: mask
+ }
+ Image {
+ id: unifiedSearchResultThumbnailPlaceholder
+ visible: icons && iconPlaceholder && unifiedSearchResultThumbnail.status !== Image.Ready
+ Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
+ Layout.leftMargin: iconLeftMargin
+ verticalAlignment: Qt.AlignCenter
+ cache: true
+ source: iconPlaceholder
+ sourceSize.height: unifiedSearchResultItemDetails.iconWidth
+ sourceSize.width: unifiedSearchResultItemDetails.iconWidth
+ Layout.preferredWidth: unifiedSearchResultItemDetails.iconWidth
+ Layout.preferredHeight: unifiedSearchResultItemDetails.iconWidth
+ }
+ }
+
+ ColumnLayout {
+ id: unifiedSearchResultTextContainer
+ Layout.fillWidth: true
+
+ Label {
+ id: unifiedSearchResultTitleText
+ text: title.replace(/[\r\n]+/g, " ")
+ Layout.leftMargin: textLeftMargin
+ Layout.rightMargin: textRightMargin
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ font.pixelSize: unifiedSearchResultItemDetails.titleFontSize
+ color: unifiedSearchResultItemDetails.titleColor
+ }
+ Label {
+ id: unifiedSearchResultTextSubline
+ text: subline.replace(/[\r\n]+/g, " ")
+ elide: Text.ElideRight
+ font.pixelSize: unifiedSearchResultItemDetails.sublineFontSize
+ Layout.leftMargin: textLeftMargin
+ Layout.rightMargin: textRightMargin
+ Layout.fillWidth: true
+ color: unifiedSearchResultItemDetails.sublineColor
+ }
+ }
+
+}
diff --git a/src/gui/tray/UnifiedSearchResultItemSkeleton.qml b/src/gui/tray/UnifiedSearchResultItemSkeleton.qml
new file mode 100644
index 000000000..3f86c4b9f
--- /dev/null
+++ b/src/gui/tray/UnifiedSearchResultItemSkeleton.qml
@@ -0,0 +1,58 @@
+import QtQml 2.15
+import QtQuick 2.15
+import QtQuick.Layouts 1.2
+import Style 1.0
+
+RowLayout {
+ id: unifiedSearchResultSkeletonItemDetails
+
+ property int textLeftMargin: 18
+ property int textRightMargin: 16
+ property int iconWidth: 24
+ property int iconLeftMargin: 12
+
+ property int titleFontSize: Style.topLinePixelSize
+ property int sublineFontSize: Style.subLinePixelSize
+
+ property string titleColor: "black"
+ property string sublineColor: "grey"
+
+ property string iconColor: "#afafaf"
+
+ property int index: 0
+
+ Accessible.role: Accessible.ListItem
+ Accessible.name: qsTr("Search result skeleton.").arg(index)
+
+ Rectangle {
+ id: unifiedSearchResultSkeletonThumbnail
+ color: unifiedSearchResultSkeletonItemDetails.iconColor
+ Layout.preferredWidth: unifiedSearchResultSkeletonItemDetails.iconWidth
+ Layout.preferredHeight: unifiedSearchResultSkeletonItemDetails.iconWidth
+ Layout.leftMargin: unifiedSearchResultSkeletonItemDetails.iconLeftMargin
+ Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
+ }
+
+ ColumnLayout {
+ id: unifiedSearchResultSkeletonTextContainer
+ Layout.fillWidth: true
+
+ Rectangle {
+ id: unifiedSearchResultSkeletonTitleText
+ color: unifiedSearchResultSkeletonItemDetails.titleColor
+ Layout.preferredHeight: unifiedSearchResultSkeletonItemDetails.titleFontSize
+ Layout.leftMargin: unifiedSearchResultSkeletonItemDetails.textLeftMargin
+ Layout.rightMargin: unifiedSearchResultSkeletonItemDetails.textRightMargin
+ Layout.fillWidth: true
+ }
+
+ Rectangle {
+ id: unifiedSearchResultSkeletonTextSubline
+ color: unifiedSearchResultSkeletonItemDetails.sublineColor
+ Layout.preferredHeight: unifiedSearchResultSkeletonItemDetails.sublineFontSize
+ Layout.leftMargin: unifiedSearchResultSkeletonItemDetails.textLeftMargin
+ Layout.rightMargin: unifiedSearchResultSkeletonItemDetails.textRightMargin
+ Layout.fillWidth: true
+ }
+ }
+}
diff --git a/src/gui/tray/UnifiedSearchResultItemSkeletonContainer.qml b/src/gui/tray/UnifiedSearchResultItemSkeletonContainer.qml
new file mode 100644
index 000000000..1b9f8c262
--- /dev/null
+++ b/src/gui/tray/UnifiedSearchResultItemSkeletonContainer.qml
@@ -0,0 +1,49 @@
+import QtQml 2.15
+import QtQuick 2.15
+import QtQuick.Controls 2.3
+import Style 1.0
+
+Column {
+ id: unifiedSearchResultsListViewSkeletonColumn
+
+ property int textLeftMargin: 18
+ property int textRightMargin: 16
+ property int iconWidth: 24
+ property int iconLeftMargin: 12
+ property int itemHeight: Style.trayWindowHeaderHeight
+ property int titleFontSize: Style.topLinePixelSize
+ property int sublineFontSize: Style.subLinePixelSize
+ property string titleColor: "black"
+ property string sublineColor: "grey"
+ property string iconColor: "#afafaf"
+
+ Repeater {
+ model: 10
+ UnifiedSearchResultItemSkeleton {
+ textLeftMargin: unifiedSearchResultsListViewSkeletonColumn.textLeftMargin
+ textRightMargin: unifiedSearchResultsListViewSkeletonColumn.textRightMargin
+ iconWidth: unifiedSearchResultsListViewSkeletonColumn.iconWidth
+ iconLeftMargin: unifiedSearchResultsListViewSkeletonColumn.iconLeftMargin
+ width: unifiedSearchResultsListViewSkeletonColumn.width
+ height: unifiedSearchResultsListViewSkeletonColumn.itemHeight
+ index: model.index
+ titleFontSize: unifiedSearchResultsListViewSkeletonColumn.titleFontSize
+ sublineFontSize: unifiedSearchResultsListViewSkeletonColumn.sublineFontSize
+ titleColor: unifiedSearchResultsListViewSkeletonColumn.titleColor
+ sublineColor: unifiedSearchResultsListViewSkeletonColumn.sublineColor
+ iconColor: unifiedSearchResultsListViewSkeletonColumn.iconColor
+ }
+ }
+
+ OpacityAnimator {
+ target: unifiedSearchResultsListViewSkeletonColumn;
+ from: 0.5;
+ to: 1;
+ duration: 800
+ running: unifiedSearchResultsListViewSkeletonColumn.visible
+ loops: Animation.Infinite;
+ easing {
+ type: Easing.InOutBounce;
+ }
+ }
+}
diff --git a/src/gui/tray/UnifiedSearchResultListItem.qml b/src/gui/tray/UnifiedSearchResultListItem.qml
new file mode 100644
index 000000000..33a05f2d5
--- /dev/null
+++ b/src/gui/tray/UnifiedSearchResultListItem.qml
@@ -0,0 +1,87 @@
+import QtQml 2.15
+import QtQuick 2.15
+import QtQuick.Controls 2.3
+import Style 1.0
+
+MouseArea {
+ id: unifiedSearchResultMouseArea
+
+ property int textLeftMargin: 18
+ property int textRightMargin: 16
+ property int iconWidth: 24
+ property int iconLeftMargin: 12
+
+ property int titleFontSize: Style.topLinePixelSize
+ property int sublineFontSize: Style.subLinePixelSize
+
+ property string titleColor: "black"
+ property string sublineColor: "grey"
+
+ property string currentFetchMoreInProgressProviderId: ""
+
+ readonly property bool isFetchMoreTrigger: model.typeAsString === "FetchMoreTrigger"
+
+ property bool isFetchMoreInProgress: currentFetchMoreInProgressProviderId === model.providerId
+ property bool isSearchInProgress: false
+
+ property bool isPooled: false
+
+ property var fetchMoreTriggerClicked: function(){}
+ property var resultClicked: function(){}
+
+ enabled: !isFetchMoreTrigger || !isSearchInProgress
+ hoverEnabled: enabled
+
+ ToolTip {
+ visible: unifiedSearchResultMouseArea.containsMouse
+ text: isFetchMoreTrigger ? qsTr("Load more results") : model.resultTitle + "\n\n" + model.subline
+ delay: Qt.styleHints.mousePressAndHoldInterval
+ }
+
+ Rectangle {
+ id: unifiedSearchResultHoverBackground
+ anchors.fill: parent
+ color: (parent.containsMouse ? Style.lightHover : "transparent")
+ }
+
+ Loader {
+ active: !isFetchMoreTrigger
+ sourceComponent: UnifiedSearchResultItem {
+ width: unifiedSearchResultMouseArea.width
+ height: unifiedSearchResultMouseArea.height
+ title: model.resultTitle
+ subline: model.subline
+ icons: model.icons
+ iconPlaceholder: model.imagePlaceholder
+ isRounded: model.isRounded
+ textLeftMargin: unifiedSearchResultMouseArea.textLeftMargin
+ textRightMargin: unifiedSearchResultMouseArea.textRightMargin
+ iconWidth: unifiedSearchResultMouseArea.iconWidth
+ iconLeftMargin: unifiedSearchResultMouseArea.iconLeftMargin
+ titleFontSize: unifiedSearchResultMouseArea.titleFontSize
+ sublineFontSize: unifiedSearchResultMouseArea.sublineFontSize
+ titleColor: unifiedSearchResultMouseArea.titleColor
+ sublineColor: unifiedSearchResultMouseArea.sublineColor
+ }
+ }
+
+ Loader {
+ active: isFetchMoreTrigger
+ sourceComponent: UnifiedSearchResultFetchMoreTrigger {
+ isFetchMoreInProgress: unifiedSearchResultMouseArea.isFetchMoreInProgress
+ width: unifiedSearchResultMouseArea.width
+ height: unifiedSearchResultMouseArea.height
+ isWihinViewPort: !unifiedSearchResultMouseArea.isPooled
+ fontSize: unifiedSearchResultMouseArea.titleFontSize
+ textColor: unifiedSearchResultMouseArea.sublineColor
+ }
+ }
+
+ onClicked: {
+ if (isFetchMoreTrigger) {
+ unifiedSearchResultMouseArea.fetchMoreTriggerClicked(model.providerId)
+ } else {
+ unifiedSearchResultMouseArea.resultClicked(model.providerId, model.resourceUrlRole)
+ }
+ }
+}
diff --git a/src/gui/tray/UnifiedSearchResultNothingFound.qml b/src/gui/tray/UnifiedSearchResultNothingFound.qml
new file mode 100644
index 000000000..36cea93a6
--- /dev/null
+++ b/src/gui/tray/UnifiedSearchResultNothingFound.qml
@@ -0,0 +1,47 @@
+import QtQml 2.15
+import QtQuick 2.15
+import QtQuick.Controls 2.3
+import QtQuick.Layouts 1.2
+import Style 1.0
+
+ColumnLayout {
+ id: unifiedSearchResultNothingFoundContainer
+
+ required property string text
+
+ spacing: 8
+ anchors.leftMargin: 10
+ anchors.rightMargin: 10
+
+ Image {
+ id: unifiedSearchResultsNoResultsLabelIcon
+ source: "qrc:///client/theme/magnifying-glass.svg"
+ sourceSize.width: Style.trayWindowHeaderHeight / 2
+ sourceSize.height: Style.trayWindowHeaderHeight / 2
+ Layout.alignment: Qt.AlignHCenter
+ }
+
+ Label {
+ id: unifiedSearchResultsNoResultsLabel
+ text: qsTr("No results for")
+ color: Style.menuBorder
+ font.pixelSize: Style.subLinePixelSize * 1.25
+ wrapMode: Text.Wrap
+ Layout.fillWidth: true
+ Layout.preferredHeight: Style.trayWindowHeaderHeight / 2
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ Label {
+ id: unifiedSearchResultsNoResultsLabelDetails
+ text: unifiedSearchResultNothingFoundContainer.text
+ color: "black"
+ font.pixelSize: Style.topLinePixelSize * 1.25
+ wrapMode: Text.Wrap
+ maximumLineCount: 2
+ elide: Text.ElideRight
+ Layout.fillWidth: true
+ Layout.preferredHeight: Style.trayWindowHeaderHeight / 2
+ horizontalAlignment: Text.AlignHCenter
+ }
+}
diff --git a/src/gui/tray/UnifiedSearchResultSectionItem.qml b/src/gui/tray/UnifiedSearchResultSectionItem.qml
new file mode 100644
index 000000000..a6f9fd591
--- /dev/null
+++ b/src/gui/tray/UnifiedSearchResultSectionItem.qml
@@ -0,0 +1,20 @@
+import QtQml 2.15
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.2
+import Style 1.0
+
+Label {
+ required property string section
+
+ topPadding: 8
+ bottomPadding: 8
+ leftPadding: 16
+
+ text: section
+ font.pixelSize: Style.topLinePixelSize
+ color: Style.ncBlue
+
+ Accessible.role: Accessible.Separator
+ Accessible.name: qsTr("Search results section %1").arg(section)
+}
diff --git a/src/gui/tray/UserModel.cpp b/src/gui/tray/UserModel.cpp
index c9e3d92b5..3624dd652 100644
--- a/src/gui/tray/UserModel.cpp
+++ b/src/gui/tray/UserModel.cpp
@@ -14,6 +14,7 @@
#include "syncfileitem.h"
#include "tray/ActivityListModel.h"
#include "tray/NotificationCache.h"
+#include "tray/unifiedsearchresultslistmodel.h"
#include "userstatusconnector.h"
#include <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();
diff --git a/src/gui/tray/UserModel.h b/src/gui/tray/UserModel.h
index eb3be9136..88dc78a25 100644
--- a/src/gui/tray/UserModel.h
+++ b/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;
diff --git a/src/gui/tray/Window.qml b/src/gui/tray/Window.qml
index 5bda9242d..d85615adb 100644
--- a/src/gui/tray/Window.qml
+++ b/src/gui/tray/Window.qml
@@ -1,10 +1,11 @@
import QtQml 2.12
import QtQml.Models 2.1
-import QtQuick 2.9
+import QtQuick 2.15
import QtQuick.Window 2.3
import QtQuick.Controls 2.3
import QtQuick.Layouts 1.2
import QtGraphicalEffects 1.0
+import "../"
// Custom qml modules are in /theme (and included by resources.qrc)
import Style 1.0
@@ -101,6 +102,11 @@ Window {
Rectangle {
id: trayWindowBackground
+ property bool isUnifiedSearchActive: unifiedSearchResultsListViewSkeleton.visible
+ || unifiedSearchResultNothingFound.visible
+ || unifiedSearchResultsErrorLabel.visible
+ || unifiedSearchResultsListView.visible
+
anchors.fill: parent
radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius
border.width: Style.trayWindowBorderWidth
@@ -420,7 +426,7 @@ Window {
visible: UserModel.currentUser.statusMessage !== ""
width: Style.currentAccountLabelWidth
text: UserModel.currentUser.statusMessage !== ""
- ? UserModel.currentUser.statusMessage
+ ? UserModel.currentUser.statusMessage
: UserModel.currentUser.server
elide: Text.ElideRight
color: Style.ncTextColor
@@ -452,20 +458,20 @@ Window {
Item {
Layout.fillWidth: true
}
-
+
RowLayout {
id: openLocalFolderRowLayout
spacing: 0
Layout.preferredWidth: Style.trayWindowHeaderHeight
Layout.preferredHeight: Style.trayWindowHeaderHeight
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
-
+
HeaderButton {
id: openLocalFolderButton
visible: UserModel.currentUser.hasLocalFolder
icon.source: "qrc:///client/theme/white/folder.svg"
onClicked: UserModel.openCurrentAccountLocalFolder()
-
+
Rectangle {
id: folderStateIndicatorBackground
width: Style.folderStateIndicatorSize
@@ -476,7 +482,7 @@ Window {
radius: width*0.5
z: 1
}
-
+
Image {
id: folderStateIndicator
visible: UserModel.currentUser.hasLocalFolder
@@ -484,19 +490,17 @@ Window {
? Style.stateOnlineImageSource
: Style.stateOfflineImageSource
cache: false
-
+
anchors.top: openLocalFolderButton.verticalCenter
- anchors.left: openLocalFolderButton.horizontalCenter
+ anchors.left: openLocalFolderButton.horizontalCenter
sourceSize.width: Style.folderStateIndicatorSize
sourceSize.height: Style.folderStateIndicatorSize
-
+
Accessible.role: Accessible.Indicator
Accessible.name: UserModel.currentUser.isConnected ? qsTr("Connected") : qsTr("Disconnected")
z: 2
}
}
-
-
Accessible.role: Accessible.Button
Accessible.name: qsTr("Open local folder of current account")
@@ -504,11 +508,11 @@ Window {
HeaderButton {
id: trayWindowTalkButton
-
+
visible: UserModel.currentUser.serverHasTalk
icon.source: "qrc:///client/theme/white/talk-app.svg"
onClicked: UserModel.openCurrentAccountTalk()
-
+
Accessible.role: Accessible.Button
Accessible.name: qsTr("Open Nextcloud Talk in browser")
Accessible.onPressAction: trayWindowTalkButton.clicked()
@@ -517,7 +521,7 @@ Window {
HeaderButton {
id: trayWindowAppsButton
icon.source: "qrc:///client/theme/white/more-apps.svg"
-
+
onClicked: {
if(appsMenu.count <= 0) {
UserModel.openCurrentAccountServer()
@@ -566,20 +570,158 @@ Window {
}
} // Rectangle trayWindowHeaderBackground
+ UnifiedSearchInputContainer {
+ id: trayWindowUnifiedSearchInputContainer
+ height: Style.trayWindowHeaderHeight * 0.65
+
+ anchors {
+ top: trayWindowHeaderBackground.bottom
+ left: trayWindowBackground.left
+ right: trayWindowBackground.right
+
+ margins: {
+ top: 10
+ }
+ }
+
+ text: UserModel.currentUser.unifiedSearchResultsListModel.searchTerm
+ readOnly: !UserModel.currentUser.isConnected || UserModel.currentUser.unifiedSearchResultsListModel.currentFetchMoreInProgressProviderId
+ isSearchInProgress: UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress
+ onTextEdited: { UserModel.currentUser.unifiedSearchResultsListModel.searchTerm = trayWindowUnifiedSearchInputContainer.text }
+ }
+
+ ErrorBox {
+ id: unifiedSearchResultsErrorLabel
+ visible: UserModel.currentUser.unifiedSearchResultsListModel.errorString && !unifiedSearchResultsListView.visible && ! UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress && ! UserModel.currentUser.unifiedSearchResultsListModel.currentFetchMoreInProgressProviderId
+ text: UserModel.currentUser.unifiedSearchResultsListModel.errorString
+ color: Style.errorBoxBackgroundColor
+ backgroundColor: Style.errorBoxTextColor
+ borderColor: "transparent"
+ anchors.top: trayWindowUnifiedSearchInputContainer.bottom
+ anchors.left: trayWindowBackground.left
+ anchors.right: trayWindowBackground.right
+ anchors.margins: 10
+ }
+
+ UnifiedSearchResultNothingFound {
+ id: unifiedSearchResultNothingFound
+ visible: false
+ anchors.top: trayWindowUnifiedSearchInputContainer.bottom
+ anchors.left: trayWindowBackground.left
+ anchors.right: trayWindowBackground.right
+ anchors.topMargin: 10
+
+ text: UserModel.currentUser.unifiedSearchResultsListModel.searchTerm
+
+ property bool isSearchRunning: UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress
+ property bool isSearchResultsEmpty: unifiedSearchResultsListView.count === 0
+ property bool nothingFound: text && isSearchResultsEmpty && !UserModel.currentUser.unifiedSearchResultsListModel.errorString
+
+ onIsSearchRunningChanged: {
+ if (unifiedSearchResultNothingFound.isSearchRunning) {
+ visible = false;
+ } else {
+ if (nothingFound) {
+ visible = true;
+ }
+ }
+ }
+
+ onTextChanged: {
+ visible = false;
+ }
+
+ onIsSearchResultsEmptyChanged: {
+ if (!unifiedSearchResultNothingFound.isSearchResultsEmpty) {
+ visible = false;
+ }
+ }
+ }
+
+ UnifiedSearchResultItemSkeletonContainer {
+ id: unifiedSearchResultsListViewSkeleton
+ visible: !unifiedSearchResultNothingFound.visible && !unifiedSearchResultsListView.visible && ! UserModel.currentUser.unifiedSearchResultsListModel.errorString && UserModel.currentUser.unifiedSearchResultsListModel.searchTerm
+ anchors.top: trayWindowUnifiedSearchInputContainer.bottom
+ anchors.left: trayWindowBackground.left
+ anchors.right: trayWindowBackground.right
+ anchors.bottom: trayWindowBackground.bottom
+ textLeftMargin: trayWindowBackground.Style.unifiedSearchResultTextLeftMargin
+ textRightMargin: trayWindowBackground.Style.unifiedSearchResultTextRightMargin
+ iconWidth: trayWindowBackground.Style.unifiedSearchResulIconWidth
+ iconLeftMargin: trayWindowBackground.Style.unifiedSearchResulIconLeftMargin
+ itemHeight: trayWindowBackground.Style.unifiedSearchItemHeight
+ titleFontSize: trayWindowBackground.Style.unifiedSearchResulTitleFontSize
+ sublineFontSize: trayWindowBackground.Style.unifiedSearchResulSublineFontSize
+ titleColor: trayWindowBackground.Style.unifiedSearchResulTitleColor
+ sublineColor: trayWindowBackground.Style.unifiedSearchResulSublineColor
+ iconColor: "#afafaf"
+ }
+
+ ListView {
+ id: unifiedSearchResultsListView
+ anchors.top: trayWindowUnifiedSearchInputContainer.bottom
+ anchors.left: trayWindowBackground.left
+ anchors.right: trayWindowBackground.right
+ anchors.bottom: trayWindowBackground.bottom
+ spacing: 4
+ visible: count > 0
+ clip: true
+ ScrollBar.vertical: ScrollBar {
+ id: unifiedSearchResultsListViewScrollbar
+ }
+
+ keyNavigationEnabled: true
+
+ reuseItems: true
+
+ Accessible.role: Accessible.List
+ Accessible.name: qsTr("Unified search results list")
+
+ model: UserModel.currentUser.unifiedSearchResultsListModel
+
+ delegate: UnifiedSearchResultListItem {
+ width: unifiedSearchResultsListView.width
+ height: trayWindowBackground.Style.unifiedSearchItemHeight
+ isSearchInProgress: unifiedSearchResultsListView.model.isSearchInProgress
+ textLeftMargin: trayWindowBackground.Style.unifiedSearchResultTextLeftMargin
+ textRightMargin: trayWindowBackground.Style.unifiedSearchResultTextRightMargin
+ iconWidth: trayWindowBackground.Style.unifiedSearchResulIconWidth
+ iconLeftMargin: trayWindowBackground.Style.unifiedSearchResulIconLeftMargin
+ titleFontSize: trayWindowBackground.Style.unifiedSearchResulTitleFontSize
+ sublineFontSize: trayWindowBackground.Style.unifiedSearchResulSublineFontSize
+ titleColor: trayWindowBackground.Style.unifiedSearchResulTitleColor
+ sublineColor: trayWindowBackground.Style.unifiedSearchResulSublineColor
+ currentFetchMoreInProgressProviderId: unifiedSearchResultsListView.model.currentFetchMoreInProgressProviderId
+ fetchMoreTriggerClicked: unifiedSearchResultsListView.model.fetchMoreTriggerClicked
+ resultClicked: unifiedSearchResultsListView.model.resultClicked
+ ListView.onPooled: isPooled = true
+ ListView.onReused: isPooled = false
+ }
+
+ section.property: "providerName"
+ section.criteria: ViewSection.FullString
+ section.delegate: UnifiedSearchResultSectionItem {
+ width: unifiedSearchResultsListView.width
+ }
+ }
+
SyncStatus {
id: syncStatus
- anchors.top: trayWindowHeaderBackground.bottom
+ visible: !trayWindowBackground.isUnifiedSearchActive
+
+ anchors.top: trayWindowUnifiedSearchInputContainer.bottom
anchors.left: trayWindowBackground.left
anchors.right: trayWindowBackground.right
}
ActivityList {
+ visible: !trayWindowBackground.isUnifiedSearchActive
anchors.top: syncStatus.bottom
anchors.left: trayWindowBackground.left
anchors.right: trayWindowBackground.right
anchors.bottom: trayWindowBackground.bottom
-
+
model: activityModel
onShowFileActivity: {
openFileActivityDialog(displayPath, absolutePath)
@@ -598,7 +740,7 @@ Window {
function refresh() {
active = true
item.model.load(activityModel.accountState, absolutePath)
- item.show()
+ item.show()
}
active: false
diff --git a/src/gui/tray/unifiedsearchresult.cpp b/src/gui/tray/unifiedsearchresult.cpp
new file mode 100644
index 000000000..768ff3148
--- /dev/null
+++ b/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;
+}
+}
diff --git a/src/gui/tray/unifiedsearchresult.h b/src/gui/tray/unifiedsearchresult.h
new file mode 100644
index 000000000..bae3158d6
--- /dev/null
+++ b/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;
+};
+}
diff --git a/src/gui/tray/unifiedsearchresultimageprovider.cpp b/src/gui/tray/unifiedsearchresultimageprovider.cpp
new file mode 100644
index 000000000..f0bba8efe
--- /dev/null
+++ b/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);
+}
+
+}
diff --git a/src/gui/tray/unifiedsearchresultimageprovider.h b/src/gui/tray/unifiedsearchresultimageprovider.h
new file mode 100644
index 000000000..0e35c9be7
--- /dev/null
+++ b/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;
+};
+}
diff --git a/src/gui/tray/unifiedsearchresultslistmodel.cpp b/src/gui/tray/unifiedsearchresultslistmodel.cpp
new file mode 100644
index 000000000..a6cc8569d
--- /dev/null
+++ b/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();
+ }
+}
+
+}
diff --git a/src/gui/tray/unifiedsearchresultslistmodel.h b/src/gui/tray/unifiedsearchresultslistmodel.h
new file mode 100644
index 000000000..5ae811f20
--- /dev/null
+++ b/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;
+};
+}
diff --git a/src/libsync/theme.cpp b/src/libsync/theme.cpp
index 699951130..d8fdd609e 100644
--- a/src/libsync/theme.cpp
+++ b/src/libsync/theme.cpp
@@ -841,4 +841,19 @@ bool Theme::showVirtualFilesOption() const
return ConfigFile().showExperimentalOptions() || vfsMode == Vfs::WindowsCfApi;
}
+QColor Theme::errorBoxTextColor() const
+{
+ return QColor{"white"};
+}
+
+QColor Theme::errorBoxBackgroundColor() const
+{
+ return QColor{"red"};
+}
+
+QColor Theme::errorBoxBorderColor() const
+{
+ return QColor{"black"};
+}
+
} // end namespace client
diff --git a/src/libsync/theme.h b/src/libsync/theme.h
index 37e3c52c1..ec9a5dd42 100644
--- a/src/libsync/theme.h
+++ b/src/libsync/theme.h
@@ -61,6 +61,10 @@ class OWNCLOUDSYNC_EXPORT Theme : public QObject
Q_PROPERTY(QColor wizardHeaderBackgroundColor READ wizardHeaderBackgroundColor CONSTANT)
#endif
Q_PROPERTY(QString updateCheckUrl READ updateCheckUrl CONSTANT)
+
+ Q_PROPERTY(QColor errorBoxTextColor READ errorBoxTextColor CONSTANT)
+ Q_PROPERTY(QColor errorBoxBackgroundColor READ errorBoxBackgroundColor CONSTANT)
+ Q_PROPERTY(QColor errorBoxBorderColor READ errorBoxBorderColor CONSTANT)
public:
enum CustomMediaType {
oCSetupTop, // ownCloud connect page
@@ -547,6 +551,15 @@ public:
*/
virtual bool showVirtualFilesOption() const;
+ /** @return color for the ErrorBox text. */
+ virtual QColor errorBoxTextColor() const;
+
+ /** @return color for the ErrorBox background. */
+ virtual QColor errorBoxBackgroundColor() const;
+
+ /** @return color for the ErrorBox border. */
+ virtual QColor errorBoxBorderColor() const;
+
static constexpr const char *themePrefix = ":/client/theme/";
protected:
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 9e118c3c8..987fb8c9f 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -60,6 +60,7 @@ nextcloud_add_test(Theme)
nextcloud_add_test(IconUtils)
nextcloud_add_test(NotificationCache)
nextcloud_add_test(SetUserStatusDialog)
+nextcloud_add_test(UnifiedSearchListmodel)
if( UNIX AND NOT APPLE )
nextcloud_add_test(InotifyWatcher)
diff --git a/test/syncenginetestutils.cpp b/test/syncenginetestutils.cpp
index 7b5caa94a..dcf641aa8 100644
--- a/test/syncenginetestutils.cpp
+++ b/test/syncenginetestutils.cpp
@@ -709,14 +709,20 @@ void FakeChunkMoveReply::abort()
}
FakePayloadReply::FakePayloadReply(QNetworkAccessManager::Operation op, const QNetworkRequest &request, const QByteArray &body, QObject *parent)
- : FakeReply { parent }
+ : FakePayloadReply(op, request, body, FakePayloadReply::defaultDelay, parent)
+{
+}
+
+FakePayloadReply::FakePayloadReply(
+ QNetworkAccessManager::Operation op, const QNetworkRequest &request, const QByteArray &body, int delay, QObject *parent)
+ : FakeReply{parent}
, _body(body)
{
setRequest(request);
setUrl(request.url());
setOperation(op);
open(QIODevice::ReadOnly);
- QTimer::singleShot(10, this, &FakePayloadReply::respond);
+ QTimer::singleShot(delay, this, &FakePayloadReply::respond);
}
void FakePayloadReply::respond()
diff --git a/test/syncenginetestutils.h b/test/syncenginetestutils.h
index 6b170866a..e5624cd3c 100644
--- a/test/syncenginetestutils.h
+++ b/test/syncenginetestutils.h
@@ -316,12 +316,17 @@ public:
FakePayloadReply(QNetworkAccessManager::Operation op, const QNetworkRequest &request,
const QByteArray &body, QObject *parent);
+ FakePayloadReply(QNetworkAccessManager::Operation op, const QNetworkRequest &request,
+ const QByteArray &body, int delay, QObject *parent);
+
void respond();
void abort() override {}
qint64 readData(char *buf, qint64 max) override;
qint64 bytesAvailable() const override;
QByteArray _body;
+
+ static const int defaultDelay = 10;
};
diff --git a/test/testunifiedsearchlistmodel.cpp b/test/testunifiedsearchlistmodel.cpp
new file mode 100644
index 000000000..ea7de04ba
--- /dev/null
+++ b/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"
diff --git a/theme.qrc.in b/theme.qrc.in
index c37796734..aba4639b4 100644
--- a/theme.qrc.in
+++ b/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>
diff --git a/theme/Style/Style.qml b/theme/Style/Style.qml
index ea5aabde0..b29db7aaf 100644
--- a/theme/Style/Style.qml
+++ b/theme/Style/Style.qml
@@ -12,6 +12,11 @@ QtObject {
property color lightHover: "#f7f7f7"
property color menuBorder: "#bdbdbd"
+ // ErrorBox colors
+ property color errorBoxTextColor: Theme.errorBoxTextColor
+ property color errorBoxBackgroundColor: Theme.errorBoxBackgroundColor
+ property color errorBoxBorderColor: Theme.errorBoxBorderColor
+
// Fonts
// We are using pixel size because this is cross platform comparable, point size isn't
property int topLinePixelSize: 12
@@ -56,4 +61,15 @@ QtObject {
// Visual behaviour
property bool hoverEffectsEnabled: true
+
+ // unified search constants
+ readonly property int unifiedSearchItemHeight: trayWindowHeaderHeight
+ readonly property int unifiedSearchResultTextLeftMargin: 18
+ readonly property int unifiedSearchResultTextRightMargin: 16
+ readonly property int unifiedSearchResulIconWidth: 24
+ readonly property int unifiedSearchResulIconLeftMargin: 12
+ readonly property int unifiedSearchResulTitleFontSize: topLinePixelSize
+ readonly property int unifiedSearchResulSublineFontSize: subLinePixelSize
+ readonly property string unifiedSearchResulTitleColor: "black"
+ readonly property string unifiedSearchResulSublineColor: "grey"
}
diff --git a/theme/black/calendar.svg b/theme/black/calendar.svg
new file mode 100644
index 000000000..4ea05fefe
--- /dev/null
+++ b/theme/black/calendar.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="32" version="1.1" height="32" viewbox="0 0 32 32"><path fill="#000" d="m8 2c-1.108 0-2 0.892-2 2v4c0 1.108 0.892 2 2 2s2-0.892 2-2v-4c0-1.108-0.892-2-2-2zm16 0c-1.108 0-2 0.892-2 2v4c0 1.108 0.892 2 2 2s2-0.892 2-2v-4c0-1.108-0.892-2-2-2zm-13 4v2c0 1.662-1.338 3-3 3s-3-1.338-3-3v-1.875a3.993 3.993 0 0 0 -3 3.875v16c0 2.216 1.784 4 4 4h20c2.216 0 4-1.784 4-4v-16a3.993 3.993 0 0 0 -3 -3.875v1.875c0 1.662-1.338 3-3 3s-3-1.338-3-3v-2zm-4.906 10h19.812a0.09 0.09 0 0 1 0.094 0.094v9.812a0.09 0.09 0 0 1 -0.094 0.094h-19.812a0.09 0.09 0 0 1 -0.094 -0.094v-9.812a0.09 0.09 0 0 1 0.094 -0.094z"/></svg>
diff --git a/theme/black/clear.svg b/theme/black/clear.svg
new file mode 100644
index 000000000..3cf9469e0
--- /dev/null
+++ b/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>
diff --git a/theme/black/comment.svg b/theme/black/comment.svg
new file mode 100644
index 000000000..67c3fee58
--- /dev/null
+++ b/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>
diff --git a/theme/black/deck.svg b/theme/black/deck.svg
new file mode 100644
index 000000000..122762496
--- /dev/null
+++ b/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>
diff --git a/theme/black/search.svg b/theme/black/search.svg
new file mode 100644
index 000000000..58e7264c6
--- /dev/null
+++ b/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>