diff --git a/src/gui/filedetails/ShareView.qml b/src/gui/filedetails/ShareView.qml index e2cb0d658..20a63963c 100644 --- a/src/gui/filedetails/ShareView.qml +++ b/src/gui/filedetails/ShareView.qml @@ -152,6 +152,7 @@ ColumnLayout { } ShareeSearchField { + id: shareeSearchField Layout.fillWidth: true Layout.leftMargin: root.horizontalPadding Layout.rightMargin: root.horizontalPadding diff --git a/src/gui/filedetails/ShareeDelegate.qml b/src/gui/filedetails/ShareeDelegate.qml index a9128cb4c..221b14b69 100644 --- a/src/gui/filedetails/ShareeDelegate.qml +++ b/src/gui/filedetails/ShareeDelegate.qml @@ -20,8 +20,54 @@ import QtQuick.Controls 2.15 import com.nextcloud.desktopclient 1.0 import Style 1.0 +import "../tray" + ItemDelegate { id: root text: model.display + + contentItem: RowLayout { + height: visible ? implicitHeight : 0 + + Loader { + id: shareeIconLoader + + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + + active: model.icon !== "" + + sourceComponent: Image { + id: shareeIcon + + horizontalAlignment: Qt.AlignLeft + verticalAlignment: Qt.AlignVCenter + + width: height + height: shareeLabel.height + + smooth: true + antialiasing: true + mipmap: true + fillMode: Image.PreserveAspectFit + + source: model.icon + + sourceSize: Qt.size(shareeIcon.height * 1.0, shareeIcon.height * 1.0) + } + } + + EnforcedPlainTextLabel { + id: shareeLabel + Layout.preferredHeight: unifiedSearchResultSkeletonItemDetails.iconWidth + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + + Layout.fillWidth: true + + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + text: model.display + color: Style.ncTextColor + } + } } diff --git a/src/gui/filedetails/ShareeSearchField.qml b/src/gui/filedetails/ShareeSearchField.qml index 286d27391..c3dd98731 100644 --- a/src/gui/filedetails/ShareeSearchField.qml +++ b/src/gui/filedetails/ShareeSearchField.qml @@ -41,7 +41,7 @@ TextField { readonly property double iconsScaleFactor: 0.6 function triggerSuggestionsVisibility() { - shareeListView.count > 0 && text !== "" ? suggestionsPopup.open() : suggestionsPopup.close(); + shareeListView.count > 0 ? suggestionsPopup.open() : suggestionsPopup.close(); } placeholderText: qsTr("Search for users or groups…") @@ -73,7 +73,7 @@ TextField { case Qt.Key_Enter: case Qt.Key_Return: if(shareeListView.currentIndex > -1) { - shareeListView.itemAtIndex(shareeListView.currentIndex).selectSharee(); + shareeListView.itemAtIndex(shareeListView.currentIndex).selectItem(); event.accepted = true; break; } @@ -219,6 +219,9 @@ TextField { anchors.left: parent.left anchors.right: parent.right + enabled: model.type !== Sharee.LookupServerSearchResults + hoverEnabled: model.type !== Sharee.LookupServerSearchResults + function selectSharee() { root.shareeSelected(model.sharee); suggestionsPopup.close(); @@ -226,6 +229,15 @@ TextField { root.clear(); } + function selectItem() { + if (model.type === Sharee.LookupServerSearch) { + shareeListView.currentIndex = -1 + root.shareeModel.searchGlobally() + } else { + selectSharee() + } + } + onHoveredChanged: if (hovered) { // When we set the currentIndex the list view will scroll... // unless we tamper with the preferred highlight points to stop this. @@ -241,7 +253,7 @@ TextField { shareeListView.preferredHighlightBegin = savedPreferredHighlightBegin; shareeListView.preferredHighlightEnd = savedPreferredHighlightEnd; } - onClicked: selectSharee() + onClicked: selectItem() } } } diff --git a/src/gui/filedetails/shareemodel.cpp b/src/gui/filedetails/shareemodel.cpp index 13ced59dc..934c040c0 100644 --- a/src/gui/filedetails/shareemodel.cpp +++ b/src/gui/filedetails/shareemodel.cpp @@ -19,6 +19,7 @@ #include #include "ocsshareejob.h" +#include "theme.h" namespace OCC { @@ -29,7 +30,10 @@ ShareeModel::ShareeModel(QObject *parent) { _searchRateLimitingTimer.setSingleShot(true); _searchRateLimitingTimer.setInterval(500); + _searchGloballyPlaceholder.reset(new Sharee({}, tr("Search globally"), Sharee::LookupServerSearch, QStringLiteral("magnifying-glass.svg"))); + _searchGloballyPlaceholder->setIsIconColourful(true); connect(&_searchRateLimitingTimer, &QTimer::timeout, this, &ShareeModel::fetch); + connect(Theme::instance(), &Theme::darkModeChanged, this, &ShareeModel::slotDarkModeChanged); } // ---------------------- QAbstractListModel methods ---------------------- // @@ -48,6 +52,8 @@ QHash ShareeModel::roleNames() const auto roles = QAbstractListModel::roleNames(); roles[ShareeRole] = "sharee"; roles[AutoCompleterStringMatchRole] = "autoCompleterStringMatch"; + roles[TypeRole] = "type"; + roles[IconRole] = "icon"; return roles; } @@ -68,6 +74,10 @@ QVariant ShareeModel::data(const QModelIndex &index, const int role) const case AutoCompleterStringMatchRole: // Don't show this to the user return QString(sharee->displayName() + " (" + sharee->shareWith() + ")"); + case IconRole: + return sharee->iconUrlColoured(); + case TypeRole: + return sharee->type(); case ShareeRole: return QVariant::fromValue(sharee); } @@ -119,6 +129,12 @@ void ShareeModel::setSearchString(const QString &searchString) return; } + beginResetModel(); + _sharees.clear(); + endResetModel(); + + Q_EMIT shareesReady(); + _searchString = searchString; Q_EMIT searchStringChanged(); @@ -165,16 +181,28 @@ void ShareeModel::setShareeBlocklist(const QVariantList shareeBlocklist) filterSharees(); } +void ShareeModel::searchGlobally() +{ + setLookupMode(ShareeModel::LookupMode::GlobalSearch); + beginResetModel(); + _sharees.clear(); + endResetModel(); + + Q_EMIT shareesReady(); + fetch(); +} + // ------------------------- Internal data methods ------------------------- // void ShareeModel::fetch() { - if(!_accountState || !_accountState->account() || _searchString.isEmpty()) { + if (!_accountState || !_accountState->account() || _searchString.isEmpty()) { qCInfo(lcShareeModel) << "Not fetching sharees for searchString: " << _searchString; return; } _fetchOngoing = true; + Q_EMIT fetchOngoingChanged(); const auto shareItemTypeString = _shareItemIsFolder ? QStringLiteral("folder") : QStringLiteral("file"); @@ -233,9 +261,35 @@ void ShareeModel::shareesFetched(const QJsonDocument &reply) beginResetModel(); _sharees = newSharees; + insertSearchGloballyItem(newSharees); endResetModel(); Q_EMIT shareesReady(); + + setLookupMode(LookupMode::LocalSearch); +} + +void ShareeModel::insertSearchGloballyItem(const QVector &newShareesFetched) +{ + const auto foundIt = std::find_if(std::begin(_sharees), std::end(_sharees), [](const ShareePtr &sharee) { + return sharee->type() == Sharee::LookupServerSearch || sharee->type() == Sharee::LookupServerSearchResults; + }); + + // remove it if it somehow appeared not at the end, to avoid writing complex proxy models for sorting + if (foundIt != std::end(_sharees) && (foundIt + 1) != std::end(_sharees)) { + _sharees.erase(foundIt); + } + + _sharees.push_back(_searchGloballyPlaceholder); + + if (lookupMode() == LookupMode::GlobalSearch) { + const auto displayName = newShareesFetched.isEmpty() ? tr("No results found") : tr("Global search results"); + _searchGloballyPlaceholder->setDisplayName(displayName); + _searchGloballyPlaceholder->setType(Sharee::LookupServerSearchResults); + } else { + _searchGloballyPlaceholder->setDisplayName(tr("Search globally")); + _searchGloballyPlaceholder->setType(Sharee::LookupServerSearch); + } } ShareePtr ShareeModel::parseSharee(const QJsonObject &data) const @@ -275,4 +329,13 @@ void ShareeModel::filterSharees() Q_EMIT shareesReady(); } +void ShareeModel::slotDarkModeChanged() +{ + for (int i = 0; i < _sharees.size(); ++i) { + if (_sharees[i]->updateIconUrl()) { + Q_EMIT dataChanged(index(i), index(i), {IconRole}); + } + } +} + } diff --git a/src/gui/filedetails/shareemodel.h b/src/gui/filedetails/shareemodel.h index 94362d11b..edf085b3b 100644 --- a/src/gui/filedetails/shareemodel.h +++ b/src/gui/filedetails/shareemodel.h @@ -45,6 +45,8 @@ public: enum Roles { ShareeRole = Qt::UserRole + 1, AutoCompleterStringMatchRole, + TypeRole, + IconRole, }; Q_ENUM(Roles); @@ -80,12 +82,15 @@ public slots: void setSearchString(const QString &searchString); void setLookupMode(const OCC::ShareeModel::LookupMode lookupMode); void setShareeBlocklist(const QVariantList shareeBlocklist); + void searchGlobally(); void fetch(); private slots: void shareesFetched(const QJsonDocument &reply); + void insertSearchGloballyItem(const QVector &newShareesFetched); void filterSharees(); + void slotDarkModeChanged(); private: [[nodiscard]] ShareePtr parseSharee(const QJsonObject &data) const; @@ -100,6 +105,8 @@ private: QVector _sharees; QVector _shareeBlocklist; + + ShareePtr _searchGloballyPlaceholder; }; } diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index 5ef4b7e4c..a43363879 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -128,6 +128,7 @@ ownCloudGui::ownCloudGui(Application *parent) qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "UnifiedSearchResultsListModel", "UnifiedSearchResultsListModel"); qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "UserStatus", "Access to Status enum"); + qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "Sharee", "Access to Type enum"); qRegisterMetaTypeStreamOperators(); @@ -136,6 +137,7 @@ ownCloudGui::ownCloudGui(Application *parent) qRegisterMetaType("UserStatus"); qRegisterMetaType("SharePtr"); qRegisterMetaType("ShareePtr"); + qRegisterMetaType("Sharee"); qmlRegisterSingletonInstance("com.nextcloud.desktopclient", 1, 0, "UserModel", UserModel::instance()); qmlRegisterSingletonInstance("com.nextcloud.desktopclient", 1, 0, "UserAppsModel", UserAppsModel::instance()); diff --git a/src/gui/sharee.cpp b/src/gui/sharee.cpp index 206a9d0e6..0a45a1e0a 100644 --- a/src/gui/sharee.cpp +++ b/src/gui/sharee.cpp @@ -14,22 +14,29 @@ #include "sharee.h" #include "ocsshareejob.h" +#include "theme.h" #include #include #include -namespace OCC { - +namespace OCC +{ Q_LOGGING_CATEGORY(lcSharing, "nextcloud.gui.sharing", QtInfoMsg) -Sharee::Sharee(const QString shareWith, - const QString displayName, - const Type type) +Sharee::Sharee(const QString &shareWith, const QString &displayName, const Type type, const QString &iconUrl) : _shareWith(shareWith) , _displayName(displayName) , _type(type) + , _iconUrl(iconUrl) { + if (!_iconUrl.isEmpty()) { + // make sure no color path is contained in the url + _iconUrl.replace(QStringLiteral("/black"), ""); + _iconUrl.replace(QStringLiteral("/white"), ""); + _iconColor = Theme::instance()->darkMode() ? QStringLiteral("white") : QStringLiteral("black"); + } + updateIconUrl(); } QString Sharee::format() const @@ -61,9 +68,61 @@ QString Sharee::displayName() const return _displayName; } +void Sharee::setDisplayName(const QString &displayName) +{ + if (displayName != _displayName) { + _displayName = displayName; + } +} + +void Sharee::setIconUrl(const QString &iconUrl) +{ + if (iconUrl != _iconUrl) { + _iconUrl = iconUrl; + } +} + +void Sharee::setType(const Type &type) +{ + if (type != _type) { + _type = type; + } +} + +void Sharee::setIsIconColourful(const bool isColourful) +{ + if (_isIconColourful != isColourful) { + _isIconColourful = isColourful; + updateIconUrl(); + } +} + +bool Sharee::updateIconUrl() +{ + if (_iconUrl.isEmpty() || !_isIconColourful) { + return false; + } + + const auto iconUrlColoured = _iconUrlColoured; + _iconColor = (!_isIconColourful || !Theme::instance()->darkMode()) ? QStringLiteral("black") : QStringLiteral("white"); + _iconUrlColoured = QStringLiteral("image://svgimage-custom-color/") + _iconUrl + QStringLiteral("/") + _iconColor; + + return iconUrlColoured != _iconUrlColoured; +} + Sharee::Type Sharee::type() const { return _type; } +QString Sharee::iconUrl() const +{ + return _iconUrl; +} + +QString Sharee::iconUrlColoured() const +{ + return _iconUrlColoured; +} + } diff --git a/src/gui/sharee.h b/src/gui/sharee.h index 2139a9117..54f06858b 100644 --- a/src/gui/sharee.h +++ b/src/gui/sharee.h @@ -35,35 +35,47 @@ Q_DECLARE_LOGGING_CATEGORY(lcSharing) class Sharee { + Q_GADGET + Q_PROPERTY(QString format READ format) + Q_PROPERTY(QString shareWith MEMBER _shareWith) + Q_PROPERTY(QString displayName MEMBER _displayName) + Q_PROPERTY(QString iconUrlColoured MEMBER _iconUrlColoured) + Q_PROPERTY(Type type MEMBER _type) + public: // Keep in sync with Share::ShareType - enum Type { - User = 0, - Group = 1, - Email = 4, - Federated = 6, - Circle = 7, - Room = 10 - }; - - explicit Sharee(const QString shareWith, - const QString displayName, - const Type type); + enum Type { Invalid = -1, User = 0, Group = 1, Email = 4, Federated = 6, Circle = 7, Room = 10, LookupServerSearch = 999, LookupServerSearchResults = 1000 }; + Q_ENUM(Type); + explicit Sharee() = default; + explicit Sharee(const QString &shareWith, const QString &displayName, const Type type, const QString &iconUrl = {}); [[nodiscard]] QString format() const; [[nodiscard]] QString shareWith() const; [[nodiscard]] QString displayName() const; + [[nodiscard]] QString iconUrl() const; + [[nodiscard]] QString iconUrlColoured() const; [[nodiscard]] Type type() const; + bool updateIconUrl(); + + void setDisplayName(const QString &displayName); + void setType(const Type &type); + void setIsIconColourful(const bool isColourful); + void setIconUrl(const QString &iconUrl); private: QString _shareWith; QString _displayName; - Type _type; + QString _iconUrlColoured; + QString _iconColor; + Type _type = Type::Invalid; + QString _iconUrl; + bool _isIconColourful = false; }; using ShareePtr = QSharedPointer; } Q_DECLARE_METATYPE(OCC::ShareePtr) +Q_DECLARE_METATYPE(OCC::Sharee) #endif //SHAREE_H diff --git a/test/testshareemodel.cpp b/test/testshareemodel.cpp index c9560a75f..16304095f 100644 --- a/test/testshareemodel.cpp +++ b/test/testshareemodel.cpp @@ -33,6 +33,8 @@ class TestShareeModel : public QObject { Q_OBJECT + int _numLookupSearchParamSet = 0; + public: ~TestShareeModel() override { @@ -62,6 +64,9 @@ public: QString category; switch(definition.type) { + case Sharee::Invalid: + category = QStringLiteral("invalid"); + break; case Sharee::Circle: category = QStringLiteral("circles"); break; @@ -80,6 +85,12 @@ public: case Sharee::User: category = QStringLiteral("users"); break; + case Sharee::LookupServerSearch: + category = QStringLiteral("placeholder_lookupserversearch"); + break; + case Sharee::LookupServerSearchResults: + category = QStringLiteral("placeholder_lookupserversearchresults"); + break; } auto shareesInCategory = _shareesMap.value(category).toJsonArray(); @@ -244,6 +255,10 @@ private slots: const auto lookupParam = urlQuery.queryItemValue(QStringLiteral("lookup")); const auto formatParam = urlQuery.queryItemValue(QStringLiteral("format")); + if (!lookupParam.isEmpty() && lookupParam == QStringLiteral("true")) { + ++_numLookupSearchParamSet; + } + if (formatParam != QStringLiteral("json")) { reply = new FakeErrorReply(op, req, this, 400, fake400Response); } else { @@ -324,12 +339,51 @@ private slots: const auto searchString = QStringLiteral("i"); model.setSearchString(searchString); QVERIFY(shareesReady.wait(3000)); - QCOMPARE(model.rowCount(), shareesCount(searchString)); + QCOMPARE(model.rowCount(), shareesCount(searchString) + 1); + QVERIFY(model.rowCount() > 0); + auto lastElementType = model.data(model.index(model.rowCount() - 1), ShareeModel::Roles::TypeRole).toInt(); + QVERIFY(lastElementType == Sharee::Type::LookupServerSearch); const auto emailSearchString = QStringLiteral("email"); model.setSearchString(emailSearchString); QVERIFY(shareesReady.wait(3000)); - QCOMPARE(model.rowCount(), shareesCount(emailSearchString)); + QCOMPARE(model.rowCount(), shareesCount(emailSearchString) + 1); + QVERIFY(model.rowCount() > 0); + lastElementType = model.data(model.index(model.rowCount() - 1), ShareeModel::Roles::TypeRole).toInt(); + QVERIFY(lastElementType == Sharee::Type::LookupServerSearch); + } + + void testShareesFetchGlobally() + { + resetTestData(); + standardReplyPopulate(); + + ShareeModel model; + QAbstractItemModelTester modelTester(&model); + QCOMPARE(model.rowCount(), 0); + + model.setAccountState(_accountState.data()); + + QSignalSpy shareesReady(&model, &ShareeModel::shareesReady); + const auto emailSearchString = QStringLiteral("email"); + model.setSearchString(emailSearchString); + QVERIFY(shareesReady.wait(3000)); + QCOMPARE(model.rowCount(), shareesCount(emailSearchString) + 1); + QVERIFY(model.rowCount() > 0); + auto lastElementType = model.data(model.index(model.rowCount() - 1), ShareeModel::Roles::TypeRole).toInt(); + QVERIFY(lastElementType == Sharee::Type::LookupServerSearch); + QCOMPARE(_numLookupSearchParamSet, 0); + + QSignalSpy lookupModeChanged(&model, &ShareeModel::lookupModeChanged); + model.searchGlobally(); + QVERIFY(shareesReady.wait(3000)); + QCOMPARE(lookupModeChanged.count(), 2); + QVERIFY(model.lookupMode() == ShareeModel::LookupMode::LocalSearch); + QCOMPARE(model.rowCount(), shareesCount(emailSearchString) + 1); + QVERIFY(model.rowCount() > 0); + lastElementType = model.data(model.index(model.rowCount() - 1), ShareeModel::Roles::TypeRole).toInt(); + QVERIFY(lastElementType == Sharee::Type::LookupServerSearchResults); + QCOMPARE(_numLookupSearchParamSet, 1); } void testFetchSignalling() @@ -367,7 +421,9 @@ private slots: QSignalSpy shareesReady(&model, &ShareeModel::shareesReady); QVERIFY(shareesReady.wait(3000)); - QCOMPARE(model.rowCount(), shareesCount(searchString)); + QCOMPARE(model.rowCount(), shareesCount(searchString) + 1); + auto lastElementType = model.data(model.index(model.rowCount() - 1), ShareeModel::Roles::TypeRole).toInt(); + QVERIFY(lastElementType == Sharee::Type::LookupServerSearch); const auto shareeIndex = model.index(0, 0, {}); @@ -409,12 +465,16 @@ private slots: const auto searchString = QStringLiteral("i"); model.setSearchString(searchString); QVERIFY(shareesReady.wait(3000)); - QCOMPARE(model.rowCount(), shareesCount(searchString) - 1); + QCOMPARE(model.rowCount(), shareesCount(searchString) - 1 + 1); + auto lastElementType = model.data(model.index(model.rowCount() - 1), ShareeModel::Roles::TypeRole).toInt(); + QVERIFY(lastElementType == Sharee::Type::LookupServerSearch); const ShareePtr shareeTwo(new Sharee(_michaelUserDefinition.shareWith, _michaelUserDefinition.label, _michaelUserDefinition.type)); const QVariantList largerShareeBlocklist {QVariant::fromValue(sharee), QVariant::fromValue(shareeTwo)}; model.setShareeBlocklist(largerShareeBlocklist); - QCOMPARE(model.rowCount(), shareesCount(searchString) - 2); + QCOMPARE(model.rowCount(), shareesCount(searchString) - 2 + 1); + lastElementType = model.data(model.index(model.rowCount() - 1), ShareeModel::Roles::TypeRole).toInt(); + QVERIFY(lastElementType == Sharee::Type::LookupServerSearch); } void testServerError()