Unified Search via Tray window

Signed-off-by: alex-z <blackslayer4@gmail.com>
This commit is contained in:
alex-z 2021-09-09 14:33:57 +03:00
parent b8e2dc24f3
commit c1dab7e4cb
35 changed files with 2528 additions and 23 deletions

View file

@ -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>

View file

@ -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

View file

@ -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
}

View file

@ -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");

View file

@ -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()

View file

@ -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()
}
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}
}

View file

@ -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;
}
}
}

View file

@ -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)
}
}
}

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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();

View file

@ -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;

View file

@ -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
@ -496,8 +502,6 @@ Window {
}
}
Accessible.role: Accessible.Button
Accessible.name: qsTr("Open local folder of current account")
}
@ -566,15 +570,153 @@ 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

View file

@ -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;
}
}

View file

@ -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;
};
}

View file

@ -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);
}
}

View file

@ -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;
};
}

View file

@ -0,0 +1,708 @@
/*
* Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
#include "unifiedsearchresultslistmodel.h"
#include "account.h"
#include "accountstate.h"
#include "guiutility.h"
#include "folderman.h"
#include "networkjobs.h"
#include <algorithm>
#include <QAbstractListModel>
#include <QDesktopServices>
namespace {
QString imagePlaceholderUrlForProviderId(const QString &providerId)
{
if (providerId.contains(QStringLiteral("message"), Qt::CaseInsensitive)
|| providerId.contains(QStringLiteral("talk"), Qt::CaseInsensitive)) {
return QStringLiteral("qrc:///client/theme/black/wizard-talk.svg");
} else if (providerId.contains(QStringLiteral("file"), Qt::CaseInsensitive)) {
return QStringLiteral("qrc:///client/theme/black/edit.svg");
} else if (providerId.contains(QStringLiteral("deck"), Qt::CaseInsensitive)) {
return QStringLiteral("qrc:///client/theme/black/deck.svg");
} else if (providerId.contains(QStringLiteral("calendar"), Qt::CaseInsensitive)) {
return QStringLiteral("qrc:///client/theme/black/calendar.svg");
} else if (providerId.contains(QStringLiteral("mail"), Qt::CaseInsensitive)) {
return QStringLiteral("qrc:///client/theme/black/email.svg");
} else if (providerId.contains(QStringLiteral("comment"), Qt::CaseInsensitive)) {
return QStringLiteral("qrc:///client/theme/black/comment.svg");
}
return QStringLiteral("qrc:///client/theme/change.svg");
}
QString localIconPathFromIconPrefix(const QString &iconNameWithPrefix)
{
if (iconNameWithPrefix.contains(QStringLiteral("message"), Qt::CaseInsensitive)
|| iconNameWithPrefix.contains(QStringLiteral("talk"), Qt::CaseInsensitive)) {
return QStringLiteral(":/client/theme/black/wizard-talk.svg");
} else if (iconNameWithPrefix.contains(QStringLiteral("folder"), Qt::CaseInsensitive)) {
return QStringLiteral(":/client/theme/black/folder.svg");
} else if (iconNameWithPrefix.contains(QStringLiteral("deck"), Qt::CaseInsensitive)) {
return QStringLiteral(":/client/theme/black/deck.svg");
} else if (iconNameWithPrefix.contains(QStringLiteral("contacts"), Qt::CaseInsensitive)) {
return QStringLiteral(":/client/theme/black/wizard-groupware.svg");
} else if (iconNameWithPrefix.contains(QStringLiteral("calendar"), Qt::CaseInsensitive)) {
return QStringLiteral(":/client/theme/black/calendar.svg");
} else if (iconNameWithPrefix.contains(QStringLiteral("mail"), Qt::CaseInsensitive)) {
return QStringLiteral(":/client/theme/black/email.svg");
}
return QStringLiteral(":/client/theme/change.svg");
}
QString iconUrlForDefaultIconName(const QString &defaultIconName)
{
const QUrl urlForIcon{defaultIconName};
if (urlForIcon.isValid() && !urlForIcon.scheme().isEmpty()) {
return defaultIconName;
}
if (defaultIconName.startsWith(QStringLiteral("icon-"))) {
const auto parts = defaultIconName.split(QLatin1Char('-'));
if (parts.size() > 1) {
const QString iconFilePath = QStringLiteral(":/client/theme/") + parts[1] + QStringLiteral(".svg");
if (QFile::exists(iconFilePath)) {
return iconFilePath;
}
const QString blackIconFilePath = QStringLiteral(":/client/theme/black/") + parts[1] + QStringLiteral(".svg");
if (QFile::exists(blackIconFilePath)) {
return blackIconFilePath;
}
}
const auto iconNameFromIconPrefix = localIconPathFromIconPrefix(defaultIconName);
if (!iconNameFromIconPrefix.isEmpty()) {
return iconNameFromIconPrefix;
}
}
return QStringLiteral(":/client/theme/change.svg");
}
QString generateUrlForThumbnail(const QString &thumbnailUrl, const QUrl &serverUrl)
{
auto serverUrlCopy = serverUrl;
auto thumbnailUrlCopy = thumbnailUrl;
if (thumbnailUrlCopy.startsWith(QLatin1Char('/')) || thumbnailUrlCopy.startsWith(QLatin1Char('\\'))) {
// relative image resource URL, just needs some concatenation with current server URL
// some icons may contain parameters after (?)
const QStringList thumbnailUrlCopySplitted = thumbnailUrlCopy.contains(QLatin1Char('?'))
? thumbnailUrlCopy.split(QLatin1Char('?'), Qt::SkipEmptyParts)
: QStringList{thumbnailUrlCopy};
Q_ASSERT(!thumbnailUrlCopySplitted.isEmpty());
serverUrlCopy.setPath(thumbnailUrlCopySplitted[0]);
thumbnailUrlCopy = serverUrlCopy.toString();
if (thumbnailUrlCopySplitted.size() > 1) {
thumbnailUrlCopy += QLatin1Char('?') + thumbnailUrlCopySplitted[1];
}
}
return thumbnailUrlCopy;
}
QString generateUrlForIcon(const QString &fallackIcon, const QUrl &serverUrl)
{
auto serverUrlCopy = serverUrl;
auto fallackIconCopy = fallackIcon;
if (fallackIconCopy.startsWith(QLatin1Char('/')) || fallackIconCopy.startsWith(QLatin1Char('\\'))) {
// relative image resource URL, just needs some concatenation with current server URL
// some icons may contain parameters after (?)
const QStringList fallackIconPathSplitted =
fallackIconCopy.contains(QLatin1Char('?')) ? fallackIconCopy.split(QLatin1Char('?')) : QStringList{fallackIconCopy};
Q_ASSERT(!fallackIconPathSplitted.isEmpty());
serverUrlCopy.setPath(fallackIconPathSplitted[0]);
fallackIconCopy = serverUrlCopy.toString();
if (fallackIconPathSplitted.size() > 1) {
fallackIconCopy += QLatin1Char('?') + fallackIconPathSplitted[1];
}
} else if (!fallackIconCopy.isEmpty()) {
// could be one of names for standard icons (e.g. icon-mail)
const auto defaultIconUrl = iconUrlForDefaultIconName(fallackIconCopy);
if (!defaultIconUrl.isEmpty()) {
fallackIconCopy = defaultIconUrl;
}
}
return fallackIconCopy;
}
QString iconsFromThumbnailAndFallbackIcon(const QString &thumbnailUrl, const QString &fallackIcon, const QUrl &serverUrl)
{
if (thumbnailUrl.isEmpty() && fallackIcon.isEmpty()) {
return {};
}
if (serverUrl.isEmpty()) {
const QStringList listImages = {thumbnailUrl, fallackIcon};
return listImages.join(QLatin1Char(';'));
}
const auto urlForThumbnail = generateUrlForThumbnail(thumbnailUrl, serverUrl);
const auto urlForFallackIcon = generateUrlForIcon(fallackIcon, serverUrl);
if (urlForThumbnail.isEmpty() && !urlForFallackIcon.isEmpty()) {
return urlForFallackIcon;
}
if (!urlForThumbnail.isEmpty() && urlForFallackIcon.isEmpty()) {
return urlForThumbnail;
}
const QStringList listImages{urlForThumbnail, urlForFallackIcon};
return listImages.join(QLatin1Char(';'));
}
constexpr int searchTermEditingFinishedSearchStartDelay = 800;
// server-side bug of returning the cursor > 0 and isPaginated == 'true', using '5' as it is done on Android client's end now
constexpr int minimumEntresNumberToShowLoadMore = 5;
}
namespace OCC {
Q_LOGGING_CATEGORY(lcUnifiedSearch, "nextcloud.gui.unifiedsearch", QtInfoMsg)
UnifiedSearchResultsListModel::UnifiedSearchResultsListModel(AccountState *accountState, QObject *parent)
: QAbstractListModel(parent)
, _accountState(accountState)
{
}
QVariant UnifiedSearchResultsListModel::data(const QModelIndex &index, int role) const
{
Q_ASSERT(checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid));
switch (role) {
case ProviderNameRole:
return _results.at(index.row())._providerName;
case ProviderIdRole:
return _results.at(index.row())._providerId;
case ImagePlaceholderRole:
return imagePlaceholderUrlForProviderId(_results.at(index.row())._providerId);
case IconsRole:
return _results.at(index.row())._icons;
case TitleRole:
return _results.at(index.row())._title;
case SublineRole:
return _results.at(index.row())._subline;
case ResourceUrlRole:
return _results.at(index.row())._resourceUrl;
case RoundedRole:
return _results.at(index.row())._isRounded;
case TypeRole:
return _results.at(index.row())._type;
case TypeAsStringRole:
return UnifiedSearchResult::typeAsString(_results.at(index.row())._type);
}
return {};
}
int UnifiedSearchResultsListModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return _results.size();
}
QHash<int, QByteArray> UnifiedSearchResultsListModel::roleNames() const
{
auto roles = QAbstractListModel::roleNames();
roles[ProviderNameRole] = "providerName";
roles[ProviderIdRole] = "providerId";
roles[IconsRole] = "icons";
roles[ImagePlaceholderRole] = "imagePlaceholder";
roles[TitleRole] = "resultTitle";
roles[SublineRole] = "subline";
roles[ResourceUrlRole] = "resourceUrlRole";
roles[TypeRole] = "type";
roles[TypeAsStringRole] = "typeAsString";
roles[RoundedRole] = "isRounded";
return roles;
}
QString UnifiedSearchResultsListModel::searchTerm() const
{
return _searchTerm;
}
QString UnifiedSearchResultsListModel::errorString() const
{
return _errorString;
}
QString UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderId() const
{
return _currentFetchMoreInProgressProviderId;
}
void UnifiedSearchResultsListModel::setSearchTerm(const QString &term)
{
if (term == _searchTerm) {
return;
}
_searchTerm = term;
emit searchTermChanged();
if (!_errorString.isEmpty()) {
_errorString.clear();
emit errorStringChanged();
}
disconnectAndClearSearchJobs();
clearCurrentFetchMoreInProgressProviderId();
disconnect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this,
&UnifiedSearchResultsListModel::slotSearchTermEditingFinished);
if (_unifiedSearchTextEditingFinishedTimer.isActive()) {
_unifiedSearchTextEditingFinishedTimer.stop();
}
if (!_searchTerm.isEmpty()) {
_unifiedSearchTextEditingFinishedTimer.setInterval(searchTermEditingFinishedSearchStartDelay);
connect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this,
&UnifiedSearchResultsListModel::slotSearchTermEditingFinished);
_unifiedSearchTextEditingFinishedTimer.start();
}
if (!_results.isEmpty()) {
beginResetModel();
_results.clear();
endResetModel();
}
}
bool UnifiedSearchResultsListModel::isSearchInProgress() const
{
return !_searchJobConnections.isEmpty();
}
void UnifiedSearchResultsListModel::resultClicked(const QString &providerId, const QUrl &resourceUrl) const
{
const QUrlQuery urlQuery{resourceUrl};
const auto dir = urlQuery.queryItemValue(QStringLiteral("dir"), QUrl::ComponentFormattingOption::FullyDecoded);
const auto fileName =
urlQuery.queryItemValue(QStringLiteral("scrollto"), QUrl::ComponentFormattingOption::FullyDecoded);
if (providerId.contains(QStringLiteral("file"), Qt::CaseInsensitive) && !dir.isEmpty() && !fileName.isEmpty()) {
if (!_accountState || !_accountState->account()) {
return;
}
const QString relativePath = dir + QLatin1Char('/') + fileName;
const auto localFiles =
FolderMan::instance()->findFileInLocalFolders(QFileInfo(relativePath).path(), _accountState->account());
if (!localFiles.isEmpty()) {
QDesktopServices::openUrl(localFiles.constFirst());
return;
}
}
Utility::openBrowser(resourceUrl);
}
void UnifiedSearchResultsListModel::fetchMoreTriggerClicked(const QString &providerId)
{
if (isSearchInProgress() || !_currentFetchMoreInProgressProviderId.isEmpty()) {
return;
}
const auto providerInfo = _providers.value(providerId, {});
if (!providerInfo._id.isEmpty() && providerInfo._id == providerId && providerInfo._isPaginated) {
// Load more items
_currentFetchMoreInProgressProviderId = providerId;
emit currentFetchMoreInProgressProviderIdChanged();
startSearchForProvider(providerId, providerInfo._cursor);
}
}
void UnifiedSearchResultsListModel::slotSearchTermEditingFinished()
{
disconnect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this,
&UnifiedSearchResultsListModel::slotSearchTermEditingFinished);
if (!_accountState || !_accountState->account()) {
qCCritical(lcUnifiedSearch) << QString("Account state is invalid. Could not start search!");
return;
}
if (_providers.isEmpty()) {
auto job = new JsonApiJob(_accountState->account(), QLatin1String("ocs/v2.php/search/providers"));
QObject::connect(job, &JsonApiJob::jsonReceived, this, &UnifiedSearchResultsListModel::slotFetchProvidersFinished);
job->start();
} else {
startSearch();
}
}
void UnifiedSearchResultsListModel::slotFetchProvidersFinished(const QJsonDocument &json, int statusCode)
{
const auto job = qobject_cast<JsonApiJob *>(sender());
if (!job) {
qCCritical(lcUnifiedSearch) << QString("Failed to fetch providers.").arg(_searchTerm);
_errorString += tr("Failed to fetch providers.") + QLatin1Char('\n');
emit errorStringChanged();
return;
}
if (statusCode != 200) {
qCCritical(lcUnifiedSearch) << QString("%1: Failed to fetch search providers for '%2'. Error: %3")
.arg(statusCode)
.arg(_searchTerm)
.arg(job->errorString());
_errorString +=
tr("Failed to fetch search providers for '%1'. Error: %2").arg(_searchTerm).arg(job->errorString())
+ QLatin1Char('\n');
emit errorStringChanged();
return;
}
const auto providerList =
json.object().value(QStringLiteral("ocs")).toObject().value(QStringLiteral("data")).toVariant().toList();
for (const auto &provider : providerList) {
const auto providerMap = provider.toMap();
const auto id = providerMap[QStringLiteral("id")].toString();
const auto name = providerMap[QStringLiteral("name")].toString();
if (!name.isEmpty() && id != QStringLiteral("talk-message-current")) {
UnifiedSearchProvider newProvider;
newProvider._name = name;
newProvider._id = id;
newProvider._order = providerMap[QStringLiteral("order")].toInt();
_providers.insert(newProvider._id, newProvider);
}
}
if (!_providers.empty()) {
startSearch();
}
}
void UnifiedSearchResultsListModel::slotSearchForProviderFinished(const QJsonDocument &json, int statusCode)
{
Q_ASSERT(_accountState && _accountState->account());
const auto job = qobject_cast<JsonApiJob *>(sender());
if (!job) {
qCCritical(lcUnifiedSearch) << QString("Search has failed for '%2'.").arg(_searchTerm);
_errorString += tr("Search has failed for '%2'.").arg(_searchTerm) + QLatin1Char('\n');
emit errorStringChanged();
return;
}
const auto providerId = job->property("providerId").toString();
if (providerId.isEmpty()) {
return;
}
if (!_searchJobConnections.isEmpty()) {
_searchJobConnections.remove(providerId);
if (_searchJobConnections.isEmpty()) {
emit isSearchInProgressChanged();
}
}
if (providerId == _currentFetchMoreInProgressProviderId) {
clearCurrentFetchMoreInProgressProviderId();
}
if (statusCode != 200) {
qCCritical(lcUnifiedSearch) << QString("%1: Search has failed for '%2'. Error: %3")
.arg(statusCode)
.arg(_searchTerm)
.arg(job->errorString());
_errorString +=
tr("Search has failed for '%1'. Error: %2").arg(_searchTerm).arg(job->errorString()) + QLatin1Char('\n');
emit errorStringChanged();
return;
}
const auto data = json.object().value(QStringLiteral("ocs")).toObject().value(QStringLiteral("data")).toObject();
if (!data.isEmpty()) {
parseResultsForProvider(data, providerId, job->property("appendResults").toBool());
}
}
void UnifiedSearchResultsListModel::startSearch()
{
Q_ASSERT(_accountState && _accountState->account());
disconnectAndClearSearchJobs();
if (!_accountState || !_accountState->account()) {
return;
}
if (!_results.isEmpty()) {
beginResetModel();
_results.clear();
endResetModel();
}
for (const auto &provider : _providers) {
startSearchForProvider(provider._id);
}
}
void UnifiedSearchResultsListModel::startSearchForProvider(const QString &providerId, qint32 cursor)
{
Q_ASSERT(_accountState && _accountState->account());
if (!_accountState || !_accountState->account()) {
return;
}
auto job = new JsonApiJob(_accountState->account(),
QLatin1String("ocs/v2.php/search/providers/%1/search").arg(providerId));
QUrlQuery params;
params.addQueryItem(QStringLiteral("term"), _searchTerm);
if (cursor > 0) {
params.addQueryItem(QStringLiteral("cursor"), QString::number(cursor));
job->setProperty("appendResults", true);
}
job->setProperty("providerId", providerId);
job->addQueryParams(params);
const auto wasSearchInProgress = isSearchInProgress();
_searchJobConnections.insert(providerId,
QObject::connect(
job, &JsonApiJob::jsonReceived, this, &UnifiedSearchResultsListModel::slotSearchForProviderFinished));
if (isSearchInProgress() && !wasSearchInProgress) {
emit isSearchInProgressChanged();
}
job->start();
}
void UnifiedSearchResultsListModel::parseResultsForProvider(const QJsonObject &data, const QString &providerId, bool fetchedMore)
{
const auto cursor = data.value(QStringLiteral("cursor")).toInt();
const auto entries = data.value(QStringLiteral("entries")).toVariant().toList();
auto &provider = _providers[providerId];
if (provider._id.isEmpty() && fetchedMore) {
_providers.remove(providerId);
return;
}
if (entries.isEmpty()) {
// we may have received false pagination information from the server, such as, we expect more
// results available via pagination, but, there are no more left, so, we need to stop paginating for
// this provider
provider._isPaginated = false;
if (fetchedMore) {
removeFetchMoreTrigger(provider._id);
}
return;
}
provider._isPaginated = data.value(QStringLiteral("isPaginated")).toBool();
provider._cursor = cursor;
if (provider._pageSize == -1) {
provider._pageSize = cursor;
}
if ((provider._pageSize != -1 && entries.size() < provider._pageSize)
|| entries.size() < minimumEntresNumberToShowLoadMore) {
// for some providers we are still getting a non-null cursor and isPaginated true even thought
// there are no more results to paginate
provider._isPaginated = false;
}
QVector<UnifiedSearchResult> newEntries;
const auto makeResourceUrl = [](const QString &resourceUrl, const QUrl &accountUrl) {
QUrl finalResurceUrl(resourceUrl);
if (finalResurceUrl.scheme().isEmpty() && accountUrl.scheme().isEmpty()) {
finalResurceUrl = accountUrl;
finalResurceUrl.setPath(resourceUrl);
}
return finalResurceUrl;
};
for (const auto &entry : entries) {
const auto entryMap = entry.toMap();
if (entryMap.isEmpty()) {
continue;
}
UnifiedSearchResult result;
result._providerId = provider._id;
result._order = provider._order;
result._providerName = provider._name;
result._isRounded = entryMap.value(QStringLiteral("rounded")).toBool();
result._title = entryMap.value(QStringLiteral("title")).toString();
result._subline = entryMap.value(QStringLiteral("subline")).toString();
const auto resourceUrl = entryMap.value(QStringLiteral("resourceUrl")).toString();
const auto accountUrl = (_accountState && _accountState->account()) ? _accountState->account()->url() : QUrl();
result._resourceUrl = makeResourceUrl(resourceUrl, accountUrl);
result._icons = iconsFromThumbnailAndFallbackIcon(entryMap.value(QStringLiteral("thumbnailUrl")).toString(),
entryMap.value(QStringLiteral("icon")).toString(), accountUrl);
newEntries.push_back(result);
}
if (fetchedMore) {
appendResultsToProvider(newEntries, provider);
} else {
appendResults(newEntries, provider);
}
}
void UnifiedSearchResultsListModel::appendResults(QVector<UnifiedSearchResult> results, const UnifiedSearchProvider &provider)
{
if (provider._cursor > 0 && provider._isPaginated) {
UnifiedSearchResult fetchMoreTrigger;
fetchMoreTrigger._providerId = provider._id;
fetchMoreTrigger._providerName = provider._name;
fetchMoreTrigger._order = provider._order;
fetchMoreTrigger._type = UnifiedSearchResult::Type::FetchMoreTrigger;
results.push_back(fetchMoreTrigger);
}
if (_results.isEmpty()) {
beginInsertRows({}, 0, results.size() - 1);
_results = results;
endInsertRows();
return;
}
// insertion is done with sorting (first -> by order, then -> by name)
const auto itToInsertTo = std::find_if(std::begin(_results), std::end(_results),
[&provider](const UnifiedSearchResult &current) {
// insert before other results of higher order when possible
if (current._order > provider._order) {
return true;
} else {
if (current._order == provider._order) {
// insert before results of higher QString value when possible
return current._providerName > provider._name;
}
return false;
}
});
const auto first = static_cast<int>(std::distance(std::begin(_results), itToInsertTo));
const auto last = first + results.size() - 1;
beginInsertRows({}, first, last);
std::copy(std::begin(results), std::end(results), std::inserter(_results, itToInsertTo));
endInsertRows();
}
void UnifiedSearchResultsListModel::appendResultsToProvider(const QVector<UnifiedSearchResult> &results, const UnifiedSearchProvider &provider)
{
if (results.isEmpty()) {
return;
}
const auto providerId = provider._id;
/* we need to find the last result that is not a fetch-more-trigger or category-separator for the current
provider */
const auto itLastResultForProviderReverse =
std::find_if(std::rbegin(_results), std::rend(_results), [&providerId](const UnifiedSearchResult &result) {
return result._providerId == providerId && result._type == UnifiedSearchResult::Type::Default;
});
if (itLastResultForProviderReverse != std::rend(_results)) {
// #1 Insert rows
// convert reverse_iterator to iterator
const auto itLastResultForProvider = (itLastResultForProviderReverse + 1).base();
const auto first = static_cast<int>(std::distance(std::begin(_results), itLastResultForProvider + 1));
const auto last = first + results.size() - 1;
beginInsertRows({}, first, last);
std::copy(std::begin(results), std::end(results), std::inserter(_results, itLastResultForProvider + 1));
endInsertRows();
// #2 Remove the FetchMoreTrigger item if there are no more results to load for this provider
if (!provider._isPaginated) {
removeFetchMoreTrigger(providerId);
}
}
}
void UnifiedSearchResultsListModel::removeFetchMoreTrigger(const QString &providerId)
{
const auto itFetchMoreTriggerForProviderReverse = std::find_if(
std::rbegin(_results),
std::rend(_results),
[providerId](const UnifiedSearchResult &result) {
return result._providerId == providerId && result._type == UnifiedSearchResult::Type::FetchMoreTrigger;
});
if (itFetchMoreTriggerForProviderReverse != std::rend(_results)) {
// convert reverse_iterator to iterator
const auto itFetchMoreTriggerForProvider = (itFetchMoreTriggerForProviderReverse + 1).base();
if (itFetchMoreTriggerForProvider != std::end(_results)
&& itFetchMoreTriggerForProvider != std::begin(_results)) {
const auto eraseIndex = static_cast<int>(std::distance(std::begin(_results), itFetchMoreTriggerForProvider));
Q_ASSERT(eraseIndex >= 0 && eraseIndex < static_cast<int>(_results.size()));
beginRemoveRows({}, eraseIndex, eraseIndex);
_results.erase(itFetchMoreTriggerForProvider);
endRemoveRows();
}
}
}
void UnifiedSearchResultsListModel::disconnectAndClearSearchJobs()
{
for (const auto &connection : _searchJobConnections) {
if (connection) {
QObject::disconnect(connection);
}
}
if (!_searchJobConnections.isEmpty()) {
_searchJobConnections.clear();
emit isSearchInProgressChanged();
}
}
void UnifiedSearchResultsListModel::clearCurrentFetchMoreInProgressProviderId()
{
if (!_currentFetchMoreInProgressProviderId.isEmpty()) {
_currentFetchMoreInProgressProviderId.clear();
emit currentFetchMoreInProgressProviderIdChanged();
}
}
}

View file

@ -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;
};
}

View file

@ -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

View file

@ -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:

View file

@ -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)

View file

@ -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()

View file

@ -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;
};

View file

@ -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"

View file

@ -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>

View file

@ -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"
}

1
theme/black/calendar.svg Normal file
View file

@ -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>

After

Width:  |  Height:  |  Size: 646 B

1
theme/black/clear.svg Normal file
View file

@ -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>

After

Width:  |  Height:  |  Size: 259 B

1
theme/black/comment.svg Normal file
View file

@ -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>

After

Width:  |  Height:  |  Size: 302 B

8
theme/black/deck.svg Normal file
View file

@ -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>

After

Width:  |  Height:  |  Size: 367 B

1
theme/black/search.svg Normal file
View file

@ -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>

After

Width:  |  Height:  |  Size: 392 B