diff options
author | alex-z <blackslayer4@gmail.com> | 2021-09-09 14:33:57 +0300 |
---|---|---|
committer | alex-z <blackslayer4@gmail.com> | 2021-10-25 14:45:05 +0300 |
commit | c1dab7e4cb254bf492b458c8c0c1bab8f55b17b0 (patch) | |
tree | 66681ba48f072166942867ee9021a7934d633e6f | |
parent | b8e2dc24f3c1d67424dd363af85f9311a702dd78 (diff) |
Unified Search via Tray window
Signed-off-by: alex-z <blackslayer4@gmail.com>
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 ¤t) { + // insert before other results of higher order when possible + if (current._order > provider._order) { + return true; + } else { + if (current._order == provider._order) { + // insert before results of higher QString value when possible + return current._providerName > provider._name; + } + + return false; + } + }); + + const auto first = static_cast<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> |