diff --git a/resources.qrc b/resources.qrc index bc09408ae841a..1e411df911a56 100644 --- a/resources.qrc +++ b/resources.qrc @@ -72,8 +72,11 @@ src/gui/wizard/qml/ServerPage.qml src/gui/wizard/qml/SyncOptionsPage.qml src/gui/wizard/qml/WizardButton.qml + src/gui/wizard/qml/WizardChipButton.qml src/gui/wizard/qml/WizardComboBox.qml src/gui/wizard/qml/WizardDialogFrame.qml + src/gui/wizard/qml/WizardMenu.qml + src/gui/wizard/qml/WizardMenuItem.qml src/gui/wizard/qml/WizardTextField.qml src/gui/macOS/ui/FileProviderFileDelegate.qml src/gui/integration/FileActionsWindow.qml diff --git a/src/gui/iconutils.cpp b/src/gui/iconutils.cpp index f153b13ebe53c..07ff138f184a7 100644 --- a/src/gui/iconutils.cpp +++ b/src/gui/iconutils.cpp @@ -164,8 +164,8 @@ QImage drawSvgWithCustomFillColor(const QString &sourceSvgPath, return {}; } - const auto reqSize = (requestedSize.isValid() && requestedSize.height() && requestedSize.height()) ? requestedSize : svgRenderer.defaultSize(); - if (!reqSize.isValid() || !reqSize.height() || !reqSize.height()) { + const auto reqSize = (requestedSize.isValid() && requestedSize.width() && requestedSize.height()) ? requestedSize : svgRenderer.defaultSize(); + if (!reqSize.isValid() || !reqSize.width() || !reqSize.height()) { return {}; } diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index 17fd2b6bfaf76..443fb9f507d20 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -39,6 +39,7 @@ #include "tray/trayactivationpolicy.h" #include "tray/trayaccountappsmodel.h" #include "search/unifiedsearchresultslistmodel.h" +#include "search/unifiedsearchpeoplemodel.h" #include "integration/fileactionsmodel.h" #include "governance/applygovernancelabel.h" #include "governance/deletegovernancelabel.h" @@ -159,6 +160,7 @@ ownCloudGui::ownCloudGui(Application *parent) qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "FileDetails"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "ShareModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "ShareeModel"); + qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "UnifiedSearchPeopleModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "SortedShareModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "SyncConflictsModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "FileActionsModel"); diff --git a/src/gui/search/CMakeLists.txt b/src/gui/search/CMakeLists.txt index d4dbcddc13dbd..0320e76d51ffa 100644 --- a/src/gui/search/CMakeLists.txt +++ b/src/gui/search/CMakeLists.txt @@ -9,6 +9,8 @@ target_sources(nextcloudGuiSearch unifiedsearchresult.cpp unifiedsearchresultslistmodel.h unifiedsearchresultslistmodel.cpp + unifiedsearchpeoplemodel.h + unifiedsearchpeoplemodel.cpp ) ecm_add_qml_module(nextcloudGuiSearch @@ -16,7 +18,12 @@ ecm_add_qml_module(nextcloudGuiSearch GENERATE_PLUGIN_SOURCE QML_FILES SearchWindow.qml + UnifiedSearchCustomDateRangeDialog.qml + UnifiedSearchDetailHeader.qml + UnifiedSearchFilterBar.qml UnifiedSearchInputContainer.qml + UnifiedSearchPeoplePopup.qml + UnifiedSearchResultDelegate.qml UnifiedSearchResultFetchMoreTrigger.qml UnifiedSearchResultItem.qml UnifiedSearchResultItemSkeleton.qml diff --git a/src/gui/search/SearchWindow.qml b/src/gui/search/SearchWindow.qml index c953b391081b6..782bc8289ff09 100644 --- a/src/gui/search/SearchWindow.qml +++ b/src/gui/search/SearchWindow.qml @@ -10,19 +10,19 @@ import QtQuick.Layouts import Style import com.nextcloud.desktopclient import "qrc:/qml/src/gui" +import "qrc:/qml/src/gui/tray" +import "qrc:/qml/src/gui/wizard/qml" WizardStyledWindow { id: root property var account: null property var searchModel: null - readonly property string headline: qsTr("Search") - readonly property int searchState: searchModel - ? searchModel.searchState - : UnifiedSearchResultsListModel.Placeholder - readonly property bool isSearchInProgress: searchModel !== null && searchModel.isSearchInProgress - readonly property bool canEditSearch: searchModel !== null && searchModel.canEditSearch - readonly property bool isAccountConnected: searchModel !== null && searchModel.isAccountConnected + readonly property bool aggregateView: searchModel && searchModel.viewMode === UnifiedSearchResultsListModel.Aggregate + readonly property bool filtersVisible: aggregateView && searchModel && searchModel.providersReady + readonly property int searchState: searchModel ? searchModel.searchState : UnifiedSearchResultsListModel.Placeholder + readonly property bool peoplePopupOpened: peoplePopupLoader.status === Loader.Ready && peoplePopupLoader.item.opened + readonly property bool customRangeDialogOpened: customRangeDialogLoader.status === Loader.Ready && customRangeDialogLoader.item.opened title: "" width: Style.searchWindowWidth @@ -31,57 +31,132 @@ WizardStyledWindow { minimumHeight: Style.wizardStandaloneWindowMinimumHeight function focusSearchInput() { - if (visible && searchInput.enabled) { - searchInput.forceActiveFocus() + if (visible && searchInput.enabled) searchInput.forceActiveFocus() + } + + function openPeoplePopup() { + if (peoplePopupLoader.status === Loader.Ready) { + peoplePopupLoader.item.open() + return + } + peoplePopupLoader.active = true + } + + function openCustomRangeDialog() { + if (customRangeDialogLoader.status === Loader.Ready) { + customRangeDialogLoader.item.open() + return } + customRangeDialogLoader.active = true } Shortcut { sequences: [StandardKey.Cancel] + enabled: !filterBar.opened && !root.peoplePopupOpened && !root.customRangeDialogOpened onActivated: root.close() } - Component.onCompleted: Qt.callLater(focusSearchInput) - onVisibleChanged: { - if (visible) { - Qt.callLater(focusSearchInput) + UnifiedSearchPeopleModel { + id: peopleSuggestionsModel + accountState: root.searchModel ? root.searchModel.accountState : null + } + + Connections { + target: root.searchModel + function onSelectedRowChanged() { + if (root.searchModel && root.searchModel.selectedRow >= 0 + && root.searchModel.selectedRow < resultsList.count) { + resultsList.positionViewAtIndex(root.searchModel.selectedRow, ListView.Contain) + } + } + function onViewModeChanged() { + Qt.callLater(function() { + if (root.searchModel && root.searchModel.selectedRow >= 0 + && root.searchModel.selectedRow < resultsList.count) { + resultsList.positionViewAtIndex(root.searchModel.selectedRow, ListView.Beginning) + } + }) + } + function onAccessibilityStatusChanged() { + if (root.searchModel.accessibilityStatus.length > 0) + Accessible.announce(root.searchModel.accessibilityStatus, Accessible.Polite) } } + Component.onCompleted: Qt.callLater(focusSearchInput) + onVisibleChanged: if (visible) Qt.callLater(focusSearchInput) + ColumnLayout { anchors.fill: parent - anchors.leftMargin: Style.wizardWindowMargin - anchors.rightMargin: Style.wizardWindowMargin + anchors.margins: Style.wizardWindowMargin anchors.topMargin: Style.wizardWindowTopMargin - anchors.bottomMargin: Style.wizardWindowMargin - spacing: Style.wizardSectionSpacing + spacing: Style.smallSpacing WindowAccountHeader { Layout.fillWidth: true - title: root.headline + title: qsTr("Search") user: root.account } UnifiedSearchInputContainer { id: searchInput - Layout.fillWidth: true Layout.preferredHeight: Style.unifiedSearchInputContainerHeight enabled: root.searchModel !== null - readOnly: !root.canEditSearch + readOnly: !root.searchModel || !root.searchModel.canEditSearch text: root.searchModel ? root.searchModel.searchTerm : "" - placeholderText: root.account !== null && !root.isAccountConnected + isSearchInProgress: root.searchModel + && (root.searchModel.isSearchInProgress + || root.searchModel.waitingForSearchTermEditEnd + || root.searchModel.isFetchMoreInProgress) + placeholderText: root.searchModel && !root.searchModel.isAccountConnected ? qsTr("Search is available when this account is connected") : qsTr("Search files, messages, events …") - isSearchInProgress: root.isSearchInProgress - onTextEdited: { - if (root.searchModel) { - root.searchModel.searchTerm = searchInput.text - } - } - onClearText: { - if (root.searchModel) { - root.searchModel.searchTerm = "" + onTextEdited: if (root.searchModel) root.searchModel.searchTerm = text + onClearText: if (root.searchModel) root.searchModel.searchTerm = "" + onMoveSelection: direction => root.searchModel.moveSelection(direction) + onActivateSelection: root.searchModel.activateSelected() + } + + UnifiedSearchDetailHeader { + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + visible: root.searchModel && !root.aggregateView + searchModel: root.searchModel + onNavigateBack: root.focusSearchInput() + } + + UnifiedSearchFilterBar { + id: filterBar + + Layout.fillWidth: true + Layout.preferredHeight: visible ? implicitHeight : 0 + visible: root.filtersVisible + searchModel: root.searchModel + onCustomDateRangeRequested: root.openCustomRangeDialog() + onPeopleRequested: root.openPeoplePopup() + } + + Flow { + objectName: "activeFilterFlow" + Layout.fillWidth: true + Layout.preferredHeight: visible ? implicitHeight : 0 + visible: root.filtersVisible && root.searchModel && root.searchModel.activeFilters.length > 0 + spacing: Style.smallSpacing + Repeater { + model: root.searchModel ? root.searchModel.activeFilters : [] + delegate: WizardChipButton { + id: chipButton + objectName: "activeFilterChip" + required property var modelData + text: modelData.label + textSuffix: "×" + iconBeforeText: true + iconSource: modelData.icon ? "image://tray-image-provider/" + modelData.icon : "" + tintIcon: true + iconTintColor: Style.wizardPrimaryText + Accessible.name: qsTr("Remove %1 filter").arg(modelData.label) + onClicked: root.searchModel.removeFilter(modelData.type, modelData.id) } } } @@ -96,12 +171,17 @@ WizardStyledWindow { Layout.fillWidth: true Layout.fillHeight: true - ErrorBox { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top + ColumnLayout { + anchors.centerIn: parent + width: Math.min(parent.width, Style.wizardDialogMaximumWidth) visible: root.searchState === UnifiedSearchResultsListModel.SearchError - text: root.searchModel ? root.searchModel.errorString : "" + ErrorBox { Layout.fillWidth: true; text: root.searchModel ? root.searchModel.errorString : "" } + WizardButton { + Layout.alignment: Qt.AlignHCenter + primary: true + text: qsTr("Retry") + onClicked: root.searchModel.retry() + } } UnifiedSearchPlaceholderView { @@ -115,63 +195,162 @@ WizardStyledWindow { text: root.searchModel ? root.searchModel.searchTerm : "" } - Loader { - anchors.fill: parent - anchors.margins: Style.smallSpacing - active: root.searchState === UnifiedSearchResultsListModel.Skeleton - asynchronous: true - - sourceComponent: UnifiedSearchResultItemSkeletonContainer { - anchors.fill: parent - spacing: searchResultsListView.spacing - animationRectangleWidth: root.width - } - } - ScrollView { - id: searchResultsScrollView - anchors.fill: parent contentWidth: availableWidth visible: root.searchState === UnifiedSearchResultsListModel.Results - + || root.searchState === UnifiedSearchResultsListModel.Skeleton ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ListView { - id: searchResultsListView + id: resultsList - spacing: Style.smallSpacing + objectName: "searchResultsList" clip: true - keyNavigationEnabled: true reuseItems: true + spacing: Style.extraSmallSpacing model: root.searchModel - + currentIndex: root.searchModel ? root.searchModel.selectedRow : -1 Accessible.role: Accessible.List - Accessible.name: qsTr("Search results list") - - delegate: UnifiedSearchResultListItem { - width: searchResultsListView.width - isSearchInProgress: root.isSearchInProgress - currentFetchMoreInProgressProviderId: root.searchModel - ? root.searchModel.currentFetchMoreInProgressProviderId - : "" - fetchMoreTriggerClicked: root.searchModel - ? root.searchModel.fetchMoreTriggerClicked - : function() {} - resultClicked: root.searchModel - ? root.searchModel.resultClicked - : function() {} - ListView.onPooled: isPooled = true - ListView.onReused: isPooled = false + Accessible.name: qsTr("Search results") + + delegate: Item { + id: delegateData + + required property string providerName + required property string providerId + required property string providerIcon + required property string resultTitle + required property string subline + required property url resourceUrlRole + required property string darkIcons + required property string lightIcons + required property bool darkIconsIsThumbnail + required property bool lightIconsIsThumbnail + required property string darkImagePlaceholder + required property string lightImagePlaceholder + required property bool isRounded + required property int type + required property bool isSelected + required property bool isPartialMatch + required property bool hasOverflow + required property bool isLoading + + readonly property alias loadedItem: resultDelegate.loadedItem + + width: resultsList.width + height: resultDelegate.implicitHeight + + UnifiedSearchResultDelegate { + id: resultDelegate + + anchors.fill: parent + searchModel: root.searchModel + providerName: delegateData.providerName + providerId: delegateData.providerId + providerIcon: delegateData.providerIcon + resultTitle: delegateData.resultTitle + subline: delegateData.subline + resourceUrlRole: delegateData.resourceUrlRole + darkIcons: delegateData.darkIcons + lightIcons: delegateData.lightIcons + darkIconsIsThumbnail: delegateData.darkIconsIsThumbnail + lightIconsIsThumbnail: delegateData.lightIconsIsThumbnail + darkImagePlaceholder: delegateData.darkImagePlaceholder + lightImagePlaceholder: delegateData.lightImagePlaceholder + isRounded: delegateData.isRounded + resultType: delegateData.type + isSelected: delegateData.isSelected + isPartialMatch: delegateData.isPartialMatch + hasOverflow: delegateData.hasOverflow + isLoading: delegateData.isLoading + } } - section.property: "providerName" - section.criteria: ViewSection.FullString - section.delegate: UnifiedSearchResultSectionItem { - width: searchResultsListView.width + footer: Column { + objectName: "searchResultsLoadingFooter" + width: resultsList.width + height: visible ? implicitHeight : 0 + spacing: Style.smallSpacing + visible: root.searchModel && root.searchModel.isSearchInProgress + Accessible.ignored: true + Repeater { + model: Style.unifiedSearchLoadingPlaceholderCount + Rectangle { + required property int index + width: resultsList.width * (Style.unifiedSearchLoadingPlaceholderInitialWidthRatio + + index * Style.unifiedSearchLoadingPlaceholderWidthStep) + height: Style.unifiedSearchProviderHeaderHeight + radius: Style.mediumRoundedButtonRadius + color: palette.alternateBase + opacity: Style.unifiedSearchLoadingPlaceholderOpacity + } + } } } } } + + RowLayout { + objectName: "partialFailureFooter" + Layout.fillWidth: true + visible: root.aggregateView && root.searchModel && root.searchModel.hasPartialFailure + EnforcedPlainTextLabel { Layout.fillWidth: true; text: qsTr("Some sources unavailable"); color: palette.placeholderText } + WizardButton { text: qsTr("Retry"); onClicked: root.searchModel.retryFailedProviders() } + } + + WizardButton { + Layout.fillWidth: true + visible: root.searchModel && root.searchModel.showConnectedServicesAction + text: root.searchModel && root.searchModel.externalProvidersEnabled + ? qsTr("Less from connected services") : qsTr("More from connected services") + onClicked: root.searchModel.setExternalProvidersEnabled(!root.searchModel.externalProvidersEnabled) + } } + + Component { + id: peoplePopupComponent + + UnifiedSearchPeoplePopup { + searchModel: root.searchModel + peopleModel: peopleSuggestionsModel + windowWidth: root.width + onClosed: peoplePopupLoader.active = false + onPersonSelected: root.focusSearchInput() + } + } + + Loader { + id: peoplePopupLoader + + active: false + sourceComponent: peoplePopupComponent + onLoaded: { + if (status === Loader.Ready) { + item.open() + } + } + } + + Component { + id: customRangeDialogComponent + + UnifiedSearchCustomDateRangeDialog { + searchModel: root.searchModel + onClosed: customRangeDialogLoader.active = false + } + } + + Loader { + id: customRangeDialogLoader + + active: false + sourceComponent: customRangeDialogComponent + onLoaded: { + if (status === Loader.Ready) { + item.open() + } + } + } + } diff --git a/src/gui/search/UnifiedSearchCustomDateRangeDialog.qml b/src/gui/search/UnifiedSearchCustomDateRangeDialog.qml new file mode 100644 index 0000000000000..ffaf568fbd6fc --- /dev/null +++ b/src/gui/search/UnifiedSearchCustomDateRangeDialog.qml @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +import Style +import "qrc:/qml/src/gui/tray" +import "qrc:/qml/src/gui/wizard/qml" + +Dialog { + id: root + + required property var searchModel + + property bool validationError: false + + anchors.centerIn: parent + title: qsTr("Custom date range") + modal: true + onOpened: validationError = false + + footer: RowLayout { + spacing: Style.wizardFooterSpacing + + Item { + Layout.fillWidth: true + } + + WizardButton { + text: qsTr("Cancel") + onClicked: root.close() + } + + WizardButton { + primary: true + text: qsTr("Apply") + onClicked: { + root.validationError = !root.searchModel.setCustomDateRange(customSince.text, customUntil.text) + if (!root.validationError) { + root.close() + } + } + } + } + + ColumnLayout { + EnforcedPlainTextLabel { + text: qsTr("Start date (YYYY-MM-DD)") + } + + TextField { + id: customSince + + Layout.fillWidth: true + placeholderText: qsTr("YYYY-MM-DD") + } + + EnforcedPlainTextLabel { + text: qsTr("End date (YYYY-MM-DD)") + } + + TextField { + id: customUntil + + Layout.fillWidth: true + placeholderText: qsTr("YYYY-MM-DD") + } + + EnforcedPlainTextLabel { + visible: root.validationError + text: qsTr("Enter valid dates with the start date before the end date.") + color: palette.accent + } + } +} diff --git a/src/gui/search/UnifiedSearchDetailHeader.qml b/src/gui/search/UnifiedSearchDetailHeader.qml new file mode 100644 index 0000000000000..4bb2596bf407a --- /dev/null +++ b/src/gui/search/UnifiedSearchDetailHeader.qml @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls.Basic + +import Style +import "qrc:/qml/src/gui/tray" + +Item { + id: root + + required property var searchModel + + signal navigateBack() + + objectName: "searchDetailHeader" + implicitHeight: Style.unifiedSearchDetailHeaderHeight + + ToolButton { + id: backButton + + objectName: "searchDetailBackButton" + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Back") + icon.source: "image://svgimage-custom-color/" + + (root.LayoutMirroring.enabled ? "arrow-right.svg/" : "arrow-left.svg/") + + Style.wizardPrimaryText + icon.width: Style.smallIconSize + icon.height: Style.smallIconSize + display: AbstractButton.TextBesideIcon + Accessible.name: qsTr("Back to all search results") + onClicked: { + root.searchModel.closeProviderDetail() + root.navigateBack() + } + } + + EnforcedPlainTextLabel { + objectName: "searchDetailProviderTitle" + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: backButton.width + Style.smallSpacing + anchors.rightMargin: backButton.width + Style.smallSpacing + anchors.verticalCenter: parent.verticalCenter + text: root.searchModel ? root.searchModel.detailProviderName : "" + font.bold: true + font.pixelSize: Style.wizardHeaderTitleFontPixelSize + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + } +} diff --git a/src/gui/search/UnifiedSearchFilterBar.qml b/src/gui/search/UnifiedSearchFilterBar.qml new file mode 100644 index 0000000000000..cb9072766df20 --- /dev/null +++ b/src/gui/search/UnifiedSearchFilterBar.qml @@ -0,0 +1,167 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls.Basic + +import Style +import "qrc:/qml/src/gui/wizard/qml" + +Flow { + id: root + + required property var searchModel + + readonly property bool opened: typeMenu.opened || dateMenu.opened + + signal customDateRangeRequested() + signal peopleRequested() + + objectName: "categoryFilterFlow" + spacing: Style.smallSpacing + + function hasActiveFilter(type) { + if (!searchModel) { + return false + } + const filters = searchModel.activeFilters + for (let index = 0; index < filters.length; ++index) { + if (filters[index].type === type) { + return true + } + } + return false + } + + WizardButton { + id: typeFilterButton + + objectName: "typeFilterButton" + width: Math.max(Style.unifiedSearchFilterButtonMinimumWidth, + (root.width - 2 * root.spacing) / 3) + text: qsTr("Type") + trailingIconSource: "image://svgimage-custom-color/caret-down.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + iconBeforeText: true + iconSource: "image://svgimage-custom-color/folder.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + primary: root.hasActiveFilter("provider") + Accessible.name: qsTr("Filter by type") + onClicked: typeMenu.toggle() + + WizardMenu { + id: typeMenu + + objectName: "typeFilterMenu" + anchorItem: typeFilterButton + width: anchorItem.width * Style.unifiedSearchFilterMenuWidthFactor + + Binding on height { + when: root.searchModel + && root.searchModel.providers.length + > Style.unifiedSearchProviderMenuMaximumVisibleRows + value: Style.unifiedSearchProviderMenuMaximumVisibleRows + * Style.standardPrimaryButtonHeight + + typeMenu.topPadding + typeMenu.bottomPadding + } + + Repeater { + model: root.searchModel ? root.searchModel.providers : [] + + delegate: WizardMenuItem { + id: providerMenuItem + + required property var modelData + + text: (providerMenuItem.modelData.selected ? "✓ " : "") + providerMenuItem.modelData.name + icon.source: providerMenuItem.modelData.icon + ? "image://tray-image-provider/" + providerMenuItem.modelData.icon + : "" + tintIcon: true + iconTintColor: Style.wizardPrimaryText + onTriggered: root.searchModel.toggleProviderFilter(providerMenuItem.modelData.id) + } + } + } + } + + WizardButton { + id: dateFilterButton + + objectName: "dateFilterButton" + width: Math.max(Style.unifiedSearchFilterButtonMinimumWidth, + (root.width - 2 * root.spacing) / 3) + text: qsTr("Date") + trailingIconSource: "image://svgimage-custom-color/caret-down.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + iconBeforeText: true + iconSource: "image://svgimage-custom-color/calendar.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + primary: root.hasActiveFilter("date") + enabled: root.searchModel && root.searchModel.dateFilterAvailable + Accessible.name: qsTr("Filter by date") + Accessible.description: enabled ? "" : qsTr("No search source supports date filtering") + onClicked: dateMenu.toggle() + + WizardMenu { + id: dateMenu + + objectName: "dateFilterMenu" + anchorItem: dateFilterButton + + WizardMenuItem { + objectName: "dateTodayMenuItem" + text: qsTr("Today") + onTriggered: root.searchModel.setDatePreset("today") + } + WizardMenuItem { + text: qsTr("Last 7 days") + onTriggered: root.searchModel.setDatePreset("last7days") + } + WizardMenuItem { + text: qsTr("Last 30 days") + onTriggered: root.searchModel.setDatePreset("last30days") + } + WizardMenuItem { + text: qsTr("This year") + onTriggered: root.searchModel.setDatePreset("thisyear") + } + WizardMenuItem { + text: qsTr("Last year") + onTriggered: root.searchModel.setDatePreset("lastyear") + } + MenuSeparator {} + WizardMenuItem { + text: qsTr("Custom range …") + onTriggered: root.customDateRangeRequested() + } + WizardMenuItem { + text: qsTr("Clear date") + onTriggered: root.searchModel.clearDateFilter() + } + } + } + + WizardButton { + id: peopleButton + + objectName: "peopleFilterButton" + width: Math.max(Style.unifiedSearchFilterButtonMinimumWidth, + (root.width - 2 * root.spacing) / 3) + text: qsTr("People") + trailingIconSource: "image://svgimage-custom-color/caret-down.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + iconBeforeText: true + iconSource: "image://svgimage-custom-color/account-group.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + primary: root.hasActiveFilter("person") + enabled: root.searchModel && root.searchModel.peopleFilterAvailable + Accessible.name: qsTr("Filter by person") + Accessible.description: enabled ? "" : qsTr("No search source supports people filtering") + onClicked: root.peopleRequested() + } +} diff --git a/src/gui/search/UnifiedSearchInputContainer.qml b/src/gui/search/UnifiedSearchInputContainer.qml index 6d66558f7b323..ecdba98208352 100644 --- a/src/gui/search/UnifiedSearchInputContainer.qml +++ b/src/gui/search/UnifiedSearchInputContainer.qml @@ -1,98 +1,90 @@ /* - * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-2.0-or-later */ -import QtQml import QtQuick import QtQuick.Controls -import Qt5Compat.GraphicalEffects import Style - import com.nextcloud.desktopclient -import "qrc:/qml/src/gui/common" import "qrc:/qml/src/gui/tray" -NCContextMenuTextField { +TextField { id: root signal clearText() + signal moveSelection(int direction) + signal activateSelection() property bool isSearchInProgress: false - - readonly property color textFieldIconsColor: palette.placeholderText - - readonly property int iconInset: Style.smallSpacing - - readonly property real leadingControlWidth: root.isSearchInProgress ? busyIndicator.width : searchIconImage.width - readonly property real trailingControlWidth: clearTextButton.visible ? clearTextButton.width : 0 - - topPadding: topInset - bottomPadding: bottomInset - leftPadding: iconInset + leadingControlWidth + Style.smallSpacing - rightPadding: iconInset + trailingControlWidth + Style.smallSpacing + readonly property color iconColor: palette.placeholderText + readonly property int iconSize: 24 + readonly property int controlSize: Math.max(40, height - 4) + + leftPadding: 8 + controlSize + rightPadding: 8 + + (root.text.length > 0 ? controlSize : 0) + + (root.isSearchInProgress ? 8 + iconSize : 0) verticalAlignment: Qt.AlignVCenter - placeholderText: qsTr("Search files, messages, events …") - Image { - id: searchIconImage + background: Rectangle { + radius: 8 + color: Style.wizardFieldBackground + border.width: 1 + border.color: Style.wizardFieldBorder + } - anchors { - left: root.left - leftMargin: iconInset - top: root.top - topMargin: Style.extraSmallSpacing - bottom: root.bottom - bottomMargin: Style.extraSmallSpacing - } + Keys.onPressed: event => { + if (inputMethodComposing) return + if (event.key === Qt.Key_Down) moveSelection(UnifiedSearchResultsListModel.Next) + else if (event.key === Qt.Key_Up) moveSelection(UnifiedSearchResultsListModel.Previous) + else if (event.key === Qt.Key_Home) moveSelection(UnifiedSearchResultsListModel.First) + else if (event.key === Qt.Key_End) moveSelection(UnifiedSearchResultsListModel.Last) + else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) activateSelection() + else return + event.accepted = true + } - fillMode: Image.PreserveAspectFit - smooth: true - antialiasing: true - mipmap: true - source: "image://svgimage-custom-color/search.svg" + "/" + root.textFieldIconsColor - visible: !root.isSearchInProgress + Image { + objectName: "searchLeadingIcon" + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + width: root.iconSize + height: root.iconSize + sourceSize.width: width + sourceSize.height: height + source: "image://svgimage-custom-color/search.svg/" + root.iconColor + Accessible.ignored: true } NCBusyIndicator { - id: busyIndicator - - anchors { - top: root.top - topMargin: Style.extraSmallSpacing - bottom: root.bottom - bottomMargin: Style.extraSmallSpacing - left: root.left - leftMargin: iconInset - } - - width: height - color: root.textFieldIconsColor + objectName: "searchProgressIndicator" + anchors.right: clearSearchButton.visible ? clearSearchButton.left : parent.right + anchors.rightMargin: clearSearchButton.visible ? 0 : 8 + anchors.verticalCenter: parent.verticalCenter + width: root.iconSize + height: root.iconSize + color: root.iconColor visible: root.isSearchInProgress running: visible + Accessible.ignored: true } - Image { - id: clearTextButton - - anchors { - top: root.top - topMargin: Style.extraSmallSpacing - bottom: root.bottom - bottomMargin: Style.extraSmallSpacing - right: root.right - rightMargin: iconInset - } - - fillMode: Image.PreserveAspectFit - visible: root.text - source: "image://svgimage-custom-color/clear.svg" + "/" + root.textFieldIconsColor - - MouseArea { - id: clearTextButtonMouseArea - anchors.fill: parent - onClicked: root.clearText() - } + ToolButton { + id: clearSearchButton + + objectName: "clearSearchButton" + anchors.right: parent.right + anchors.rightMargin: 2 + anchors.verticalCenter: parent.verticalCenter + width: root.controlSize + height: root.controlSize + icon.source: "image://svgimage-custom-color/clear.svg/" + root.iconColor + visible: root.text.length > 0 + Accessible.name: qsTr("Clear search") + Accessible.description: qsTr("Keeps the active filters") + onClicked: root.clearText() } } diff --git a/src/gui/search/UnifiedSearchPeoplePopup.qml b/src/gui/search/UnifiedSearchPeoplePopup.qml new file mode 100644 index 0000000000000..f8ff9cfe20f0e --- /dev/null +++ b/src/gui/search/UnifiedSearchPeoplePopup.qml @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +import Style +import "qrc:/qml/src/gui/tray" +import "qrc:/qml/src/gui/wizard/qml" + +Popup { + id: root + + required property var searchModel + required property var peopleModel + required property real windowWidth + + signal personSelected() + + parent: Overlay.overlay + width: Math.min(windowWidth - Style.unifiedSearchPeoplePopupHorizontalMargin, + Style.wizardDialogMaximumWidth) + height: Style.unifiedSearchPeoplePopupHeight + x: (windowWidth - width) / 2 + y: Style.unifiedSearchPeoplePopupTopMargin + modal: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + onOpened: peopleSearch.forceActiveFocus() + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.smallSpacing + + TextField { + id: peopleSearch + + Layout.fillWidth: true + placeholderText: qsTr("Search people") + onTextEdited: root.peopleModel.searchTerm = text + } + + EnforcedPlainTextLabel { + visible: root.peopleModel.errorString.length > 0 + text: root.peopleModel.errorString + wrapMode: Text.Wrap + } + + WizardButton { + visible: root.peopleModel.errorString.length > 0 + text: qsTr("Retry") + onClicked: root.peopleModel.retry() + } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.peopleModel + + delegate: ItemDelegate { + id: personDelegate + + required property string userId + required property string displayName + required property string avatarUrl + + width: ListView.view.width + height: Style.unifiedSearchProviderHeaderHeight + text: displayName + hoverEnabled: true + Accessible.description: userId + + background: Rectangle { + color: personDelegate.hovered || personDelegate.down + ? Style.listItemHoverBackground + : "transparent" + radius: Style.mediumRoundedButtonRadius + } + + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + + contentItem: RowLayout { + Image { + Layout.preferredWidth: Style.accountAvatarSize + Layout.preferredHeight: Style.accountAvatarSize + sourceSize.width: Style.accountAvatarSize + sourceSize.height: Style.accountAvatarSize + asynchronous: true + source: personDelegate.avatarUrl.length > 0 + ? "image://tray-image-provider/" + personDelegate.avatarUrl + : "" + Accessible.ignored: true + } + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: personDelegate.displayName + elide: Text.ElideRight + } + } + + onClicked: { + root.searchModel.setPersonFilter(personDelegate.userId, + personDelegate.displayName, + personDelegate.avatarUrl) + root.close() + root.personSelected() + } + } + } + } +} diff --git a/src/gui/search/UnifiedSearchResultDelegate.qml b/src/gui/search/UnifiedSearchResultDelegate.qml new file mode 100644 index 0000000000000..af549099446a7 --- /dev/null +++ b/src/gui/search/UnifiedSearchResultDelegate.qml @@ -0,0 +1,248 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +import Style +import com.nextcloud.desktopclient +import "qrc:/qml/src/gui/tray" + +Item { + id: root + + required property var searchModel + required property string providerName + required property string providerId + required property string providerIcon + required property string resultTitle + required property string subline + required property url resourceUrlRole + required property string darkIcons + required property string lightIcons + required property bool darkIconsIsThumbnail + required property bool lightIconsIsThumbnail + required property string darkImagePlaceholder + required property string lightImagePlaceholder + required property bool isRounded + required property int resultType + required property bool isSelected + required property bool isPartialMatch + required property bool hasOverflow + required property bool isLoading + + readonly property alias loadedItem: rowContent.item + + implicitHeight: { + if (resultType === UnifiedSearchResultsListModel.ProviderHeader) { + return Style.unifiedSearchProviderHeaderHeight + } + if (resultType === UnifiedSearchResultsListModel.PartialMatchesHeader) { + return Style.unifiedSearchPartialMatchesHeaderHeight + } + if (resultType === UnifiedSearchResultsListModel.FetchMoreTrigger + || resultType === UnifiedSearchResultsListModel.RetryFetchMoreTrigger) { + return Style.unifiedSearchPagingRowHeight + } + return Style.unifiedSearchItemHeight + } + height: implicitHeight + + Loader { + id: rowContent + + anchors.fill: parent + sourceComponent: { + if (root.resultType === UnifiedSearchResultsListModel.ProviderHeader) { + return providerHeader + } + if (root.resultType === UnifiedSearchResultsListModel.PartialMatchesHeader) { + return partialHeader + } + if (root.resultType === UnifiedSearchResultsListModel.FetchMoreTrigger + || root.resultType === UnifiedSearchResultsListModel.RetryFetchMoreTrigger) { + return pagingRow + } + return resultRow + } + } + + Component { + id: providerHeader + + Button { + id: providerHeaderButton + + objectName: "providerHeaderRow" + width: root.width + height: Style.unifiedSearchProviderHeaderHeight + flat: true + text: root.hasOverflow ? qsTr("More from %1 →").arg(root.providerName) : root.providerName + font.bold: false + font.pixelSize: Style.unifiedSearchResultTitleFontSize + leftPadding: 0 + rightPadding: 0 + hoverEnabled: root.hasOverflow + activeFocusOnTab: root.hasOverflow + Accessible.role: root.hasOverflow ? Accessible.Button : Accessible.StaticText + Accessible.name: text + + HoverHandler { + enabled: root.hasOverflow + cursorShape: Qt.PointingHandCursor + } + + background: Rectangle { + color: root.hasOverflow && providerHeaderButton.hovered + ? Style.listItemHoverBackground + : "transparent" + radius: Style.mediumRoundedButtonRadius + } + + contentItem: EnforcedPlainTextLabel { + text: providerHeaderButton.text + color: Style.wizardPrimaryText + font: providerHeaderButton.font + elide: Text.ElideRight + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + } + + onPressed: { + if (root.hasOverflow) { + root.searchModel.openProviderDetail(root.providerId) + } + } + } + } + + Component { + id: partialHeader + + EnforcedPlainTextLabel { + objectName: "partialMatchesHeaderRow" + width: root.width + height: Style.unifiedSearchPartialMatchesHeaderHeight + verticalAlignment: Text.AlignVCenter + text: qsTr("Partial matches") + color: Style.wizardSecondaryText + font.bold: true + } + } + + Component { + id: resultRow + + ItemDelegate { + id: resultDelegateButton + + objectName: "searchResultRow" + width: root.width + height: Style.unifiedSearchItemHeight + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + activeFocusOnTab: false + opacity: root.isPartialMatch ? Style.unifiedSearchPartialMatchOpacity : 1.0 + hoverEnabled: true + Accessible.role: Accessible.ListItem + Accessible.name: root.resultTitle + Accessible.description: root.subline + Accessible.selected: root.isSelected + + background: Rectangle { + color: root.isSelected + ? Style.wizardSecondaryButtonPressed + : (resultDelegateButton.hovered ? Style.listItemHoverBackground : "transparent") + radius: Style.mediumRoundedButtonRadius + } + + contentItem: UnifiedSearchResultItem { + accessibilityEnabled: false + title: root.resultTitle + subline: root.subline + icons: Style.darkMode ? root.darkIcons : root.lightIcons + iconsIsThumbnail: Style.darkMode ? root.darkIconsIsThumbnail : root.lightIconsIsThumbnail + iconPlaceholder: Style.darkMode ? root.darkImagePlaceholder : root.lightImagePlaceholder + isRounded: root.isRounded && iconsIsThumbnail + } + + onClicked: root.searchModel.resultClicked(root.providerId, root.resourceUrlRole) + } + } + + Component { + id: pagingRow + + ItemDelegate { + id: pagingDelegate + + objectName: "searchPagingRow" + width: root.width + enabled: !root.isLoading + hoverEnabled: true + leftPadding: Style.unifiedSearchResultIconLeftMargin + rightPadding: Style.unifiedSearchResultTextRightMargin + topPadding: 0 + bottomPadding: 0 + text: root.isLoading + ? qsTr("Loading more results …") + : (root.resultType === UnifiedSearchResultsListModel.RetryFetchMoreTrigger + ? qsTr("Retry loading more results") : qsTr("Load more results")) + icon.source: "image://svgimage-custom-color/more.svg/" + Style.wizardPrimaryText + icon.width: Style.smallIconSize + icon.height: Style.smallIconSize + Accessible.role: Accessible.Button + Accessible.name: text + + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + + background: Rectangle { + color: pagingDelegate.hovered ? Style.listItemHoverBackground : "transparent" + radius: Style.mediumRoundedButtonRadius + } + + contentItem: RowLayout { + spacing: Style.smallSpacing + + BusyIndicator { + Layout.preferredWidth: Style.smallIconSize + Layout.preferredHeight: Style.smallIconSize + running: root.isLoading + visible: running + } + + Image { + Layout.preferredWidth: Style.smallIconSize + Layout.preferredHeight: Style.smallIconSize + sourceSize.width: Style.smallIconSize + sourceSize.height: Style.smallIconSize + source: pagingDelegate.icon.source + visible: !root.isLoading + Accessible.ignored: true + } + + EnforcedPlainTextLabel { + objectName: "searchPagingLabel" + Layout.fillWidth: true + text: pagingDelegate.text + color: Style.wizardPrimaryText + font.bold: false + font.pixelSize: Style.unifiedSearchResultTitleFontSize + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + } + + onPressed: root.resultType === UnifiedSearchResultsListModel.RetryFetchMoreTrigger + ? root.searchModel.retryLoadMore(root.providerId) + : root.searchModel.loadMore(root.providerId) + } + } +} diff --git a/src/gui/search/UnifiedSearchResultItem.qml b/src/gui/search/UnifiedSearchResultItem.qml index fae127653b466..a3ebafcf58098 100644 --- a/src/gui/search/UnifiedSearchResultItem.qml +++ b/src/gui/search/UnifiedSearchResultItem.qml @@ -15,6 +15,8 @@ import "qrc:/qml/src/gui/tray" RowLayout { id: unifiedSearchResultItemDetails + objectName: "searchResultContent" + property string title: "" property string subline: "" property string icons: "" @@ -22,6 +24,7 @@ RowLayout { property bool iconsIsThumbnail: false property bool isRounded: false + property bool accessibilityEnabled: true property int iconWidth: iconsIsThumbnail && icons !== "" ? Style.unifiedSearchResultIconWidth : Style.unifiedSearchResultSmallIconWidth property int titleFontSize: Style.unifiedSearchResultTitleFontSize @@ -32,8 +35,9 @@ RowLayout { Accessible.role: Accessible.ListItem - Accessible.name: resultTitle - Accessible.onPressAction: unifiedSearchResultMouseArea.clicked() + Accessible.name: title + Accessible.description: subline + Accessible.ignored: !accessibilityEnabled spacing: Style.trayHorizontalMargin @@ -86,16 +90,35 @@ RowLayout { } } - ListItemLineAndSubline { + ColumnLayout { id: unifiedSearchResultTextContainer - spacing: Style.standardSpacing + objectName: "searchResultTextContainer" + spacing: Style.unifiedSearchResultTextSpacing Layout.fillWidth: true Layout.rightMargin: Style.trayHorizontalMargin - lineText: unifiedSearchResultItemDetails.title.replace(/[\r\n]+/g, " ") - sublineText: unifiedSearchResultItemDetails.subline.replace(/[\r\n]+/g, " ") + EnforcedPlainTextLabel { + objectName: "searchResultTitle" + Layout.fillWidth: true + text: unifiedSearchResultItemDetails.title.replace(/[\r\n]+/g, " ") + textFormat: Text.PlainText + color: unifiedSearchResultItemDetails.titleColor + elide: Text.ElideRight + font.pixelSize: unifiedSearchResultItemDetails.titleFontSize + } + + EnforcedPlainTextLabel { + objectName: "searchResultSubline" + Layout.fillWidth: true + text: unifiedSearchResultItemDetails.subline.replace(/[\r\n]+/g, " ") + textFormat: Text.PlainText + color: unifiedSearchResultItemDetails.sublineColor + visible: text.length > 0 + elide: Text.ElideRight + font.pixelSize: unifiedSearchResultItemDetails.sublineFontSize + } } } diff --git a/src/gui/search/UnifiedSearchResultNothingFound.qml b/src/gui/search/UnifiedSearchResultNothingFound.qml index fe61aa1149127..46d9c80fdce89 100644 --- a/src/gui/search/UnifiedSearchResultNothingFound.qml +++ b/src/gui/search/UnifiedSearchResultNothingFound.qml @@ -10,42 +10,55 @@ import QtQuick.Layouts import Style import "qrc:/qml/src/gui/tray" -ColumnLayout { +Item { id: unifiedSearchResultNothingFoundContainer + objectName: "nothingFoundView" + required property string text - spacing: Style.standardSpacing - anchors.leftMargin: Style.unifiedSearchResultNothingFoundHorizontalMargin - anchors.rightMargin: Style.unifiedSearchResultNothingFoundHorizontalMargin + ColumnLayout { + id: content - Image { - id: unifiedSearchResultsNoResultsLabelIcon - source: `image://svgimage-custom-color/magnifying-glass.svg/${palette.windowText}` - sourceSize.width: Style.trayWindowHeaderHeight / 2 - sourceSize.height: Style.trayWindowHeaderHeight / 2 - Layout.alignment: Qt.AlignHCenter - } + objectName: "nothingFoundContent" + anchors.centerIn: parent + width: parent.width - 2 * Style.unifiedSearchResultNothingFoundHorizontalMargin + spacing: Style.standardSpacing - EnforcedPlainTextLabel { - id: unifiedSearchResultsNoResultsLabel - text: qsTr("No results for") - font.pixelSize: Style.unifiedSearchPlaceholderViewSublineFontPixelSize - wrapMode: Text.Wrap - Layout.fillWidth: true - Layout.preferredHeight: Style.trayWindowHeaderHeight / 2 - horizontalAlignment: Text.AlignHCenter - } + Image { + id: unifiedSearchResultsNoResultsLabelIcon + + objectName: "nothingFoundIcon" + source: `image://svgimage-custom-color/magnifying-glass.svg/${palette.windowText}` + sourceSize.width: Style.trayWindowHeaderHeight / 2 + sourceSize.height: Style.trayWindowHeaderHeight / 2 + Layout.alignment: Qt.AlignHCenter + } + + EnforcedPlainTextLabel { + id: unifiedSearchResultsNoResultsLabel + + objectName: "nothingFoundMessage" + text: qsTr("No results for") + font.pixelSize: Style.unifiedSearchPlaceholderViewSublineFontPixelSize + wrapMode: Text.Wrap + Layout.fillWidth: true + Layout.preferredHeight: Style.trayWindowHeaderHeight / 2 + horizontalAlignment: Text.AlignHCenter + } + + EnforcedPlainTextLabel { + id: unifiedSearchResultsNoResultsLabelDetails - EnforcedPlainTextLabel { - id: unifiedSearchResultsNoResultsLabelDetails - text: unifiedSearchResultNothingFoundContainer.text - font.pixelSize: Style.unifiedSearchPlaceholderViewTitleFontPixelSize - wrapMode: Text.Wrap - maximumLineCount: 2 - elide: Text.ElideRight - Layout.fillWidth: true - Layout.preferredHeight: Style.trayWindowHeaderHeight / 2 - horizontalAlignment: Text.AlignHCenter + objectName: "nothingFoundQuery" + text: unifiedSearchResultNothingFoundContainer.text + font.pixelSize: Style.unifiedSearchPlaceholderViewTitleFontPixelSize + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + Layout.fillWidth: true + Layout.preferredHeight: Style.trayWindowHeaderHeight / 2 + horizontalAlignment: Text.AlignHCenter + } } } diff --git a/src/gui/search/unifiedsearchpeoplemodel.cpp b/src/gui/search/unifiedsearchpeoplemodel.cpp new file mode 100644 index 0000000000000..4a04cc1a87d02 --- /dev/null +++ b/src/gui/search/unifiedsearchpeoplemodel.cpp @@ -0,0 +1,279 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "unifiedsearchpeoplemodel.h" + +#include "account.h" +#include "accountstate.h" +#include "networkjobs.h" + +#include +#include +#include +#include +#include + +namespace +{ +constexpr auto maximumPeopleResults = 50; + +QString avatarUrl(const OCC::AccountPtr &account, const QString &userId) +{ + const auto encodedUserId = QString::fromUtf8(QUrl::toPercentEncoding(userId)); + const auto avatarPath = QStringLiteral("index.php/avatar/%1/64").arg(encodedUserId); + return account->url().resolved(QUrl(avatarPath)).toString(); +} +} + +namespace OCC +{ +UnifiedSearchPeopleModel::UnifiedSearchPeopleModel(QObject *parent, int debounceInterval) + : QAbstractListModel(parent) +{ + _debounceTimer.setSingleShot(true); + _debounceTimer.setInterval(debounceInterval); + connect(&_debounceTimer, &QTimer::timeout, this, &UnifiedSearchPeopleModel::startSearch); +} + +UnifiedSearchPeopleModel::~UnifiedSearchPeopleModel() +{ + cancel(); +} + +QVariant UnifiedSearchPeopleModel::data(const QModelIndex &index, int role) const +{ + Q_ASSERT(checkIndex(index, CheckIndexOption::IndexIsValid)); + const auto &person = _people.at(index.row()); + switch (role) { + case UserIdRole: + return person.id; + case DisplayNameRole: + return person.displayName; + case AvatarUrlRole: + return person.avatarUrl; + } + return {}; +} + +int UnifiedSearchPeopleModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : _people.size(); +} + +QHash UnifiedSearchPeopleModel::roleNames() const +{ + static const auto roles = QHash{ + {UserIdRole, "userId"}, + {DisplayNameRole, "displayName"}, + {AvatarUrlRole, "avatarUrl"}, + }; + return roles; +} + +AccountState *UnifiedSearchPeopleModel::accountState() const +{ + return _accountState.data(); +} + +QString UnifiedSearchPeopleModel::searchTerm() const +{ + return _searchTerm; +} + +bool UnifiedSearchPeopleModel::busy() const +{ + return _busy; +} + +QString UnifiedSearchPeopleModel::errorString() const +{ + return _errorString; +} + +void UnifiedSearchPeopleModel::setAccountState(AccountState *accountState) +{ + if (_accountState == accountState) { + return; + } + cancel(); + clearPeople(); + if (_accountState) { + disconnect(_accountState, nullptr, this, nullptr); + } + _accountState = accountState; + if (_accountState) { + connect(_accountState, &AccountState::isConnectedChanged, this, [this] { + if (!_accountState->isConnected()) { + cancel(); + clearPeople(); + setErrorString(tr("People search is unavailable.")); + } else { + setErrorString({}); + _debounceTimer.start(0); + } + }); + connect(_accountState, &QObject::destroyed, this, [this] { + cancel(); + clearPeople(); + setErrorString(tr("People search is unavailable.")); + Q_EMIT accountStateChanged(); + }); + } + Q_EMIT accountStateChanged(); + if (_accountState && _accountState->isConnected()) { + setErrorString({}); + _debounceTimer.start(0); + } else { + setErrorString(tr("People search is unavailable.")); + } +} + +void UnifiedSearchPeopleModel::setSearchTerm(const QString &searchTerm) +{ + if (_searchTerm == searchTerm) { + return; + } + _searchTerm = searchTerm; + Q_EMIT searchTermChanged(); + cancel(); + clearPeople(); + setErrorString({}); + _debounceTimer.start(); +} + +void UnifiedSearchPeopleModel::retry() +{ + startSearch(); +} + +void UnifiedSearchPeopleModel::startSearch() +{ + cancel(); + if (!_accountState || !_accountState->account() || !_accountState->isConnected()) { + setErrorString(tr("People search is unavailable.")); + return; + } + const auto account = _accountState->account(); + if (_searchTerm.trimmed().isEmpty()) { + auto people = QVector{}; + const auto selfId = account->davUser(); + if (!selfId.isEmpty()) { + const auto selfName = account->prettyName().isEmpty() ? selfId : account->prettyName(); + people.push_back({selfId, selfName, avatarUrl(account, selfId)}); + } + replacePeople(std::move(people)); + setErrorString({}); + return; + } + + const auto generation = ++_generation; + const auto job = new JsonApiJob(account, QStringLiteral("ocs/v2.php/apps/files_sharing/api/v1/sharees")); + auto query = QUrlQuery{}; + query.addQueryItem(QStringLiteral("search"), _searchTerm); + query.addQueryItem(QStringLiteral("shareType"), QStringLiteral("0")); + query.addQueryItem(QStringLiteral("lookup"), QStringLiteral("false")); + query.addQueryItem(QStringLiteral("page"), QStringLiteral("1")); + query.addQueryItem(QStringLiteral("perPage"), QString::number(maximumPeopleResults)); + job->addQueryParams(query); + _job = job; + setBusy(true); + connect(job, &JsonApiJob::jsonReceived, this, [this, generation, job, account](const QJsonDocument &reply, int statusCode) { + if (generation != _generation || _job != job) { + return; + } + _job.clear(); + setBusy(false); + if (statusCode != 200) { + clearPeople(); + setErrorString(tr("Could not load people. Try again.")); + return; + } + auto people = QVector{}; + auto seen = QSet{}; + const auto ocs = reply.object().value(QStringLiteral("ocs")).toObject(); + const auto data = ocs.value(QStringLiteral("data")).toObject(); + const auto appendUsers = [&people, &seen, &account](const QJsonArray &users) { + for (const auto &value : users) { + if (people.size() >= maximumPeopleResults) { + break; + } + const auto object = value.toObject(); + const auto id = object.value(QStringLiteral("value")).toObject().value(QStringLiteral("shareWith")).toString(); + if (id.isEmpty() || seen.contains(id)) { + continue; + } + seen.insert(id); + people.push_back({id, object.value(QStringLiteral("label")).toString(id), avatarUrl(account, id)}); + } + }; + appendUsers(data.value(QStringLiteral("exact")).toObject().value(QStringLiteral("users")).toArray()); + appendUsers(data.value(QStringLiteral("users")).toArray()); + const auto selfId = account->davUser(); + const auto selfName = account->prettyName().isEmpty() ? selfId : account->prettyName(); + const auto selfMatchesSearch = + _searchTerm.isEmpty() || selfId.contains(_searchTerm, Qt::CaseInsensitive) || selfName.contains(_searchTerm, Qt::CaseInsensitive); + if (!selfId.isEmpty() && !seen.contains(selfId) && selfMatchesSearch) { + people.prepend({selfId, selfName, avatarUrl(account, selfId)}); + if (people.size() > maximumPeopleResults) { + people.removeLast(); + } + } + replacePeople(std::move(people)); + setErrorString({}); + }); + job->start(); +} + +void UnifiedSearchPeopleModel::cancel() +{ + _debounceTimer.stop(); + ++_generation; + if (_job) { + disconnect(_job, nullptr, this, nullptr); + const auto job = qobject_cast(_job.data()); + const auto reply = job ? job->reply() : nullptr; + if (reply && reply->isRunning()) { + reply->abort(); + } + _job.clear(); + } + setBusy(false); +} + +void UnifiedSearchPeopleModel::clearPeople() +{ + if (_people.isEmpty()) { + return; + } + beginResetModel(); + _people.clear(); + endResetModel(); +} + +void UnifiedSearchPeopleModel::replacePeople(QVector people) +{ + beginResetModel(); + _people = std::move(people); + endResetModel(); +} + +void UnifiedSearchPeopleModel::setBusy(bool busy) +{ + if (_busy == busy) { + return; + } + _busy = busy; + Q_EMIT busyChanged(); +} + +void UnifiedSearchPeopleModel::setErrorString(const QString &errorString) +{ + if (_errorString == errorString) { + return; + } + _errorString = errorString; + Q_EMIT errorStringChanged(); +} +} diff --git a/src/gui/search/unifiedsearchpeoplemodel.h b/src/gui/search/unifiedsearchpeoplemodel.h new file mode 100644 index 0000000000000..b81174d8f4188 --- /dev/null +++ b/src/gui/search/unifiedsearchpeoplemodel.h @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "accountstate.h" + +#include +#include +#include + +namespace OCC { + +/** @brief Account-authenticated user suggestions for Unified Search. */ +class UnifiedSearchPeopleModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(AccountState *accountState READ accountState WRITE setAccountState NOTIFY accountStateChanged) + Q_PROPERTY(QString searchTerm READ searchTerm WRITE setSearchTerm NOTIFY searchTermChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(QString errorString READ errorString NOTIFY errorStringChanged) + +public: + enum Role { UserIdRole = Qt::UserRole + 1, DisplayNameRole, AvatarUrlRole }; + + explicit UnifiedSearchPeopleModel(QObject *parent = nullptr, int debounceInterval = 300); + ~UnifiedSearchPeopleModel() override; + [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; + [[nodiscard]] int rowCount(const QModelIndex &parent = {}) const override; + [[nodiscard]] QHash roleNames() const override; + [[nodiscard]] AccountState *accountState() const; + [[nodiscard]] QString searchTerm() const; + [[nodiscard]] bool busy() const; + [[nodiscard]] QString errorString() const; + +public Q_SLOTS: + void setAccountState(AccountState *accountState); + void setSearchTerm(const QString &searchTerm); + void retry(); + +Q_SIGNALS: + void accountStateChanged(); + void searchTermChanged(); + void busyChanged(); + void errorStringChanged(); + +private: + struct Person { QString id; QString displayName; QString avatarUrl; }; + void startSearch(); + void cancel(); + void clearPeople(); + void replacePeople(QVector people); + void setBusy(bool busy); + void setErrorString(const QString &errorString); + + QPointer _accountState; + QVector _people; + QString _searchTerm; + QString _errorString; + QTimer _debounceTimer; + QPointer _job; + quint64 _generation = 0; + bool _busy = false; +}; +} diff --git a/src/gui/search/unifiedsearchresult.cpp b/src/gui/search/unifiedsearchresult.cpp index 928266fbc7328..a4fe0ace870e7 100644 --- a/src/gui/search/unifiedsearchresult.cpp +++ b/src/gui/search/unifiedsearchresult.cpp @@ -18,9 +18,21 @@ QString UnifiedSearchResult::typeAsString(UnifiedSearchResult::Type type) result = QStringLiteral("Default"); break; + case ProviderHeader: + result = QStringLiteral("ProviderHeader"); + break; + + case PartialMatchesHeader: + result = QStringLiteral("PartialMatchesHeader"); + break; + case FetchMoreTrigger: result = QStringLiteral("FetchMoreTrigger"); break; + + case RetryFetchMoreTrigger: + result = QStringLiteral("RetryFetchMoreTrigger"); + break; } return result; } diff --git a/src/gui/search/unifiedsearchresult.h b/src/gui/search/unifiedsearchresult.h index b291b8c6cb676..f7d17247214d4 100644 --- a/src/gui/search/unifiedsearchresult.h +++ b/src/gui/search/unifiedsearchresult.h @@ -21,7 +21,10 @@ struct UnifiedSearchResult { enum Type : quint8 { Default, + ProviderHeader, + PartialMatchesHeader, FetchMoreTrigger, + RetryFetchMoreTrigger, }; static QString typeAsString(UnifiedSearchResult::Type type); @@ -30,7 +33,14 @@ struct UnifiedSearchResult QString _subline; QString _providerId; QString _providerName; + QString _providerIcon; + QString _stableKey; bool _isRounded = false; + bool _isSelected = false; + bool _isSelectable = false; + bool _isPartialMatch = false; + bool _hasOverflow = false; + bool _isLoading = false; qint32 _order = std::numeric_limits::max(); QUrl _resourceUrl; QString _darkIcons; diff --git a/src/gui/search/unifiedsearchresultslistmodel.cpp b/src/gui/search/unifiedsearchresultslistmodel.cpp index 3f59109955d83..69d3f88e9fc7a 100644 --- a/src/gui/search/unifiedsearchresultslistmodel.cpp +++ b/src/gui/search/unifiedsearchresultslistmodel.cpp @@ -7,18 +7,28 @@ #include "account.h" #include "accountstate.h" -#include "guiutility.h" #include "folderman.h" +#include "guiutility.h" #include "networkjobs.h" -#include - -#include #include +#include +#include +#include +#include +#include +#include +#include + +#include +#include using namespace Qt::StringLiterals; namespace { +constexpr qsizetype aggregateResultsPerProvider = 3; +constexpr auto requestResultsPerProvider = 10; + QString imagePlaceholderUrlForProviderId(const QString &providerId, const bool darkMode) { const auto colorIconPath = darkMode ? QStringLiteral(":/client/theme/white/") : QStringLiteral(":/client/theme/black/"); @@ -64,737 +74,1500 @@ QString localIconPathFromIconPrefix(const QString &iconNameWithPrefix, const boo QString iconUrlForDefaultIconName(const QString &defaultIconName, const bool darkMode) { const QUrl urlForIcon{defaultIconName}; - if (urlForIcon.isValid() && !urlForIcon.scheme().isEmpty()) { return defaultIconName; } const auto colorIconPath = darkMode ? QStringLiteral(":/client/theme/white/") : QStringLiteral(":/client/theme/black/"); - if (defaultIconName.startsWith(QStringLiteral("icon-"))) { const auto parts = defaultIconName.split(u'-'); - if (parts.size() > 1) { - const QString blackOrWhiteIconFilePath = colorIconPath + parts[1] + QStringLiteral(".svg"); - - if (QFile::exists(blackOrWhiteIconFilePath)) { - return blackOrWhiteIconFilePath; + const auto themedIcon = colorIconPath + parts[1] + QStringLiteral(".svg"); + if (QFile::exists(themedIcon)) { + return themedIcon; } - - const QString iconFilePath = QStringLiteral(":/client/theme/") + parts[1] + QStringLiteral(".svg"); - - if (QFile::exists(iconFilePath)) { - return iconFilePath; + const auto icon = QStringLiteral(":/client/theme/") + parts[1] + QStringLiteral(".svg"); + if (QFile::exists(icon)) { + return icon; } } - - const auto iconNameFromIconPrefix = localIconPathFromIconPrefix(defaultIconName, darkMode); - - if (!iconNameFromIconPrefix.isEmpty()) { - return iconNameFromIconPrefix; - } + return localIconPathFromIconPrefix(defaultIconName, darkMode); } return colorIconPath % QStringLiteral("change.svg"); } -QString generateUrlForThumbnail(const QString &thumbnailUrl, const QUrl &serverUrl) +QString absoluteServerResource(const QString &resource, const QUrl &serverUrl) { - auto serverUrlCopy = serverUrl; - auto thumbnailUrlCopy = thumbnailUrl; - - if (thumbnailUrlCopy.startsWith(u'/') || thumbnailUrlCopy.startsWith(u'\\')) { - // relative image resource URL, just needs some concatenation with current server URL - // some icons may contain parameters after (?) - const QStringList thumbnailUrlCopySplitted = thumbnailUrlCopy.contains(u'?') - ? thumbnailUrlCopy.split(u'?', Qt::SkipEmptyParts) - : QStringList{thumbnailUrlCopy}; - Q_ASSERT(!thumbnailUrlCopySplitted.isEmpty()); - serverUrlCopy.setPath(thumbnailUrlCopySplitted[0]); - thumbnailUrlCopy = serverUrlCopy.toString(); - if (thumbnailUrlCopySplitted.size() > 1) { - thumbnailUrlCopy += u'?' + thumbnailUrlCopySplitted[1]; - } + if (resource.isEmpty()) { + return {}; } - return thumbnailUrlCopy; -} - -QString generateUrlForIcon(const QString &fallbackIcon, const QUrl &serverUrl, const bool darkMode) -{ - auto serverUrlCopy = serverUrl; - - auto fallbackIconCopy = fallbackIcon; + const auto url = QUrl(resource); + if (!url.isRelative() && !url.scheme().isEmpty()) { + return resource; + } - if (fallbackIconCopy.startsWith(u'/') || fallbackIconCopy.startsWith(u'\\')) { - // relative image resource URL, just needs some concatenation with current server URL - // some icons may contain parameters after (?) - const QStringList fallbackIconPathSplitted = - fallbackIconCopy.contains(u'?') ? fallbackIconCopy.split(u'?') : QStringList{fallbackIconCopy}; - Q_ASSERT(!fallbackIconPathSplitted.isEmpty()); - serverUrlCopy.setPath(fallbackIconPathSplitted[0]); - fallbackIconCopy = serverUrlCopy.toString(); - if (fallbackIconPathSplitted.size() > 1) { - fallbackIconCopy += u'?' + fallbackIconPathSplitted[1]; - } - } else if (!fallbackIconCopy.isEmpty()) { - // could be one of names for standard icons (e.g. icon-mail) - const auto defaultIconUrl = iconUrlForDefaultIconName(fallbackIconCopy, darkMode); - if (!defaultIconUrl.isEmpty()) { - fallbackIconCopy = defaultIconUrl; + auto absoluteUrl = serverUrl; + if (resource.startsWith(u'/') || resource.startsWith(u'\\')) { + const auto queryPosition = resource.indexOf(u'?'); + absoluteUrl.setPath(queryPosition < 0 ? resource : resource.left(queryPosition)); + if (queryPosition >= 0) { + absoluteUrl.setQuery(resource.mid(queryPosition + 1)); } + } else { + absoluteUrl = serverUrl.resolved(QUrl(resource)); } - - return fallbackIconCopy; + return absoluteUrl.toString(); } -// Return image URL and whether it is a thumbnail or not -std::pair iconsFromThumbnailAndFallbackIcon(const QString &thumbnailUrl, const QString &fallbackIcon, const QUrl &serverUrl, const bool darkMode) +QString normalizedIcon(const QString &icon, const QUrl &serverUrl, const bool darkMode) { - if (thumbnailUrl.isEmpty() && fallbackIcon.isEmpty()) { + if (icon.isEmpty()) { return {}; } - - if (serverUrl.isEmpty()) { - const QStringList listImages = {thumbnailUrl, fallbackIcon}; - return {listImages.join(u';'), false}; + if (icon.startsWith(QStringLiteral(":/"))) { + return icon; } + if (icon.startsWith(u'/') || icon.startsWith(u'\\')) { + return absoluteServerResource(icon, serverUrl); + } + return iconUrlForDefaultIconName(icon, darkMode); +} - const auto urlForThumbnail = generateUrlForThumbnail(thumbnailUrl, serverUrl); - const auto urlForFallbackIcon = generateUrlForIcon(fallbackIcon, serverUrl, darkMode); - - qDebug() << "SEARCH" << urlForThumbnail << urlForFallbackIcon; +std::pair iconsFromThumbnailAndFallbackIcon(const QString &thumbnailUrl, + const QString &fallbackIcon, + const QUrl &serverUrl, + const bool darkMode) +{ + const auto thumbnail = absoluteServerResource(thumbnailUrl, serverUrl); + const auto icon = normalizedIcon(fallbackIcon, serverUrl, darkMode); + auto icons = QStringList{}; + if (!thumbnail.isEmpty()) { + icons.push_back(thumbnail); + } + if (!icon.isEmpty()) { + icons.push_back(icon); + } + return {icons.join(u';'), !thumbnail.isEmpty()}; +} - if (urlForThumbnail.isEmpty() && !urlForFallbackIcon.isEmpty()) { - return {urlForFallbackIcon, false}; +QString navigationAppIconForResult(const OCC::AccountState *accountState, + const QString &providerId, + const QString &resultTitle, + const bool darkMode) +{ + if (!accountState || providerId != QStringLiteral("settings_apps")) { + return {}; } - if (!urlForThumbnail.isEmpty() && urlForFallbackIcon.isEmpty()) { - return {urlForThumbnail, true}; + const auto apps = accountState->appList(); + const auto appIt = std::find_if(apps.cbegin(), apps.cend(), [&resultTitle](const auto *app) { + return app && app->name().compare(resultTitle, Qt::CaseInsensitive) == 0; + }); + if (appIt == apps.cend() || (*appIt)->iconUrl().isEmpty()) { + return {}; } - const QStringList listImages{urlForThumbnail, urlForFallbackIcon}; - return {listImages.join(u';'), true}; + return (*appIt)->iconUrl().toString() + % (darkMode ? QStringLiteral("/white") : QStringLiteral("/black")); } - -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) +UnifiedSearchResultsListModel::UnifiedSearchResultsListModel(AccountState *accountState, int debounceInterval, int revealInterval, QObject *parent) : QAbstractListModel(parent) , _accountState(accountState) { + _debounceTimer.setSingleShot(true); + _debounceTimer.setInterval(debounceInterval); + _revealTimer.setSingleShot(true); + _revealTimer.setInterval(revealInterval); + + connect(&_debounceTimer, &QTimer::timeout, this, [this] { + if (_providersReady) { + startSearch(); + } else { + _pendingSearch = true; + if (!_providersLoading) { + discoverProviders(); + } + } + setWaitingForSearchTermEditEnd(false); + }); + connect(&_revealTimer, &QTimer::timeout, this, &UnifiedSearchResultsListModel::closeRevealWindow); + connect(this, &UnifiedSearchResultsListModel::isSearchInProgressChanged, this, &UnifiedSearchResultsListModel::searchStateChanged); - connect(this, &UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderIdChanged, this, &UnifiedSearchResultsListModel::searchStateChanged); + connect(this, &UnifiedSearchResultsListModel::isSearchInProgressChanged, this, &UnifiedSearchResultsListModel::showConnectedServicesActionChanged); connect(this, &UnifiedSearchResultsListModel::searchTermChanged, this, &UnifiedSearchResultsListModel::searchStateChanged); + connect(this, &UnifiedSearchResultsListModel::searchTermChanged, this, &UnifiedSearchResultsListModel::showConnectedServicesActionChanged); connect(this, &UnifiedSearchResultsListModel::errorStringChanged, this, &UnifiedSearchResultsListModel::searchStateChanged); connect(this, &UnifiedSearchResultsListModel::waitingForSearchTermEditEndChanged, this, &UnifiedSearchResultsListModel::searchStateChanged); - connect(this, &QAbstractListModel::rowsInserted, this, &UnifiedSearchResultsListModel::searchStateChanged); - connect(this, &QAbstractListModel::rowsRemoved, this, &UnifiedSearchResultsListModel::searchStateChanged); connect(this, &QAbstractListModel::modelReset, this, &UnifiedSearchResultsListModel::searchStateChanged); - connect(this, &UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderIdChanged, this, &UnifiedSearchResultsListModel::canEditSearchChanged); + connect(this, &UnifiedSearchResultsListModel::providersChanged, this, &UnifiedSearchResultsListModel::showConnectedServicesActionChanged); + connect(this, &UnifiedSearchResultsListModel::viewModeChanged, this, &UnifiedSearchResultsListModel::showConnectedServicesActionChanged); + + _lastKnownConnected = isAccountConnected(); if (_accountState) { - connect(_accountState, &AccountState::isConnectedChanged, this, &UnifiedSearchResultsListModel::canEditSearchChanged); + connect(_accountState, &AccountState::isConnectedChanged, this, [this] { + const auto connected = isAccountConnected(); + Q_EMIT canEditSearchChanged(); + if (_lastKnownConnected && !connected) { + const auto wasInProgress = isSearchInProgress(); + ++_queryGeneration; + abortSearchJobs(); + abortProviderDiscovery(); + _debounceTimer.stop(); + setWaitingForSearchTermEditEnd(false); + resetProviderRuntime(); + setProvidersReady(false); + rebuildProjection(); + setErrorString(tr("Search is unavailable while this account is offline.")); + updateProgressSignals(wasInProgress); + } else if (!_lastKnownConnected && connected) { + setErrorString({}); + _pendingSearch = hasSearchTerm(); + discoverProviders(); + } + _lastKnownConnected = connected; + }); + } + + if (isAccountConnected()) { + QTimer::singleShot(0, this, &UnifiedSearchResultsListModel::discoverProviders); + } else { + setErrorString(tr("Search is unavailable while this account is offline.")); } } +UnifiedSearchResultsListModel::~UnifiedSearchResultsListModel() +{ + abortSearchJobs(); + abortProviderDiscovery(); +} + QVariant UnifiedSearchResultsListModel::data(const QModelIndex &index, int role) const { Q_ASSERT(checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)); - + const auto &result = _results.at(index.row()); switch (role) { case ProviderNameRole: - return _results.at(index.row())._providerName; + return result._providerName; case ProviderIdRole: - return _results.at(index.row())._providerId; + return result._providerId; + case ProviderIconRole: + return result._providerIcon; case DarkImagePlaceholderRole: - return imagePlaceholderUrlForProviderId(_results.at(index.row())._providerId, true); + return imagePlaceholderUrlForProviderId(result._providerId, true); case LightImagePlaceholderRole: - return imagePlaceholderUrlForProviderId(_results.at(index.row())._providerId, false); + return imagePlaceholderUrlForProviderId(result._providerId, false); case DarkIconsRole: - return _results.at(index.row())._darkIcons; + return result._darkIcons; case LightIconsRole: - return _results.at(index.row())._lightIcons; + return result._lightIcons; case DarkIconsIsThumbnailRole: - return _results.at(index.row())._darkIconsIsThumbnail; + return result._darkIconsIsThumbnail; case LightIconsIsThumbnailRole: - return _results.at(index.row())._lightIconsIsThumbnail; + return result._lightIconsIsThumbnail; case TitleRole: - return _results.at(index.row())._title; + return result._title; case SublineRole: - return _results.at(index.row())._subline; + return result._subline; case ResourceUrlRole: - return _results.at(index.row())._resourceUrl; + return result._resourceUrl; case RoundedRole: - return _results.at(index.row())._isRounded; + return result._isRounded; case TypeRole: - return _results.at(index.row())._type; + return static_cast(result._type); case TypeAsStringRole: - return UnifiedSearchResult::typeAsString(_results.at(index.row())._type); + return UnifiedSearchResult::typeAsString(result._type); + case StableKeyRole: + return result._stableKey; + case SelectedRole: + return result._isSelected; + case SelectableRole: + return result._isSelectable; + case PartialMatchRole: + return result._isPartialMatch; + case HasOverflowRole: + return result._hasOverflow; + case LoadingRole: + return result._isLoading; } - return {}; } int UnifiedSearchResultsListModel::rowCount(const QModelIndex &parent) const { - if (parent.isValid()) { - return 0; - } - - return _results.size(); + return parent.isValid() ? 0 : _results.size(); } QHash UnifiedSearchResultsListModel::roleNames() const { - auto roles = QAbstractListModel::roleNames(); - roles[ProviderNameRole] = "providerName"; - roles[ProviderIdRole] = "providerId"; - roles[DarkIconsRole] = "darkIcons"; - roles[LightIconsRole] = "lightIcons"; - roles[DarkIconsIsThumbnailRole] = "darkIconsIsThumbnail"; - roles[LightIconsIsThumbnailRole] = "lightIconsIsThumbnail"; - roles[DarkImagePlaceholderRole] = "darkImagePlaceholder"; - roles[LightImagePlaceholderRole] = "lightImagePlaceholder"; - roles[TitleRole] = "resultTitle"; - roles[SublineRole] = "subline"; - roles[ResourceUrlRole] = "resourceUrlRole"; - roles[TypeRole] = "type"; - roles[TypeAsStringRole] = "typeAsString"; - roles[RoundedRole] = "isRounded"; + static const auto roles = [this] { + auto result = QAbstractListModel::roleNames(); + result[ProviderNameRole] = "providerName"; + result[ProviderIdRole] = "providerId"; + result[ProviderIconRole] = "providerIcon"; + result[DarkIconsRole] = "darkIcons"; + result[LightIconsRole] = "lightIcons"; + result[DarkIconsIsThumbnailRole] = "darkIconsIsThumbnail"; + result[LightIconsIsThumbnailRole] = "lightIconsIsThumbnail"; + result[DarkImagePlaceholderRole] = "darkImagePlaceholder"; + result[LightImagePlaceholderRole] = "lightImagePlaceholder"; + result[TitleRole] = "resultTitle"; + result[SublineRole] = "subline"; + result[ResourceUrlRole] = "resourceUrlRole"; + result[TypeRole] = "type"; + result[TypeAsStringRole] = "typeAsString"; + result[RoundedRole] = "isRounded"; + result[StableKeyRole] = "stableKey"; + result[SelectedRole] = "isSelected"; + result[SelectableRole] = "isSelectable"; + result[PartialMatchRole] = "isPartialMatch"; + result[HasOverflowRole] = "hasOverflow"; + result[LoadingRole] = "isLoading"; + return result; + }(); return roles; } -QString UnifiedSearchResultsListModel::searchTerm() const +bool UnifiedSearchResultsListModel::isSearchInProgress() const { - return _searchTerm; + return _inFlightSearchRequests > 0 || (_providersLoading && hasSearchTerm()); } -QString UnifiedSearchResultsListModel::errorString() const +QString UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderId() const { return _currentFetchMoreInProgressProviderId; } +QString UnifiedSearchResultsListModel::searchTerm() const { return _searchTerm; } +QString UnifiedSearchResultsListModel::errorString() const { return _errorString; } +bool UnifiedSearchResultsListModel::waitingForSearchTermEditEnd() const { return _waitingForSearchTermEditEnd; } +bool UnifiedSearchResultsListModel::isFetchMoreInProgress() const { return !_currentFetchMoreInProgressProviderId.isEmpty(); } +bool UnifiedSearchResultsListModel::hasSearchTerm() const { return !_searchTerm.trimmed().isEmpty(); } +bool UnifiedSearchResultsListModel::hasSearchError() const { return !_errorString.isEmpty(); } +bool UnifiedSearchResultsListModel::canEditSearch() const { return _accountState && _accountState->account() && isAccountConnected(); } +bool UnifiedSearchResultsListModel::isAccountConnected() const { return _accountState && _accountState->isConnected(); } + +UnifiedSearchResultsListModel::SearchState UnifiedSearchResultsListModel::searchState() const { - return _errorString; + if (!_results.isEmpty()) { + return SearchState::Results; + } + if (hasSearchError() && !isSearchInProgress()) { + return SearchState::SearchError; + } + if (!hasSearchTerm()) { + return SearchState::Placeholder; + } + if (_waitingForSearchTermEditEnd || isSearchInProgress()) { + return SearchState::Skeleton; + } + return SearchState::NothingFound; } -QString UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderId() const +QVariantList UnifiedSearchResultsListModel::providers() const { - return _currentFetchMoreInProgressProviderId; + QVariantList result; + for (const auto &providerId : _providerOrder) { + const auto providerIt = _providers.constFind(providerId); + if (providerIt == _providers.cend()) { + continue; + } + const auto &provider = providerIt.value(); + result.push_back(QVariantMap{ + {QStringLiteral("id"), provider.id}, + {QStringLiteral("name"), provider.name}, + {QStringLiteral("icon"), providerIcon(provider, false)}, + {QStringLiteral("selected"), _selectedProviderIds.contains(provider.id)}, + {QStringLiteral("external"), provider.isExternalProvider}, + }); + } + return result; } -bool UnifiedSearchResultsListModel::waitingForSearchTermEditEnd() const +QVariantList UnifiedSearchResultsListModel::activeFilters() const { - return _waitingForSearchTermEditEnd; + QVariantList result; + for (const auto &providerId : _selectedProviderIds) { + const auto providerIt = _providers.constFind(providerId); + if (providerIt == _providers.cend()) { + continue; + } + const auto &provider = providerIt.value(); + result.push_back(QVariantMap{{QStringLiteral("type"), QStringLiteral("provider")}, + {QStringLiteral("id"), providerId}, + {QStringLiteral("label"), provider.name}, + {QStringLiteral("icon"), providerIcon(provider, false)}}); + } + if (_since.isValid() && _until.isValid()) { + result.push_back(QVariantMap{{QStringLiteral("type"), QStringLiteral("date")}, + {QStringLiteral("id"), QStringLiteral("date")}, + {QStringLiteral("label"), _dateLabel}}); + } + if (!_personId.isEmpty()) { + result.push_back(QVariantMap{{QStringLiteral("type"), QStringLiteral("person")}, + {QStringLiteral("id"), _personId}, + {QStringLiteral("label"), _personName}, + {QStringLiteral("icon"), _personAvatarUrl}}); + } + return result; } +bool UnifiedSearchResultsListModel::providersReady() const { return _providersReady; } + +bool UnifiedSearchResultsListModel::dateFilterAvailable() const +{ + return std::any_of(_providers.cbegin(), _providers.cend(), [](const auto &provider) { + return provider.filters.contains(QStringLiteral("since")) && provider.filters.contains(QStringLiteral("until")); + }); +} + +bool UnifiedSearchResultsListModel::peopleFilterAvailable() const +{ + return std::any_of(_providers.cbegin(), _providers.cend(), [](const auto &provider) { + return provider.filters.contains(QStringLiteral("person")); + }); +} + +bool UnifiedSearchResultsListModel::hasExternalProviders() const +{ + return std::any_of(_providers.cbegin(), _providers.cend(), [](const auto &provider) { return provider.isExternalProvider; }); +} + +bool UnifiedSearchResultsListModel::externalProvidersEnabled() const { return _externalProvidersEnabled; } + +bool UnifiedSearchResultsListModel::showConnectedServicesAction() const +{ + return _viewMode == ViewMode::Aggregate && hasSearchTerm() && hasExternalProviders() && !isSearchInProgress(); +} + +bool UnifiedSearchResultsListModel::hasPartialFailure() const { return _hasPartialFailure; } +UnifiedSearchResultsListModel::ViewMode UnifiedSearchResultsListModel::viewMode() const { return _viewMode; } + +QString UnifiedSearchResultsListModel::detailProviderName() const +{ + const auto providerIt = _providers.constFind(_detailProviderId); + return providerIt == _providers.cend() ? QString() : providerIt->name; +} + +int UnifiedSearchResultsListModel::selectedRow() const { return _selectedRow; } +QString UnifiedSearchResultsListModel::accessibilityStatus() const { return _accessibilityStatus; } +AccountState *UnifiedSearchResultsListModel::accountState() const { return _accountState; } + void UnifiedSearchResultsListModel::setSearchTerm(const QString &term) { - if (term == _searchTerm) { + if (_searchTerm == term) { return; } - _searchTerm = term; Q_EMIT searchTermChanged(); + scheduleSearch(); +} - if (!_errorString.isEmpty()) { - _errorString.clear(); - Q_EMIT errorStringChanged(); +void UnifiedSearchResultsListModel::discoverProviders() +{ + if (!_accountState || !_accountState->account()) { + setErrorString(tr("Failed to fetch search providers.")); + return; + } + if (!isAccountConnected()) { + setErrorString(tr("Search is unavailable while this account is offline.")); + return; } - disconnectAndClearSearchJobs(); + const auto wasInProgress = isSearchInProgress(); + abortProviderDiscovery(); + _providersLoading = true; + setProvidersReady(false); + const auto generation = ++_providerGeneration; + + auto *const job = new JsonApiJob(_accountState->account(), QStringLiteral("ocs/v2.php/search/providers")); + _providerDiscoveryJob = job; + connect(job, &JsonApiJob::jsonReceived, this, [this, generation, job](const QJsonDocument &json, const int statusCode) { + providerDiscoveryFinished(json, statusCode, generation, job); + }); + connect(job, &QObject::destroyed, this, [this, job] { + if (_providerDiscoveryJob == job) { + _providerDiscoveryJob.clear(); + } + }); + job->start(); + updateProgressSignals(wasInProgress); +} - clearCurrentFetchMoreInProgressProviderId(); +void UnifiedSearchResultsListModel::providerDiscoveryFinished(const QJsonDocument &json, + int statusCode, + quint64 generation, + QObject *jobObject) +{ + if (generation != _providerGeneration || jobObject != _providerDiscoveryJob) { + return; + } + const auto wasInProgress = isSearchInProgress(); + _providerDiscoveryJob.clear(); + _providersLoading = false; + _providers.clear(); + _providerOrder.clear(); - disconnect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this, - &UnifiedSearchResultsListModel::slotSearchTermEditingFinished); + if (statusCode != 200) { + if (!_selectedProviderIds.isEmpty()) { + _selectedProviderIds.clear(); + Q_EMIT activeFiltersChanged(); + } + setErrorString(tr("Failed to fetch search providers.")); + Q_EMIT providersChanged(); + updateProgressSignals(wasInProgress); + return; + } - if (_unifiedSearchTextEditingFinishedTimer.isActive()) { - _unifiedSearchTextEditingFinishedTimer.stop(); - _waitingForSearchTermEditEnd = false; - Q_EMIT waitingForSearchTermEditEndChanged(); + const auto providerList = json.object().value("ocs"_L1).toObject().value("data"_L1).toArray(); + QVector appOrder; + QHash> providersByApp; + auto discoveryIndex = 0; + for (const auto &providerValue : providerList) { + const auto providerObject = providerValue.toObject(); + UnifiedSearchProvider provider; + provider.id = providerObject.value("id"_L1).toString(); + provider.name = providerObject.value("name"_L1).toString(); + if (provider.id.isEmpty() || provider.name.isEmpty()) { + continue; + } + provider.appId = providerObject.value("appId"_L1).toString(provider.id); + provider.icon = providerObject.value("icon"_L1).toString(); + provider.order = providerObject.contains("order"_L1) + ? providerObject.value("order"_L1).toInt(std::numeric_limits::max()) + : std::numeric_limits::max(); + provider.discoveryIndex = discoveryIndex++; + const auto externalValue = providerObject.value("isExternalProvider"_L1); + provider.isExternalProvider = externalValue.isBool() + ? externalValue.toBool() + : externalValue.toString().compare(QStringLiteral("true"), Qt::CaseInsensitive) == 0; + const auto filtersValue = providerObject.value("filters"_L1); + if (filtersValue.isUndefined() || filtersValue.isNull()) { + provider.filters.insert(QStringLiteral("term")); + } else if (filtersValue.isArray()) { + for (const auto &filter : filtersValue.toArray()) { + provider.filters.insert(filter.toString()); + } + if (provider.filters.isEmpty()) provider.filters.insert(QStringLiteral("term")); + } else { + const auto filters = filtersValue.toObject(); + for (auto it = filters.constBegin(); it != filters.constEnd(); ++it) { + provider.filters.insert(it.key()); + } + if (provider.filters.isEmpty()) provider.filters.insert(QStringLiteral("term")); + } + _providers.insert(provider.id, provider); + if (!providersByApp.contains(provider.appId)) { + appOrder.push_back(provider.appId); + } + providersByApp[provider.appId].push_back(provider.id); + } + const auto providerLess = [this](const QString &leftId, const QString &rightId) { + const auto &left = _providers[leftId]; + const auto &right = _providers[rightId]; + return std::tie(left.order, left.discoveryIndex) < std::tie(right.order, right.discoveryIndex); + }; + for (auto it = providersByApp.begin(); it != providersByApp.end(); ++it) { + std::stable_sort(it->begin(), it->end(), providerLess); + } + std::stable_sort(appOrder.begin(), appOrder.end(), [&providersByApp, &providerLess](const QString &leftApp, const QString &rightApp) { + return providerLess(providersByApp[leftApp].constFirst(), providersByApp[rightApp].constFirst()); + }); + for (const auto &appId : appOrder) { + _providerOrder.append(providersByApp.value(appId)); } - if (!_searchTerm.isEmpty()) { - _unifiedSearchTextEditingFinishedTimer.setInterval(searchTermEditingFinishedSearchStartDelay); - connect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this, - &UnifiedSearchResultsListModel::slotSearchTermEditingFinished); - _unifiedSearchTextEditingFinishedTimer.start(); - _waitingForSearchTermEditEnd = true; - Q_EMIT waitingForSearchTermEditEndChanged(); + const auto previousSelectedProviderIds = _selectedProviderIds; + _selectedProviderIds.removeIf([this](const auto &providerId) { + return !_providers.contains(providerId); + }); + if (_selectedProviderIds != previousSelectedProviderIds) { + Q_EMIT activeFiltersChanged(); } - if (!_results.isEmpty()) { - beginResetModel(); - _results.clear(); - endResetModel(); + setProvidersReady(!_providers.isEmpty()); + setErrorString(_providersReady ? QString() : tr("No search providers are available.")); + Q_EMIT providersChanged(); + updateProgressSignals(wasInProgress); + + if (_pendingSearch && hasSearchTerm() && _providersReady) { + _pendingSearch = false; + startSearch(); } } -bool UnifiedSearchResultsListModel::isSearchInProgress() const +void UnifiedSearchResultsListModel::scheduleSearch() { - return !_searchJobConnections.isEmpty(); + const auto wasInProgress = isSearchInProgress(); + ++_queryGeneration; + abortSearchJobs(); + _revealTimer.stop(); + _debounceTimer.stop(); + _pendingSearch = false; + _revealWindowClosed = false; + resetProviderRuntime(); + setErrorString({}); + if (_viewMode != ViewMode::Aggregate) { + _viewMode = ViewMode::Aggregate; + _detailProviderId.clear(); + Q_EMIT viewModeChanged(); + } + _aggregateSelectedStableKey.clear(); + rebuildProjection(); + + if (hasSearchTerm()) { + setWaitingForSearchTermEditEnd(true); + _debounceTimer.start(); + } else { + setWaitingForSearchTermEditEnd(false); + setAccessibilityStatus(tr("Search cleared")); + } + updateProgressSignals(wasInProgress); } -bool UnifiedSearchResultsListModel::isFetchMoreInProgress() const +void UnifiedSearchResultsListModel::startSearch() { - return !_currentFetchMoreInProgressProviderId.isEmpty(); + if (!_accountState || !_accountState->account() || !isAccountConnected() || !hasSearchTerm() || !_providersReady) { + return; + } + const auto wasInProgress = isSearchInProgress(); + abortSearchJobs(); + _revealOrder.clear(); + _revealWindowClosed = false; + resetProviderRuntime(); + setErrorString({}); + + auto requestCount = 0; + for (const auto &providerId : _providerOrder) { + auto &provider = _providers[providerId]; + if (!providerIsApplicable(provider)) { + continue; + } + provider.partialMatch = !providerSupportsAllContentFilters(provider); + provider.status = ProviderStatus::Loading; + ++requestCount; + } + if (requestCount == 0) { + rebuildProjection(); + updateAccessibilityStatus(); + updateProgressSignals(wasInProgress); + return; + } + + _revealTimer.start(); + for (const auto &providerId : _providerOrder) { + if (_providers[providerId].status == ProviderStatus::Loading) { + startSearchForProvider(providerId); + } + } + setAccessibilityStatus(tr("Searching")); + updateProgressSignals(wasInProgress); } -bool UnifiedSearchResultsListModel::hasSearchTerm() const +void UnifiedSearchResultsListModel::startSearchForProvider(const QString &providerId, bool pagination) { - return !_searchTerm.isEmpty(); + if (!_accountState || !_accountState->account() || !_providers.contains(providerId)) { + return; + } + auto &provider = _providers[providerId]; + const auto generation = _queryGeneration; + auto *const job = new JsonApiJob(_accountState->account(), + QStringLiteral("ocs/v2.php/search/providers/%1/search").arg(QString::fromUtf8(QUrl::toPercentEncoding(providerId)))); + job->addQueryParams(queryForProvider(provider, pagination)); + connect(job, &JsonApiJob::jsonReceived, this, + [this, providerId, pagination, generation, job](const QJsonDocument &json, const int statusCode) { + providerSearchFinished(json, statusCode, providerId, pagination, generation, job); + }); + trackSearchJob(job); + if (pagination) { + provider.paging = true; + provider.loadMoreFailed = false; + _currentFetchMoreInProgressProviderId = providerId; + Q_EMIT currentFetchMoreInProgressProviderIdChanged(); + if (!updateDetailPagingState(provider)) { + rebuildProjection(); + } + } + job->start(); } -bool UnifiedSearchResultsListModel::hasSearchError() const +void UnifiedSearchResultsListModel::providerSearchFinished(const QJsonDocument &json, + int statusCode, + const QString &providerId, + bool pagination, + quint64 generation, + QObject *jobObject) { - return !_errorString.isEmpty(); + const auto wasInProgress = isSearchInProgress(); + untrackSearchJob(jobObject); + if (generation != _queryGeneration || !_providers.contains(providerId)) { + updateProgressSignals(wasInProgress); + return; + } + + auto &provider = _providers[providerId]; + const auto previousEntryCount = provider.entries.size(); + if (pagination) { + provider.paging = false; + if (_currentFetchMoreInProgressProviderId == providerId) { + _currentFetchMoreInProgressProviderId.clear(); + Q_EMIT currentFetchMoreInProgressProviderIdChanged(); + } + } + + if (statusCode != 200) { + if (pagination) { + provider.loadMoreFailed = true; + } else { + provider.status = ProviderStatus::Failed; + } + } else { + const auto data = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject(); + auto parsedEntries = parseEntries(data.value("entries"_L1).toArray(), provider, + pagination ? provider.entries.size() : 0); + const auto pageHasMore = !parsedEntries.isEmpty() + && data.value("isPaginated"_L1).toBool(false) + && !data.value("cursor"_L1).isNull() + && !data.value("cursor"_L1).isUndefined(); + if (pagination) { + provider.entries.append(std::move(parsedEntries)); + provider.hasMore = pageHasMore; + } else { + provider.entries = std::move(parsedEntries); + provider.status = ProviderStatus::Blocked; + provider.hasMore = pageHasMore; + } + provider.cursor = data.value("cursor"_L1); + provider.loadMoreFailed = false; + } + + if (!pagination) { + promoteReadyProviders(); + updateAggregateState(); + } else if (!updateDetailProjectionAfterPagination(provider, previousEntryCount)) { + rebuildProjection(); + } + updateAccessibilityStatus(); + updateProgressSignals(wasInProgress); } -bool UnifiedSearchResultsListModel::canEditSearch() const +QVector UnifiedSearchResultsListModel::parseEntries(const QJsonArray &entries, + const UnifiedSearchProvider &provider, + int firstEntryIndex) const { - return isAccountConnected() && !isFetchMoreInProgress(); + QVector parsedEntries; + const auto account = _accountState ? _accountState->account() : AccountPtr(); + const auto accountUrl = account ? account->url() : QUrl(); + auto entryIndex = firstEntryIndex; + for (const auto &entryValue : entries) { + if (parsedEntries.size() >= requestResultsPerProvider) { + break; + } + const auto entry = entryValue.toObject(); + if (entry.isEmpty()) { + continue; + } + UnifiedSearchResult result; + result._providerId = provider.id; + result._providerName = provider.name; + result._order = provider.order; + result._isRounded = entry.value("rounded"_L1).toBool(false); + result._title = entry.value("title"_L1).toString(); + result._subline = entry.value("subline"_L1).toString(); + result._resourceUrl = openableResourceUrl(QUrl(entry.value("resourceUrl"_L1).toString()), accountUrl); + const auto entryIcon = entry.value("icon"_L1).toString(); + const auto darkNavigationAppIcon = navigationAppIconForResult(_accountState, provider.id, result._title, true); + const auto lightNavigationAppIcon = navigationAppIconForResult(_accountState, provider.id, result._title, false); + const auto darkFallbackIcon = !darkNavigationAppIcon.isEmpty() + ? darkNavigationAppIcon + : (entryIcon.isEmpty() ? providerIcon(provider, true) : entryIcon); + const auto lightFallbackIcon = !lightNavigationAppIcon.isEmpty() + ? lightNavigationAppIcon + : (entryIcon.isEmpty() ? providerIcon(provider, false) : entryIcon); + const auto darkIcons = iconsFromThumbnailAndFallbackIcon(entry.value("thumbnailUrl"_L1).toString(), darkFallbackIcon, accountUrl, true); + const auto lightIcons = iconsFromThumbnailAndFallbackIcon(entry.value("thumbnailUrl"_L1).toString(), lightFallbackIcon, accountUrl, false); + result._darkIcons = darkIcons.first; + result._lightIcons = lightIcons.first; + result._darkIconsIsThumbnail = darkIcons.second; + result._lightIconsIsThumbnail = lightIcons.second; + result._providerIcon = providerIcon(provider, false); + result._isSelectable = true; + result._stableKey = stableKeyForResult(provider.id, result, entryIndex++); + parsedEntries.push_back(result); + } + return parsedEntries; } -bool UnifiedSearchResultsListModel::isAccountConnected() const +void UnifiedSearchResultsListModel::promoteReadyProviders() { - return _accountState && _accountState->isConnected(); + auto unresolvedPredecessor = false; + auto changed = false; + for (const auto &providerId : _providerOrder) { + auto &provider = _providers[providerId]; + if (!providerIsApplicable(provider)) { + continue; + } + if (provider.status == ProviderStatus::Loading) { + unresolvedPredecessor = true; + } else if (provider.status == ProviderStatus::Blocked) { + if (_revealWindowClosed || !unresolvedPredecessor) { + provider.status = ProviderStatus::Loaded; + if (!_revealOrder.contains(providerId)) { + _revealOrder.push_back(providerId); + } + changed = true; + } else { + unresolvedPredecessor = true; + } + } + } + if (changed) { + rebuildProjection(); + } } -UnifiedSearchResultsListModel::SearchState UnifiedSearchResultsListModel::searchState() const +void UnifiedSearchResultsListModel::closeRevealWindow() { - if (rowCount() > 0) { - return SearchState::Results; + _revealWindowClosed = true; + promoteReadyProviders(); +} + +void UnifiedSearchResultsListModel::rebuildProjection() +{ + if (_selectedRow >= 0 && _selectedRow < _results.size() && _results[_selectedRow]._isSelectable) { + _selectedStableKey = _results[_selectedRow]._stableKey; } - if (hasSearchError()) { - return (!isSearchInProgress() && !isFetchMoreInProgress()) ? SearchState::SearchError : SearchState::None; + QVector projection; + if (_viewMode == ViewMode::ProviderDetail && _providers.contains(_detailProviderId)) { + const auto &provider = _providers[_detailProviderId]; + projection = provider.entries; + if (provider.loadMoreFailed || provider.hasMore) { + projection.push_back(pagingRowForProvider(provider)); + } + } else { + QSet filteredResourceUrls; + for (const auto &providerId : _revealOrder) { + const auto &provider = _providers[providerId]; + if (provider.partialMatch) { + continue; + } + for (const auto &entry : provider.entries) { + if (!entry._resourceUrl.isEmpty()) { + filteredResourceUrls.insert(entry._resourceUrl.toString()); + } + } + appendProviderProjection(provider, false, {}, projection); + } + + auto partialHeaderAdded = false; + for (const auto &providerId : _revealOrder) { + const auto &provider = _providers[providerId]; + if (!provider.partialMatch || provider.entries.isEmpty()) { + continue; + } + const auto hasVisibleEntry = std::any_of(provider.entries.cbegin(), provider.entries.cend(), + [&filteredResourceUrls](const auto &entry) { + return entry._resourceUrl.isEmpty() || !filteredResourceUrls.contains(entry._resourceUrl.toString()); + }); + if (!hasVisibleEntry) { + continue; + } + if (!partialHeaderAdded) { + UnifiedSearchResult header; + header._type = UnifiedSearchResult::Type::PartialMatchesHeader; + header._title = tr("Partial matches"); + header._stableKey = QStringLiteral("partial-matches"); + projection.push_back(header); + partialHeaderAdded = true; + } + appendProviderProjection(provider, true, filteredResourceUrls, projection); + } } - if (!hasSearchTerm()) { - return SearchState::Placeholder; + auto selectedRow = -1; + for (auto row = 0; row < projection.size(); ++row) { + if (!projection[row]._isSelectable) { + continue; + } + if (selectedRow < 0) { + selectedRow = row; + } + if (!_selectedStableKey.isEmpty() && projection[row]._stableKey == _selectedStableKey) { + selectedRow = row; + break; + } + } + for (auto row = 0; row < projection.size(); ++row) { + projection[row]._isSelected = row == selectedRow; } - if (!isSearchInProgress() && !waitingForSearchTermEditEnd()) { - return SearchState::NothingFound; + beginResetModel(); + _results = std::move(projection); + endResetModel(); + setSelectedRow(selectedRow); + if (selectedRow >= 0) { + _selectedStableKey = _results[selectedRow]._stableKey; + } else { + _selectedStableKey.clear(); } +} - return SearchState::Skeleton; +UnifiedSearchResult UnifiedSearchResultsListModel::pagingRowForProvider(const UnifiedSearchProvider &provider) const +{ + UnifiedSearchResult pagingRow; + pagingRow._providerId = provider.id; + pagingRow._providerName = provider.name; + pagingRow._type = provider.loadMoreFailed + ? UnifiedSearchResult::Type::RetryFetchMoreTrigger + : UnifiedSearchResult::Type::FetchMoreTrigger; + pagingRow._isLoading = provider.paging; + pagingRow._stableKey = QStringLiteral("paging:%1").arg(provider.id); + return pagingRow; } -void UnifiedSearchResultsListModel::resultClicked(const QString &providerId, const QUrl &resourceUrl) const +bool UnifiedSearchResultsListModel::updateDetailPagingState(const UnifiedSearchProvider &provider) { - 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 (_viewMode != ViewMode::ProviderDetail || _detailProviderId != provider.id || _results.isEmpty()) { + return false; + } + const auto row = _results.size() - 1; + auto &pagingRow = _results[row]; + if (pagingRow._providerId != provider.id + || (pagingRow._type != UnifiedSearchResult::Type::FetchMoreTrigger + && pagingRow._type != UnifiedSearchResult::Type::RetryFetchMoreTrigger)) { + return false; + } + pagingRow = pagingRowForProvider(provider); + Q_EMIT dataChanged(index(row), index(row), {TypeRole, TypeAsStringRole, LoadingRole}); + return true; +} - if (providerId.contains("file"_L1, Qt::CaseInsensitive) && !dir.isEmpty() && !fileName.isEmpty()) { - if (!_accountState || !_accountState->account()) { - return; - } +bool UnifiedSearchResultsListModel::updateDetailProjectionAfterPagination(const UnifiedSearchProvider &provider, + const int previousEntryCount) +{ + if (_viewMode != ViewMode::ProviderDetail || _detailProviderId != provider.id + || previousEntryCount < 0 || provider.entries.size() < previousEntryCount + || _results.size() != previousEntryCount + 1) { + return false; + } + const auto pagingRowIndex = _results.size() - 1; + if (_results[pagingRowIndex]._providerId != provider.id + || (_results[pagingRowIndex]._type != UnifiedSearchResult::Type::FetchMoreTrigger + && _results[pagingRowIndex]._type != UnifiedSearchResult::Type::RetryFetchMoreTrigger)) { + return false; + } + + if (provider.loadMoreFailed) { + _results[pagingRowIndex] = pagingRowForProvider(provider); + Q_EMIT dataChanged(index(pagingRowIndex), index(pagingRowIndex)); + return true; + } - const QString relativePath = dir + u'/' + fileName; - const auto localFiles = - FolderMan::instance()->findFileInLocalFolders(QFileInfo(relativePath).path(), _accountState->account()); + const auto appendedCount = provider.entries.size() - previousEntryCount; + if (appendedCount == 0) { + beginRemoveRows({}, pagingRowIndex, pagingRowIndex); + _results.removeLast(); + endRemoveRows(); + return true; + } - if (!localFiles.isEmpty()) { - qCInfo(lcUnifiedSearch) << "Opening file:" << localFiles.constFirst(); - QDesktopServices::openUrl(QUrl::fromLocalFile(localFiles.constFirst())); - return; - } + _results[pagingRowIndex] = provider.entries[previousEntryCount]; + Q_EMIT dataChanged(index(pagingRowIndex), index(pagingRowIndex)); + + QVector tail; + tail.reserve(appendedCount); + for (auto entryIndex = previousEntryCount + 1; entryIndex < provider.entries.size(); ++entryIndex) { + tail.push_back(provider.entries[entryIndex]); } - Utility::openBrowser(resourceUrl); + if (provider.hasMore) { + tail.push_back(pagingRowForProvider(provider)); + } + if (!tail.isEmpty()) { + const auto firstRow = _results.size(); + const auto lastRow = firstRow + tail.size() - 1; + beginInsertRows({}, firstRow, lastRow); + _results.append(tail); + endInsertRows(); + } + return true; } -void UnifiedSearchResultsListModel::fetchMoreTriggerClicked(const QString &providerId) +void UnifiedSearchResultsListModel::appendProviderProjection(const UnifiedSearchProvider &provider, + bool partial, + const QSet &filteredResourceUrls, + QVector &projection) const { - if (isSearchInProgress() || !_currentFetchMoreInProgressProviderId.isEmpty()) { + QVector visibleEntries; + visibleEntries.reserve(aggregateResultsPerProvider + 1); + for (const auto &entry : provider.entries) { + if (partial && !entry._resourceUrl.isEmpty() && filteredResourceUrls.contains(entry._resourceUrl.toString())) { + continue; + } + auto visibleEntry = entry; + visibleEntry._isPartialMatch = partial; + visibleEntries.push_back(visibleEntry); + if (visibleEntries.size() > aggregateResultsPerProvider) { + break; + } + } + if (visibleEntries.isEmpty()) { return; } - const auto providerInfo = _providers.value(providerId, {}); + UnifiedSearchResult header; + header._providerId = provider.id; + header._providerName = provider.name; + header._providerIcon = visibleEntries.constFirst()._providerIcon; + header._title = provider.name; + header._hasOverflow = visibleEntries.size() > aggregateResultsPerProvider || provider.hasMore; + header._isPartialMatch = partial; + header._type = UnifiedSearchResult::Type::ProviderHeader; + header._stableKey = QStringLiteral("header:%1:%2").arg(partial ? QStringLiteral("partial") : QStringLiteral("full"), provider.id); + projection.push_back(header); + + const auto visibleCount = std::min(aggregateResultsPerProvider, visibleEntries.size()); + for (auto index = 0; index < visibleCount; ++index) { + projection.push_back(visibleEntries[index]); + } +} - if (!providerInfo._id.isEmpty() && providerInfo._id == providerId && providerInfo._isPaginated) { - // Load more items - _currentFetchMoreInProgressProviderId = providerId; +void UnifiedSearchResultsListModel::resetProviderRuntime() +{ + for (auto it = _providers.begin(); it != _providers.end(); ++it) { + it->status = ProviderStatus::Idle; + it->entries.clear(); + it->cursor = {}; + it->hasMore = false; + it->loadMoreFailed = false; + it->paging = false; + it->partialMatch = false; + } + _revealOrder.clear(); + if (_hasPartialFailure) { + _hasPartialFailure = false; + Q_EMIT hasPartialFailureChanged(); + } + if (!_currentFetchMoreInProgressProviderId.isEmpty()) { + _currentFetchMoreInProgressProviderId.clear(); Q_EMIT currentFetchMoreInProgressProviderIdChanged(); - startSearchForProvider(providerId, providerInfo._cursor); } } -void UnifiedSearchResultsListModel::slotSearchTermEditingFinished() +void UnifiedSearchResultsListModel::abortSearchJobs() { - _waitingForSearchTermEditEnd = false; - Q_EMIT waitingForSearchTermEditEndChanged(); - - disconnect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this, - &UnifiedSearchResultsListModel::slotSearchTermEditingFinished); + const auto hadJobs = !_activeSearchJobs.isEmpty() || _inFlightSearchRequests > 0; + for (const auto &jobObject : std::as_const(_activeSearchJobs)) { + if (!jobObject) { + continue; + } + disconnect(jobObject, nullptr, this, nullptr); + if (const auto job = qobject_cast(jobObject.data()); job && job->reply() && job->reply()->isRunning()) { + job->reply()->abort(); + } + } + _activeSearchJobs.clear(); + _inFlightSearchRequests = 0; + if (hadJobs) { + Q_EMIT isSearchInProgressChanged(); + } +} - if (!_accountState || !_accountState->account()) { - qCCritical(lcUnifiedSearch) << QStringLiteral("Account state is invalid. Could not start search!"); +void UnifiedSearchResultsListModel::abortProviderDiscovery() +{ + if (!_providerDiscoveryJob) { + _providersLoading = false; 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(); + disconnect(_providerDiscoveryJob, nullptr, this, nullptr); + if (const auto job = qobject_cast(_providerDiscoveryJob.data()); job && job->reply() && job->reply()->isRunning()) { + job->reply()->abort(); } + _providerDiscoveryJob.clear(); + _providersLoading = false; } -void UnifiedSearchResultsListModel::slotFetchProvidersFinished(const QJsonDocument &json, int statusCode) +void UnifiedSearchResultsListModel::trackSearchJob(QObject *jobObject) { - const auto job = qobject_cast(sender()); + const auto wasInProgress = isSearchInProgress(); + _activeSearchJobs.push_back(jobObject); + ++_inFlightSearchRequests; + connect(jobObject, &QObject::destroyed, this, [this, jobObject] { untrackSearchJob(jobObject); }); + updateProgressSignals(wasInProgress); +} - if (!job) { - qCCritical(lcUnifiedSearch) << QStringLiteral("Failed to fetch providers.").arg(_searchTerm); - _errorString += tr("Failed to fetch providers.") + u'\n'; - Q_EMIT errorStringChanged(); +void UnifiedSearchResultsListModel::untrackSearchJob(QObject *jobObject) +{ + const auto it = std::find_if(_activeSearchJobs.begin(), _activeSearchJobs.end(), [jobObject](const auto &candidate) { + return candidate == jobObject; + }); + if (it == _activeSearchJobs.end()) { return; } + _activeSearchJobs.erase(it); + _inFlightSearchRequests = std::max(0, _inFlightSearchRequests - 1); +} - if (statusCode != 200) { - qCCritical(lcUnifiedSearch) << QStringLiteral("%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()) - + u'\n'; - Q_EMIT errorStringChanged(); - return; +void UnifiedSearchResultsListModel::updateProgressSignals(bool wasInProgress) +{ + if (wasInProgress != isSearchInProgress()) { + Q_EMIT isSearchInProgressChanged(); } - const auto providerList = - json.object().value("ocs"_L1).toObject().value("data"_L1).toVariant().toList(); +} - for (const auto &provider : providerList) { - const auto providerMap = provider.toMap(); - const auto id = providerMap["id"_L1].toString(); - const auto name = providerMap["name"_L1].toString(); - if (!name.isEmpty() && id != "talk-message-current"_L1) { - UnifiedSearchProvider newProvider; - newProvider._name = name; - newProvider._id = id; - newProvider._order = providerMap["order"_L1].toInt(); - _providers.insert(newProvider._id, newProvider); +void UnifiedSearchResultsListModel::updateAggregateState() +{ + auto applicableCount = 0; + auto failedCount = 0; + auto settledCount = 0; + for (const auto &providerId : _providerOrder) { + const auto &provider = _providers[providerId]; + if (!providerIsApplicable(provider)) { + continue; + } + ++applicableCount; + if (provider.status == ProviderStatus::Failed) { + ++failedCount; + ++settledCount; + } else if (provider.status == ProviderStatus::Loaded || provider.status == ProviderStatus::Blocked) { + ++settledCount; } } - if (!_providers.empty()) { - startSearch(); + const auto partialFailure = failedCount > 0 && failedCount < applicableCount; + if (_hasPartialFailure != partialFailure) { + _hasPartialFailure = partialFailure; + Q_EMIT hasPartialFailureChanged(); + } + if (applicableCount > 0 && failedCount == applicableCount) { + setErrorString(tr("Search failed for all available sources. Please try again.")); + } else if (settledCount == applicableCount) { + setErrorString({}); + _revealTimer.stop(); + promoteReadyProviders(); } + Q_EMIT showConnectedServicesActionChanged(); } -void UnifiedSearchResultsListModel::slotSearchForProviderFinished(const QJsonDocument &json, int statusCode) +void UnifiedSearchResultsListModel::updateAccessibilityStatus() { - Q_ASSERT(_accountState && _accountState->account()); - - const auto job = qobject_cast(sender()); - - if (!job) { - qCCritical(lcUnifiedSearch) << QStringLiteral("Search has failed for '%2'.").arg(_searchTerm); - _errorString += tr("Search has failed for '%2'.").arg(_searchTerm) + u'\n'; - Q_EMIT errorStringChanged(); - return; - } - - const auto providerId = job->property("providerId").toString(); - - if (providerId.isEmpty()) { + if (isSearchInProgress()) { + setAccessibilityStatus(tr("Searching")); return; } - - if (!_searchJobConnections.isEmpty()) { - _searchJobConnections.remove(providerId); - - if (_searchJobConnections.isEmpty()) { - Q_EMIT isSearchInProgressChanged(); + auto resultCount = 0; + for (const auto &result : _results) { + if (result._type == UnifiedSearchResult::Type::Default) { + ++resultCount; } } + if (_viewMode == ViewMode::ProviderDetail) { + setAccessibilityStatus(tr("%1 results in %2").arg(resultCount).arg(detailProviderName())); + } else if (resultCount == 0) { + setAccessibilityStatus(tr("No matching results")); + } else if (_hasPartialFailure) { + setAccessibilityStatus(tr("%1 results. Some sources are unavailable.").arg(resultCount)); + } else { + setAccessibilityStatus(tr("%1 results").arg(resultCount)); + } +} - if (providerId == _currentFetchMoreInProgressProviderId) { - clearCurrentFetchMoreInProgressProviderId(); +void UnifiedSearchResultsListModel::restartForFilterChange() +{ + Q_EMIT activeFiltersChanged(); + Q_EMIT providersChanged(); + if (_viewMode != ViewMode::Aggregate) { + _viewMode = ViewMode::Aggregate; + _detailProviderId.clear(); + Q_EMIT viewModeChanged(); + } + if (hasSearchTerm()) { + scheduleSearch(); } +} - if (statusCode != 200) { - qCCritical(lcUnifiedSearch) << QStringLiteral("%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()) + u'\n'; - Q_EMIT errorStringChanged(); +void UnifiedSearchResultsListModel::setErrorString(const QString &error) +{ + if (_errorString == error) { return; } + _errorString = error; + Q_EMIT errorStringChanged(); +} - const auto data = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject(); - if (!data.isEmpty()) { - parseResultsForProvider(data, providerId, job->property("appendResults").toBool()); +void UnifiedSearchResultsListModel::setProvidersReady(const bool ready) +{ + if (_providersReady == ready) { + return; } + _providersReady = ready; + Q_EMIT providersReadyChanged(); } -void UnifiedSearchResultsListModel::startSearch() +void UnifiedSearchResultsListModel::setWaitingForSearchTermEditEnd(bool waiting) { - Q_ASSERT(_accountState && _accountState->account()); - - disconnectAndClearSearchJobs(); + if (_waitingForSearchTermEditEnd == waiting) { + return; + } + const auto wasInProgress = isSearchInProgress(); + _waitingForSearchTermEditEnd = waiting; + Q_EMIT waitingForSearchTermEditEndChanged(); + updateProgressSignals(wasInProgress); +} - if (!_accountState || !_accountState->account()) { +void UnifiedSearchResultsListModel::setSelectedRow(int row) +{ + if (_selectedRow == row) { return; } + _selectedRow = row; + Q_EMIT selectedRowChanged(); +} - if (!_results.isEmpty()) { - beginResetModel(); - _results.clear(); - endResetModel(); +void UnifiedSearchResultsListModel::setAccessibilityStatus(const QString &status) +{ + if (_accessibilityStatus == status) { + return; } + _accessibilityStatus = status; + Q_EMIT accessibilityStatusChanged(); +} - for (const auto &provider : std::as_const(_providers)) { - startSearchForProvider(provider._id); +bool UnifiedSearchResultsListModel::providerIsApplicable(const UnifiedSearchProvider &provider) const +{ + if (!_selectedProviderIds.isEmpty()) { + return _selectedProviderIds.contains(provider.id); } + return !provider.isExternalProvider || _externalProvidersEnabled; } -void UnifiedSearchResultsListModel::startSearchForProvider(const QString &providerId, qint32 cursor) +bool UnifiedSearchResultsListModel::providerSupportsAllContentFilters(const UnifiedSearchProvider &provider) const { - Q_ASSERT(_accountState && _accountState->account()); + const auto supportsDate = !_since.isValid() || (provider.filters.contains(QStringLiteral("since")) && provider.filters.contains(QStringLiteral("until"))); + const auto supportsPerson = _personId.isEmpty() || provider.filters.contains(QStringLiteral("person")); + return supportsDate && supportsPerson; +} - if (!_accountState || !_accountState->account()) { - return; +QString UnifiedSearchResultsListModel::providerIcon(const UnifiedSearchProvider &provider, const bool darkMode) const +{ + if (_accountState) { + if (const auto app = _accountState->findApp(provider.appId); app && !app->iconUrl().isEmpty()) { + return app->iconUrl().toString(); + } } - auto job = new JsonApiJob(_accountState->account(), - QLatin1String("ocs/v2.php/search/providers/%1/search").arg(providerId)); + const auto account = _accountState ? _accountState->account() : AccountPtr(); + return normalizedIcon(provider.icon, account ? account->url() : QUrl(), darkMode); +} - QUrlQuery params; - params.addQueryItem(QStringLiteral("term"), _searchTerm); - if (cursor > 0) { - params.addQueryItem(QStringLiteral("cursor"), QString::number(cursor)); - job->setProperty("appendResults", true); +QUrlQuery UnifiedSearchResultsListModel::queryForProvider(const UnifiedSearchProvider &provider, bool pagination) const +{ + QUrlQuery query; + query.addQueryItem(QStringLiteral("term"), _searchTerm); + query.addQueryItem(QStringLiteral("limit"), QString::number(requestResultsPerProvider)); + if (_since.isValid() && provider.filters.contains(QStringLiteral("since"))) { + query.addQueryItem(QStringLiteral("since"), _since.toUTC().toString(Qt::ISODateWithMs)); } - 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) { - Q_EMIT isSearchInProgressChanged(); + if (_until.isValid() && provider.filters.contains(QStringLiteral("until"))) { + query.addQueryItem(QStringLiteral("until"), _until.toUTC().toString(Qt::ISODateWithMs)); } - job->start(); + if (!_personId.isEmpty() && provider.filters.contains(QStringLiteral("person"))) { + query.addQueryItem(QStringLiteral("person"), _personId); + } + if (pagination && !provider.cursor.isNull() && !provider.cursor.isUndefined()) { + query.addQueryItem(QStringLiteral("cursor"), provider.cursor.toVariant().toString()); + } + return query; } -void UnifiedSearchResultsListModel::parseResultsForProvider(const QJsonObject &data, const QString &providerId, bool fetchedMore) +QString UnifiedSearchResultsListModel::stableKeyForResult(const QString &providerId, + const UnifiedSearchResult &result, + int entryIndex) { - const auto cursor = data.value("cursor"_L1).toInt(); - const auto entries = data.value("entries"_L1).toVariant().toList(); + return QStringLiteral("result:%1:%2:%3").arg(providerId, result._resourceUrl.toString(), QString::number(entryIndex)); +} - auto &provider = _providers[providerId]; +QUrl UnifiedSearchResultsListModel::openableResourceUrl(const QUrl &resourceUrl, const QUrl &accountUrl) +{ + return resourceUrl.isRelative() ? accountUrl.resolved(resourceUrl) : resourceUrl; +} - if (provider._id.isEmpty() && fetchedMore) { - _providers.remove(providerId); - return; +void UnifiedSearchResultsListModel::resultClicked(const QString &providerId, const QUrl &resourceUrl) const +{ + const QUrlQuery urlQuery{resourceUrl}; + const auto dir = urlQuery.queryItemValue(QStringLiteral("dir"), QUrl::FullyDecoded); + const auto fileName = urlQuery.queryItemValue(QStringLiteral("scrollto"), QUrl::FullyDecoded); + if (providerId.contains("file"_L1, Qt::CaseInsensitive) && !dir.isEmpty() && !fileName.isEmpty()) { + if (!_accountState || !_accountState->account()) { + return; + } + const auto relativePath = dir + u'/' + fileName; + const auto localFiles = FolderMan::instance()->findFileInLocalFolders(QFileInfo(relativePath).path(), _accountState->account()); + if (!localFiles.isEmpty()) { + QDesktopServices::openUrl(QUrl::fromLocalFile(localFiles.constFirst())); + return; + } } + Utility::openBrowser(resourceUrl); +} - 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); - } +void UnifiedSearchResultsListModel::fetchMoreTriggerClicked(const QString &providerId) { loadMore(providerId); } +void UnifiedSearchResultsListModel::toggleProviderFilter(const QString &providerId) +{ + if (!_providers.contains(providerId)) { return; } - - provider._isPaginated = data.value("isPaginated"_L1).toBool(); - provider._cursor = cursor; - - if (provider._pageSize == -1) { - provider._pageSize = cursor; + if (_selectedProviderIds.contains(providerId)) { + _selectedProviderIds.removeAll(providerId); + } else { + _selectedProviderIds.push_back(providerId); } + restartForFilterChange(); +} - 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; +void UnifiedSearchResultsListModel::clearTypeFilters() +{ + if (_selectedProviderIds.isEmpty()) { + return; } + _selectedProviderIds.clear(); + restartForFilterChange(); +} - QVector newEntries; - - 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("rounded"_L1).toBool(); - result._title = entryMap.value("title"_L1).toString(); - result._subline = entryMap.value("subline"_L1).toString(); - - const auto resourceUrl = entryMap.value("resourceUrl"_L1).toUrl(); - const auto accountUrl = (_accountState && _accountState->account()) ? _accountState->account()->url() : QUrl(); - - result._resourceUrl = openableResourceUrl(resourceUrl, accountUrl); - const auto darkIconsData = iconsFromThumbnailAndFallbackIcon(entryMap.value("thumbnailUrl"_L1).toString(), - entryMap.value("icon"_L1).toString(), accountUrl, true); - const auto lightIconsData = iconsFromThumbnailAndFallbackIcon(entryMap.value("thumbnailUrl"_L1).toString(), - entryMap.value("icon"_L1).toString(), accountUrl, false); - result._darkIcons = darkIconsData.first; - result._lightIcons = lightIconsData.first; - result._darkIconsIsThumbnail = darkIconsData.second; - result._lightIconsIsThumbnail = lightIconsData.second; - - newEntries.push_back(result); - } - - if (fetchedMore) { - appendResultsToProvider(newEntries, provider); +void UnifiedSearchResultsListModel::setDatePreset(const QString &preset) +{ + const auto today = QDate::currentDate(); + auto first = today; + auto last = today; + if (preset == QStringLiteral("today")) { + _dateLabel = tr("Today"); + } else if (preset == QStringLiteral("7days") || preset == QStringLiteral("last7days")) { + first = today.addDays(-6); + _dateLabel = tr("Last 7 days"); + } else if (preset == QStringLiteral("30days") || preset == QStringLiteral("last30days")) { + first = today.addDays(-29); + _dateLabel = tr("Last 30 days"); + } else if (preset == QStringLiteral("thisyear")) { + first = QDate(today.year(), 1, 1); + last = QDate(today.year(), 12, 31); + _dateLabel = tr("This year"); + } else if (preset == QStringLiteral("lastyear")) { + first = QDate(today.year() - 1, 1, 1); + last = QDate(today.year() - 1, 12, 31); + _dateLabel = tr("Last year"); } else { - appendResults(newEntries, provider); + return; } + const auto zone = QTimeZone::systemTimeZone(); + _since = QDateTime(first, QTime(0, 0), zone); + _until = QDateTime(last, QTime(23, 59, 59, 999), zone); + restartForFilterChange(); } -QUrl UnifiedSearchResultsListModel::openableResourceUrl(const QUrl &resourceUrl, const QUrl &accountUrl) +bool UnifiedSearchResultsListModel::setCustomDateRange(const QString &sinceDate, const QString &untilDate) { - if (!resourceUrl.isRelative()) { - return resourceUrl; + const auto first = QDate::fromString(sinceDate, Qt::ISODate); + const auto last = QDate::fromString(untilDate, Qt::ISODate); + if (!first.isValid() || !last.isValid() || last < first) { + return false; } - - QUrl finalResourceUrl(accountUrl); - finalResourceUrl.setPath(resourceUrl.toString()); - return finalResourceUrl; + const auto zone = QTimeZone::systemTimeZone(); + _since = QDateTime(first, QTime(0, 0), zone); + _until = QDateTime(last, QTime(23, 59, 59, 999), zone); + _dateLabel = tr("%1 – %2").arg(QLocale().toString(first, QLocale::ShortFormat), QLocale().toString(last, QLocale::ShortFormat)); + restartForFilterChange(); + return true; } -void UnifiedSearchResultsListModel::appendResults(QVector results, const UnifiedSearchProvider &provider) +void UnifiedSearchResultsListModel::clearDateFilter() { - 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 (!_since.isValid() && !_until.isValid()) { + return; } + _since = {}; + _until = {}; + _dateLabel.clear(); + restartForFilterChange(); +} - - if (_results.isEmpty()) { - beginInsertRows({}, 0, results.size() - 1); - _results = results; - endInsertRows(); +void UnifiedSearchResultsListModel::setPersonFilter(const QString &userId, const QString &displayName, const QString &avatarUrl) +{ + if (userId.isEmpty()) { return; } + _personId = userId; + _personName = displayName; + _personAvatarUrl = avatarUrl; + restartForFilterChange(); +} - // insertion is done with sorting (first -> by order, then -> by name) - const auto itToInsertTo = std::find_if(std::begin(_results), std::end(_results), - [&provider](const UnifiedSearchResult ¤t) { - // insert before other results of higher order when possible - if (current._order > provider._order) { - return true; - } else { - if (current._order == provider._order) { - // insert before results of higher QString value when possible - return current._providerName > provider._name; - } +void UnifiedSearchResultsListModel::clearPersonFilter() +{ + if (_personId.isEmpty()) { + return; + } + _personId.clear(); + _personName.clear(); + _personAvatarUrl.clear(); + restartForFilterChange(); +} - return false; - } - }); +void UnifiedSearchResultsListModel::removeFilter(const QString &type, const QString &id) +{ + if (type == QStringLiteral("provider")) { + if (_selectedProviderIds.removeAll(id) > 0) { + restartForFilterChange(); + } + } else if (type == QStringLiteral("date")) { + clearDateFilter(); + } else if (type == QStringLiteral("person")) { + clearPersonFilter(); + } +} - const auto first = static_cast(std::distance(std::begin(_results), itToInsertTo)); - const auto last = first + results.size() - 1; +void UnifiedSearchResultsListModel::setExternalProvidersEnabled(bool enabled) +{ + if (_externalProvidersEnabled == enabled) { + return; + } + _externalProvidersEnabled = enabled; + Q_EMIT externalProvidersEnabledChanged(); + if (hasSearchTerm()) { + scheduleSearch(); + } +} - beginInsertRows({}, first, last); - std::copy(std::begin(results), std::end(results), std::inserter(_results, itToInsertTo)); - endInsertRows(); +void UnifiedSearchResultsListModel::openProviderDetail(const QString &providerId) +{ + const auto providerIt = _providers.constFind(providerId); + if (providerIt == _providers.cend() + || (providerIt->entries.size() <= aggregateResultsPerProvider && !providerIt->hasMore)) { + return; + } + _aggregateSelectedStableKey = _selectedStableKey; + _detailProviderId = providerId; + _viewMode = ViewMode::ProviderDetail; + _selectedStableKey.clear(); + Q_EMIT viewModeChanged(); + rebuildProjection(); + updateAccessibilityStatus(); } -void UnifiedSearchResultsListModel::appendResultsToProvider(const QVector &results, const UnifiedSearchProvider &provider) +void UnifiedSearchResultsListModel::closeProviderDetail() { - if (results.isEmpty()) { + if (_viewMode == ViewMode::Aggregate) { return; } + _viewMode = ViewMode::Aggregate; + _detailProviderId.clear(); + _selectedStableKey = _aggregateSelectedStableKey; + _aggregateSelectedStableKey.clear(); + Q_EMIT viewModeChanged(); + rebuildProjection(); + updateAccessibilityStatus(); +} - 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; - }); +void UnifiedSearchResultsListModel::loadMore(const QString &providerId) +{ + if (_viewMode != ViewMode::ProviderDetail || _detailProviderId != providerId || !_providers.contains(providerId)) { + return; + } + const auto &provider = _providers[providerId]; + if (provider.paging || (!provider.hasMore && !provider.loadMoreFailed)) { + return; + } + startSearchForProvider(providerId, true); +} - if (itLastResultForProviderReverse != std::rend(_results)) { - // #1 Insert rows - // convert reverse_iterator to iterator - const auto itLastResultForProvider = (itLastResultForProviderReverse + 1).base(); - const auto first = static_cast(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(); +void UnifiedSearchResultsListModel::retryLoadMore(const QString &providerId) { loadMore(providerId); } - // #2 Remove the FetchMoreTrigger item if there are no more results to load for this provider - if (!provider._isPaginated) { - removeFetchMoreTrigger(providerId); +void UnifiedSearchResultsListModel::retryFailedProviders() +{ + if (!hasSearchTerm()) { + return; + } + _revealWindowClosed = true; + setErrorString({}); + for (const auto &providerId : _providerOrder) { + auto &provider = _providers[providerId]; + if (provider.status == ProviderStatus::Failed && providerIsApplicable(provider)) { + provider.status = ProviderStatus::Loading; + startSearchForProvider(providerId); } } + if (_hasPartialFailure) { + _hasPartialFailure = false; + Q_EMIT hasPartialFailureChanged(); + } } -void UnifiedSearchResultsListModel::removeFetchMoreTrigger(const QString &providerId) +void UnifiedSearchResultsListModel::retry() { - 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(std::distance(std::begin(_results), itFetchMoreTriggerForProvider)); - Q_ASSERT(eraseIndex >= 0 && eraseIndex < static_cast(_results.size())); - beginRemoveRows({}, eraseIndex, eraseIndex); - _results.erase(itFetchMoreTriggerForProvider); - endRemoveRows(); - } + if (!_providersReady) { + discoverProviders(); + } else if (hasSearchTerm()) { + scheduleSearch(); } } -void UnifiedSearchResultsListModel::disconnectAndClearSearchJobs() +void UnifiedSearchResultsListModel::moveSelection(SelectionDirection direction) { - for (const auto &connection : std::as_const(_searchJobConnections)) { - if (connection) { - QObject::disconnect(connection); + QVector selectableRows; + for (auto row = 0; row < _results.size(); ++row) { + if (_results[row]._isSelectable) { + selectableRows.push_back(row); } } + if (selectableRows.isEmpty()) { + return; + } - if (!_searchJobConnections.isEmpty()) { - _searchJobConnections.clear(); - Q_EMIT isSearchInProgressChanged(); + auto newRow = selectableRows.constFirst(); + const auto currentPosition = selectableRows.indexOf(_selectedRow); + switch (direction) { + case SelectionDirection::Previous: + newRow = currentPosition <= 0 ? selectableRows.constFirst() : selectableRows[currentPosition - 1]; + break; + case SelectionDirection::Next: + newRow = currentPosition < 0 || currentPosition >= selectableRows.size() - 1 + ? selectableRows.constLast() + : selectableRows[currentPosition + 1]; + break; + case SelectionDirection::First: + newRow = selectableRows.constFirst(); + break; + case SelectionDirection::Last: + newRow = selectableRows.constLast(); + break; + } + if (newRow == _selectedRow) { + return; + } + const auto previousRow = _selectedRow; + if (previousRow >= 0 && previousRow < _results.size()) { + _results[previousRow]._isSelected = false; + } + _results[newRow]._isSelected = true; + _selectedStableKey = _results[newRow]._stableKey; + setSelectedRow(newRow); + if (previousRow >= 0) { + Q_EMIT dataChanged(index(previousRow), index(previousRow), {SelectedRole}); } + Q_EMIT dataChanged(index(newRow), index(newRow), {SelectedRole}); } -void UnifiedSearchResultsListModel::clearCurrentFetchMoreInProgressProviderId() +void UnifiedSearchResultsListModel::activateSelected() const { - if (!_currentFetchMoreInProgressProviderId.isEmpty()) { - _currentFetchMoreInProgressProviderId.clear(); - Q_EMIT currentFetchMoreInProgressProviderIdChanged(); + if (_selectedRow < 0 || _selectedRow >= _results.size()) { + return; + } + const auto &result = _results[_selectedRow]; + if (result._isSelectable) { + resultClicked(result._providerId, result._resourceUrl); } } - } diff --git a/src/gui/search/unifiedsearchresultslistmodel.h b/src/gui/search/unifiedsearchresultslistmodel.h index 2a8863f8c44f5..8dbff91f395d4 100644 --- a/src/gui/search/unifiedsearchresultslistmodel.h +++ b/src/gui/search/unifiedsearchresultslistmodel.h @@ -5,28 +5,36 @@ #pragma once +#include "accountstate.h" #include "unifiedsearchresult.h" -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include -#include +#include namespace OCC { -class AccountState; /** - * @brief The UnifiedSearchResultsListModel + * @brief Account-scoped presentation model for Nextcloud Unified Search. * @ingroup gui - * Simple list model to provide the list view with data for the Unified Search results. + * + * The model owns provider discovery, concurrent searches, stable provider + * reveal order, filters, aggregate/detail projections and keyboard selection. */ - 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 currentFetchMoreInProgressProviderId READ currentFetchMoreInProgressProviderId NOTIFY currentFetchMoreInProgressProviderIdChanged) Q_PROPERTY(QString errorString READ errorString NOTIFY errorStringChanged) Q_PROPERTY(QString searchTerm READ searchTerm WRITE setSearchTerm NOTIFY searchTermChanged) Q_PROPERTY(bool waitingForSearchTermEditEnd READ waitingForSearchTermEditEnd NOTIFY waitingForSearchTermEditEndChanged) @@ -36,18 +44,23 @@ class UnifiedSearchResultsListModel : public QAbstractListModel Q_PROPERTY(bool canEditSearch READ canEditSearch NOTIFY canEditSearchChanged) Q_PROPERTY(bool isAccountConnected READ isAccountConnected NOTIFY canEditSearchChanged) Q_PROPERTY(SearchState searchState READ searchState NOTIFY searchStateChanged) - - 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::max(); // sorting order (smaller number has bigger priority) - }; + Q_PROPERTY(QVariantList providers READ providers NOTIFY providersChanged) + Q_PROPERTY(QVariantList activeFilters READ activeFilters NOTIFY activeFiltersChanged) + Q_PROPERTY(bool providersReady READ providersReady NOTIFY providersReadyChanged) + Q_PROPERTY(bool dateFilterAvailable READ dateFilterAvailable NOTIFY providersChanged) + Q_PROPERTY(bool peopleFilterAvailable READ peopleFilterAvailable NOTIFY providersChanged) + Q_PROPERTY(bool hasExternalProviders READ hasExternalProviders NOTIFY providersChanged) + Q_PROPERTY(bool externalProvidersEnabled READ externalProvidersEnabled NOTIFY externalProvidersEnabledChanged) + Q_PROPERTY(bool showConnectedServicesAction READ showConnectedServicesAction NOTIFY showConnectedServicesActionChanged) + Q_PROPERTY(bool hasPartialFailure READ hasPartialFailure NOTIFY hasPartialFailureChanged) + Q_PROPERTY(ViewMode viewMode READ viewMode NOTIFY viewModeChanged) + Q_PROPERTY(QString detailProviderName READ detailProviderName NOTIFY viewModeChanged) + Q_PROPERTY(int selectedRow READ selectedRow NOTIFY selectedRowChanged) + Q_PROPERTY(QString accessibilityStatus READ accessibilityStatus NOTIFY accessibilityStatusChanged) + Q_PROPERTY(AccountState *accountState READ accountState CONSTANT) public: + /** @brief The top-level presentation state of the search results view. */ enum class SearchState { None, Placeholder, @@ -58,9 +71,36 @@ class UnifiedSearchResultsListModel : public QAbstractListModel }; Q_ENUM(SearchState) + /** @brief Selects between results from all providers and one provider. */ + enum class ViewMode { + Aggregate, + ProviderDetail, + }; + Q_ENUM(ViewMode) + + /** @brief Describes how keyboard navigation moves the current selection. */ + enum class SelectionDirection { + Previous, + Next, + First, + Last, + }; + Q_ENUM(SelectionDirection) + + /** @brief Identifies how a result-model row is rendered in QML. */ + enum class ResultType { + Default = static_cast(UnifiedSearchResult::Type::Default), + ProviderHeader = static_cast(UnifiedSearchResult::Type::ProviderHeader), + PartialMatchesHeader = static_cast(UnifiedSearchResult::Type::PartialMatchesHeader), + FetchMoreTrigger = static_cast(UnifiedSearchResult::Type::FetchMoreTrigger), + RetryFetchMoreTrigger = static_cast(UnifiedSearchResult::Type::RetryFetchMoreTrigger), + }; + Q_ENUM(ResultType) + enum DataRole { ProviderNameRole = Qt::UserRole + 1, ProviderIdRole, + ProviderIconRole, DarkImagePlaceholderRole, LightImagePlaceholderRole, DarkIconsRole, @@ -73,50 +113,69 @@ class UnifiedSearchResultsListModel : public QAbstractListModel RoundedRole, TypeRole, TypeAsStringRole, + StableKeyRole, + SelectedRole, + SelectableRole, + PartialMatchRole, + HasOverflowRole, + LoadingRole, }; - explicit UnifiedSearchResultsListModel(AccountState *accountState, QObject *parent = nullptr); + explicit UnifiedSearchResultsListModel(AccountState *accountState, + int debounceInterval = 300, + int revealInterval = 1000, + QObject *parent = nullptr); + ~UnifiedSearchResultsListModel() override; [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] QHash roleNames() const override; [[nodiscard]] bool isSearchInProgress() const; - [[nodiscard]] QString currentFetchMoreInProgressProviderId() const; [[nodiscard]] QString searchTerm() const; [[nodiscard]] QString errorString() const; [[nodiscard]] bool waitingForSearchTermEditEnd() const; - [[nodiscard]] bool isFetchMoreInProgress() const; [[nodiscard]] bool hasSearchTerm() const; [[nodiscard]] bool hasSearchError() const; [[nodiscard]] bool canEditSearch() const; - /** @brief Returns whether the account is connected. */ [[nodiscard]] bool isAccountConnected() const; [[nodiscard]] SearchState searchState() const; + [[nodiscard]] QVariantList providers() const; + [[nodiscard]] QVariantList activeFilters() const; + [[nodiscard]] bool providersReady() const; + [[nodiscard]] bool dateFilterAvailable() const; + [[nodiscard]] bool peopleFilterAvailable() const; + [[nodiscard]] bool hasExternalProviders() const; + [[nodiscard]] bool externalProvidersEnabled() const; + [[nodiscard]] bool showConnectedServicesAction() const; + [[nodiscard]] bool hasPartialFailure() const; + [[nodiscard]] ViewMode viewMode() const; + [[nodiscard]] QString detailProviderName() const; + [[nodiscard]] int selectedRow() const; + [[nodiscard]] QString accessibilityStatus() const; + [[nodiscard]] AccountState *accountState() const; Q_INVOKABLE void resultClicked(const QString &providerId, const QUrl &resourceUrl) const; Q_INVOKABLE void fetchMoreTriggerClicked(const QString &providerId); - - [[nodiscard]] QHash 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 results, const UnifiedSearchProvider &provider); - - // append pagination results to existing results from the initial search - void appendResultsToProvider(const QVector &results, const UnifiedSearchProvider &provider); - - void removeFetchMoreTrigger(const QString &providerId); - - void disconnectAndClearSearchJobs(); - - void clearCurrentFetchMoreInProgressProviderId(); + Q_INVOKABLE void toggleProviderFilter(const QString &providerId); + Q_INVOKABLE void clearTypeFilters(); + Q_INVOKABLE void setDatePreset(const QString &preset); + Q_INVOKABLE bool setCustomDateRange(const QString &sinceDate, const QString &untilDate); + Q_INVOKABLE void clearDateFilter(); + Q_INVOKABLE void setPersonFilter(const QString &userId, const QString &displayName, const QString &avatarUrl = {}); + Q_INVOKABLE void clearPersonFilter(); + Q_INVOKABLE void removeFilter(const QString &type, const QString &id = {}); + Q_INVOKABLE void setExternalProvidersEnabled(bool enabled); + Q_INVOKABLE void openProviderDetail(const QString &providerId); + Q_INVOKABLE void closeProviderDetail(); + Q_INVOKABLE void loadMore(const QString &providerId); + Q_INVOKABLE void retryLoadMore(const QString &providerId); + Q_INVOKABLE void retryFailedProviders(); + Q_INVOKABLE void retry(); + Q_INVOKABLE void moveSelection(SelectionDirection direction); + Q_INVOKABLE void activateSelected() const; Q_SIGNALS: void currentFetchMoreInProgressProviderIdChanged(); @@ -126,31 +185,133 @@ class UnifiedSearchResultsListModel : public QAbstractListModel void waitingForSearchTermEditEndChanged(); void canEditSearchChanged(); void searchStateChanged(); + void providersChanged(); + void providersReadyChanged(); + void activeFiltersChanged(); + void externalProvidersEnabledChanged(); + void showConnectedServicesActionChanged(); + void hasPartialFailureChanged(); + void viewModeChanged(); + void selectedRowChanged(); + void accessibilityStatusChanged(); public Q_SLOTS: void setSearchTerm(const QString &term); -private Q_SLOTS: - void slotSearchTermEditingFinished(); - void slotFetchProvidersFinished(const QJsonDocument &json, int statusCode); - void slotSearchForProviderFinished(const QJsonDocument &json, int statusCode); - private: - static QUrl openableResourceUrl(const QUrl &resourceUrl, const QUrl &accountUrl); + enum class ProviderStatus { + Idle, + Loading, + Blocked, + Loaded, + Failed, + }; + + struct UnifiedSearchProvider + { + QString id; + QString appId; + QString name; + QString icon; + QSet filters; + int order = std::numeric_limits::max(); + int discoveryIndex = -1; + bool isExternalProvider = false; + + ProviderStatus status = ProviderStatus::Idle; + QVector entries; + QJsonValue cursor; + bool hasMore = false; + bool loadMoreFailed = false; + bool paging = false; + bool partialMatch = false; + }; - QMap _providers; + void discoverProviders(); + void providerDiscoveryFinished(const QJsonDocument &json, int statusCode, quint64 generation, QObject *jobObject); + void scheduleSearch(); + void startSearch(); + void startSearchForProvider(const QString &providerId, bool pagination = false); + void providerSearchFinished(const QJsonDocument &json, + int statusCode, + const QString &providerId, + bool pagination, + quint64 generation, + QObject *jobObject); + [[nodiscard]] QVector parseEntries(const QJsonArray &entries, + const UnifiedSearchProvider &provider, + int firstEntryIndex = 0) const; + void promoteReadyProviders(); + void closeRevealWindow(); + void rebuildProjection(); + [[nodiscard]] UnifiedSearchResult pagingRowForProvider(const UnifiedSearchProvider &provider) const; + [[nodiscard]] bool updateDetailProjectionAfterPagination(const UnifiedSearchProvider &provider, int previousEntryCount); + [[nodiscard]] bool updateDetailPagingState(const UnifiedSearchProvider &provider); + void appendProviderProjection(const UnifiedSearchProvider &provider, + bool partial, + const QSet &filteredResourceUrls, + QVector &projection) const; + void resetProviderRuntime(); + void abortSearchJobs(); + void abortProviderDiscovery(); + void trackSearchJob(QObject *jobObject); + void untrackSearchJob(QObject *jobObject); + void updateProgressSignals(bool wasInProgress); + void updateAggregateState(); + void updateAccessibilityStatus(); + void restartForFilterChange(); + void setErrorString(const QString &error); + void setProvidersReady(bool ready); + void setWaitingForSearchTermEditEnd(bool waiting); + void setSelectedRow(int row); + void setAccessibilityStatus(const QString &status); + [[nodiscard]] bool providerIsApplicable(const UnifiedSearchProvider &provider) const; + [[nodiscard]] bool providerSupportsAllContentFilters(const UnifiedSearchProvider &provider) const; + [[nodiscard]] QString providerIcon(const UnifiedSearchProvider &provider, bool darkMode) const; + [[nodiscard]] QUrlQuery queryForProvider(const UnifiedSearchProvider &provider, bool pagination) const; + [[nodiscard]] static QString stableKeyForResult(const QString &providerId, + const UnifiedSearchResult &result, + int entryIndex); + [[nodiscard]] static QUrl openableResourceUrl(const QUrl &resourceUrl, const QUrl &accountUrl); + + QHash _providers; + QVector _providerOrder; + QVector _revealOrder; QVector _results; + QVector> _activeSearchJobs; + QPointer _providerDiscoveryJob; QString _searchTerm; QString _errorString; - bool _waitingForSearchTermEditEnd = false; - QString _currentFetchMoreInProgressProviderId; + QStringList _selectedProviderIds; + QDateTime _since; + QDateTime _until; + QString _dateLabel; + QString _personId; + QString _personName; + QString _personAvatarUrl; + QString _detailProviderId; + QString _selectedStableKey; + QString _aggregateSelectedStableKey; + QString _accessibilityStatus; - QMap _searchJobConnections; - - QTimer _unifiedSearchTextEditingFinishedTimer; - + bool _waitingForSearchTermEditEnd = false; + bool _providersLoading = false; + bool _providersReady = false; + bool _pendingSearch = false; + bool _revealWindowClosed = false; + bool _externalProvidersEnabled = false; + bool _hasPartialFailure = false; + bool _lastKnownConnected = false; + int _inFlightSearchRequests = 0; + int _selectedRow = -1; + quint64 _queryGeneration = 0; + quint64 _providerGeneration = 0; + ViewMode _viewMode = ViewMode::Aggregate; + + QTimer _debounceTimer; + QTimer _revealTimer; AccountState *_accountState = nullptr; }; } diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index e40778acf9153..50d4aafcfb949 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -461,7 +461,8 @@ void Systray::showSearchWindow(int userIndex) return; } - auto *const searchModel = new UnifiedSearchResultsListModel(accountState.data(), accountState.data()); + const auto searchModel = new UnifiedSearchResultsListModel(accountState.data()); + searchModel->setParent(accountState.data()); const QVariantMap initialProperties{ {"account", QVariantMap{ {"avatar", user->avatarUrl()}, diff --git a/src/gui/wizard/qml/WizardButton.qml b/src/gui/wizard/qml/WizardButton.qml index fe8de4c521ab5..f86d038dcaac5 100644 --- a/src/gui/wizard/qml/WizardButton.qml +++ b/src/gui/wizard/qml/WizardButton.qml @@ -4,8 +4,9 @@ */ import QtQuick -import QtQuick.Controls import QtQuick.Controls.Basic as BasicControls +import QtQuick.Effects +import QtQuick.Layouts import Style BasicControls.Button { @@ -14,7 +15,11 @@ BasicControls.Button { property bool primary: false property string iconSource: "" property bool iconBeforeText: false + property bool tintIcon: false + property color iconTintColor: Style.wizardPrimaryText + property real cornerRadius: Style.mediumRoundedButtonRadius property string textSuffix: "" + property string trailingIconSource: "" readonly property color primaryColor: Style.wizardPrimaryButtonBackground readonly property color primaryPressedColor: Style.wizardPrimaryButtonPressed readonly property color secondaryColor: Style.wizardSecondaryButtonBackground @@ -24,75 +29,112 @@ BasicControls.Button { readonly property color disabledBorderColor: Style.wizardDisabledButtonBorder implicitHeight: Style.wizardFooterButtonHeight - leftPadding: 18 - rightPadding: 18 - font.pixelSize: Style.pixelSize + 3 + leftPadding: Style.wizardButtonHorizontalPadding + rightPadding: Style.wizardButtonHorizontalPadding + font.pixelSize: Style.wizardButtonFontPixelSize font.weight: Font.Medium Accessible.role: Accessible.Button Accessible.name: textSuffix === "" ? text : text + " " + textSuffix - contentItem: Item { - implicitWidth: contentRow.implicitWidth - implicitHeight: contentRow.implicitHeight + contentItem: RowLayout { + spacing: Style.wizardButtonContentSpacing - Row { - id: contentRow - - anchors.centerIn: parent - spacing: 6 + Item { + visible: root.iconSource !== "" && root.iconBeforeText + Layout.preferredWidth: visible ? Style.smallIconSize : 0 + Layout.preferredHeight: Style.smallIconSize Image { - visible: root.iconSource !== "" && root.iconBeforeText - source: root.iconSource + id: leadingIconImage + + anchors.fill: parent + visible: !root.tintIcon + source: root.iconSource !== "" && root.iconBeforeText ? root.iconSource : "" sourceSize.width: Style.smallIconSize sourceSize.height: Style.smallIconSize - width: visible ? Style.smallIconSize : 0 - height: Style.smallIconSize - anchors.verticalCenter: parent.verticalCenter fillMode: Image.PreserveAspectFit + Accessible.ignored: true } - Text { - text: root.textSuffix === "" ? root.text : root.text + " " + root.textSuffix - font: root.font - color: root.enabled - ? (root.primary ? Style.wizardSelectedText : root.palette.buttonText) - : Style.wizardDisabledText - anchors.verticalCenter: parent.verticalCenter - elide: Text.ElideRight + MultiEffect { + objectName: "wizardButtonLeadingIconTint" + anchors.fill: leadingIconImage + visible: root.tintIcon + source: leadingIconImage + colorization: 1.0 + colorizationColor: root.iconTintColor + Accessible.ignored: true } + } - Image { - visible: root.iconSource !== "" && !root.iconBeforeText - source: root.iconSource - sourceSize.width: Style.smallIconSize - sourceSize.height: Style.smallIconSize - width: visible ? Style.smallIconSize : 0 - height: Style.smallIconSize - anchors.verticalCenter: parent.verticalCenter - fillMode: Image.PreserveAspectFit + Text { + objectName: "wizardButtonText" + Layout.fillWidth: true + text: root.textSuffix === "" ? root.text : root.text + " " + root.textSuffix + font: root.font + color: { + if (!root.enabled) { + return Style.wizardDisabledText + } + if (root.primary) { + return Style.wizardSelectedText + } + return root.palette.buttonText } + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + Image { + visible: root.iconSource !== "" && !root.iconBeforeText + source: root.iconSource !== "" && !root.iconBeforeText ? root.iconSource : "" + sourceSize.width: Style.smallIconSize + sourceSize.height: Style.smallIconSize + Layout.preferredWidth: visible ? Style.smallIconSize : 0 + Layout.preferredHeight: Style.smallIconSize + fillMode: Image.PreserveAspectFit + Accessible.ignored: true + } + + Image { + id: trailingIcon + + objectName: "wizardButtonTrailingIcon" + visible: root.trailingIconSource !== "" + source: root.trailingIconSource + sourceSize.width: Style.smallIconSize + sourceSize.height: Style.smallIconSize + Layout.preferredWidth: visible ? Style.smallIconSize : 0 + Layout.preferredHeight: Style.smallIconSize + fillMode: Image.PreserveAspectFit + Accessible.ignored: true } } background: Rectangle { - radius: Style.mediumRoundedButtonRadius - border.width: root.primary ? 0 : 1 + radius: root.cornerRadius + border.width: root.primary ? 0 : Style.normalBorderWidth border.color: root.enabled ? root.secondaryBorderColor : root.disabledBorderColor color: { if (!root.enabled) { return root.disabledColor } if (root.primary) { - return root.down ? root.primaryPressedColor : root.primaryColor + return root.down || hoverArea.containsMouse + ? root.primaryPressedColor + : root.primaryColor } - return root.down + return root.down || hoverArea.containsMouse ? root.secondaryPressedColor : root.secondaryColor } } MouseArea { + id: hoverArea + + objectName: "wizardButtonHoverArea" anchors.fill: parent acceptedButtons: Qt.NoButton enabled: root.enabled diff --git a/src/gui/wizard/qml/WizardChipButton.qml b/src/gui/wizard/qml/WizardChipButton.qml new file mode 100644 index 0000000000000..91ad50b30cbde --- /dev/null +++ b/src/gui/wizard/qml/WizardChipButton.qml @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import Style + +WizardButton { + implicitHeight: Style.wizardChipButtonHeight + cornerRadius: Style.veryRoundedButtonRadius +} diff --git a/src/gui/wizard/qml/WizardMenu.qml b/src/gui/wizard/qml/WizardMenu.qml new file mode 100644 index 0000000000000..8e64daf3b6ccd --- /dev/null +++ b/src/gui/wizard/qml/WizardMenu.qml @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls.Basic as BasicControls + +import Style + +BasicControls.Menu { + id: root + + required property Item anchorItem + + function toggle() { + if (opened) { + close() + } else { + popup(anchorItem, 0, anchorItem.height + Style.smallSpacing) + } + } + + width: anchorItem.width + padding: Style.extraSmallSpacing + closePolicy: BasicControls.Menu.CloseOnPressOutsideParent | BasicControls.Menu.CloseOnEscape + + background: Rectangle { + radius: Style.mediumRoundedButtonRadius + color: Style.wizardFieldBackground + border.width: Style.normalBorderWidth + border.color: Style.wizardSecondaryButtonBorder + } +} diff --git a/src/gui/wizard/qml/WizardMenuItem.qml b/src/gui/wizard/qml/WizardMenuItem.qml new file mode 100644 index 0000000000000..4ac379ccbdf3c --- /dev/null +++ b/src/gui/wizard/qml/WizardMenuItem.qml @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls.Basic as BasicControls +import QtQuick.Effects +import QtQuick.Layouts + +import Style + +BasicControls.MenuItem { + id: root + + property bool tintIcon: false + property color iconTintColor: Style.wizardPrimaryText + + hoverEnabled: true + implicitHeight: Style.standardPrimaryButtonHeight + leftPadding: Style.wizardMenuItemHorizontalPadding + rightPadding: Style.wizardMenuItemHorizontalPadding + font.pixelSize: Style.pixelSize + Style.extraSmallSpacing + + contentItem: RowLayout { + spacing: Style.smallSpacing + + Item { + Layout.preferredWidth: visible ? Style.smallIconSize : 0 + Layout.preferredHeight: Style.smallIconSize + visible: root.icon.source.toString() !== "" + + Image { + id: menuIconImage + + anchors.fill: parent + visible: !root.tintIcon + source: root.icon.source.toString() !== "" ? root.icon.source : "" + sourceSize.width: Style.smallIconSize + sourceSize.height: Style.smallIconSize + fillMode: Image.PreserveAspectFit + Accessible.ignored: true + } + + MultiEffect { + objectName: "wizardMenuItemIconTint" + anchors.fill: menuIconImage + visible: root.tintIcon + source: menuIconImage + colorization: 1.0 + colorizationColor: root.iconTintColor + Accessible.ignored: true + } + + Accessible.ignored: true + } + + Text { + Layout.fillWidth: true + text: root.text + font: root.font + color: root.enabled ? Style.wizardPrimaryText : Style.wizardDisabledText + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + + background: Rectangle { + color: root.hovered || root.highlighted || root.down + ? Style.wizardSecondaryButtonPressed + : "transparent" + radius: Style.mediumRoundedButtonRadius + } + + HoverHandler { + cursorShape: Qt.PointingHandCursor + } +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index f30eba02090e3..4a8f520031237 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -10,6 +10,7 @@ set(CMAKE_AUTOMOC TRUE) find_package(Qt${QT_VERSION_MAJOR}Core5Compat ${REQUIRED_QT_VERSION} CONFIG QUIET) find_package(Qt${QT_VERSION_MAJOR} OPTIONAL_COMPONENTS HttpServer CONFIG QUIET) +find_package(Qt${QT_VERSION_MAJOR} ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS QuickTest) add_library(testutils STATIC @@ -112,6 +113,7 @@ if(NOT APPLE) nextcloud_add_test(SystraySyncControlQt) endif() nextcloud_add_test(UnifiedSearchListmodel) +nextcloud_add_test(UnifiedSearchPeopleModel) nextcloud_add_test(ActivityListModel) nextcloud_add_test(SortedActivityListModel) nextcloud_add_test(ActivityData) @@ -119,6 +121,20 @@ add_test(NAME ActivityFileMenuQmlTest COMMAND Qt6::qmltestrunner -input "${CMAKE_CURRENT_SOURCE_DIR}/qml/activityfilemenu/testactivityfilemenu.qml" ) +add_executable(SearchQmlTest qml/search/searchqmltestrunner.cpp) +target_link_libraries(SearchQmlTest PRIVATE nextcloudCore Qt::QuickTest) +target_compile_definitions(SearchQmlTest PRIVATE + SEARCH_QML_TEST_IMPORT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/qml/search/imports" +) +set_target_properties(SearchQmlTest PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${BIN_OUTPUT_DIRECTORY} + FOLDER Tests +) +add_test(NAME SearchQmlTest + COMMAND SearchQmlTest -input "${CMAKE_CURRENT_SOURCE_DIR}/qml/search/testsearch.qml" + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/bin" +) +set_tests_properties(SearchQmlTest PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") nextcloud_add_test(TalkReply) nextcloud_add_test(LockFile) nextcloud_add_test(ShareModel) diff --git a/test/qml/search/imports/com/nextcloud/desktopclient/Theme.qml b/test/qml/search/imports/com/nextcloud/desktopclient/Theme.qml new file mode 100644 index 0000000000000..727397b64a081 --- /dev/null +++ b/test/qml/search/imports/com/nextcloud/desktopclient/Theme.qml @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +pragma Singleton + +import QtQuick + +QtObject { + readonly property bool darkMode: false + readonly property color wizardHeaderBackgroundColor: "#0082c9" + readonly property color wizardHeaderTitleColor: "#ffffff" + readonly property string stateOfflineImageSource: "" + readonly property string stateOnlineImageSource: "" +} diff --git a/test/qml/search/imports/com/nextcloud/desktopclient/UserModel.qml b/test/qml/search/imports/com/nextcloud/desktopclient/UserModel.qml new file mode 100644 index 0000000000000..751f9c58afb79 --- /dev/null +++ b/test/qml/search/imports/com/nextcloud/desktopclient/UserModel.qml @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +pragma Singleton + +import QtQml + +QtObject { + readonly property var currentUser: null +} diff --git a/test/qml/search/imports/com/nextcloud/desktopclient/qmldir b/test/qml/search/imports/com/nextcloud/desktopclient/qmldir new file mode 100644 index 0000000000000..629543ae97754 --- /dev/null +++ b/test/qml/search/imports/com/nextcloud/desktopclient/qmldir @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: GPL-2.0-or-later + +module com.nextcloud.desktopclient +singleton Theme 1.0 Theme.qml +singleton UserModel 1.0 UserModel.qml diff --git a/test/qml/search/searchqmltestrunner.cpp b/test/qml/search/searchqmltestrunner.cpp new file mode 100644 index 0000000000000..ebfe99bfb42a5 --- /dev/null +++ b/test/qml/search/searchqmltestrunner.cpp @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include +#include +#include + +#include "gui/search/unifiedsearchpeoplemodel.h" +#include "gui/search/unifiedsearchresultslistmodel.h" + +class SearchQmlTestSetup : public QObject +{ + Q_OBJECT + +public: + SearchQmlTestSetup() + { + Q_INIT_RESOURCE(resources); + Q_INIT_RESOURCE(theme); + + qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "UnifiedSearchPeopleModel"); + qmlRegisterUncreatableType("com.nextcloud.desktopclient", + 1, + 0, + "UnifiedSearchResultsListModel", + "UnifiedSearchResultsListModel"); + } + +public slots: + void qmlEngineAvailable(QQmlEngine *engine) + { + engine->addImportPath(QStringLiteral(SEARCH_QML_TEST_IMPORT_PATH)); + engine->addImportPath(QCoreApplication::applicationDirPath()); + engine->addImportPath(QCoreApplication::applicationDirPath() + QStringLiteral("/qml")); + engine->addImportPath(QStringLiteral("qrc:/qml/theme")); + } +}; + +QUICK_TEST_MAIN_WITH_SETUP(search, SearchQmlTestSetup) + +#include "searchqmltestrunner.moc" diff --git a/test/qml/search/testsearch.qml b/test/qml/search/testsearch.qml new file mode 100644 index 0000000000000..e38795f4564b7 --- /dev/null +++ b/test/qml/search/testsearch.qml @@ -0,0 +1,826 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtTest +import com.nextcloud.desktopclient +import com.nextcloud.desktopclient.search +import Style +import "qrc:/qml/src/gui/wizard/qml" + +Item { + width: 640 + height: 220 + + Component { + id: productionSearchWindow + + SearchWindow {} + } + + Component { + id: windowSearchModel + + ListModel { + property int viewMode: UnifiedSearchResultsListModel.Aggregate + property int searchState: UnifiedSearchResultsListModel.Results + property int selectedRow: 0 + property string searchTerm: "calendar" + property string accessibilityStatus: "" + property string detailProviderName: "Files" + property string errorString: "" + property var accountState: null + property var activeFilters: [] + property var providers: [] + property bool providersReady: true + property bool canEditSearch: true + property bool waitingForSearchTermEditEnd: false + property bool isSearchInProgress: false + property bool isAccountConnected: true + property bool dateFilterAvailable: false + property bool peopleFilterAvailable: false + property bool hasPartialFailure: false + property bool showConnectedServicesAction: false + property bool externalProvidersEnabled: false + property string currentFetchMoreInProgressProviderId: "" + readonly property bool isFetchMoreInProgress: currentFetchMoreInProgressProviderId.length > 0 + + function moveSelection(direction) {} + function activateSelected() {} + function closeProviderDetail() {} + function retry() {} + function retryFailedProviders() {} + function setExternalProvidersEnabled(enabled) {} + function removeFilter(type, id) {} + + Component.onCompleted: { + const row = { + providerName: "Files", + providerId: "files", + providerIcon: "", + resultTitle: "Calendar", + subline: "Documents", + resourceUrlRole: "https://cloud.example.test/f/1", + darkIcons: "", + lightIcons: "", + darkIconsIsThumbnail: false, + lightIconsIsThumbnail: false, + darkImagePlaceholder: "", + lightImagePlaceholder: "", + isRounded: false, + type: UnifiedSearchResultsListModel.Default, + isSelected: true, + isPartialMatch: false, + hasOverflow: false, + isLoading: false + } + append(row) + row.resultTitle = "Calendar 2" + row.resourceUrlRole = "https://cloud.example.test/f/2" + row.isSelected = false + append(row) + } + } + } + + QtObject { + id: fakeSearchModel + + property int openedProviderDetails: 0 + property int activatedResults: 0 + property int loadedPages: 0 + property int retriedPages: 0 + + function openProviderDetail(providerId) { + if (providerId === "files") { + ++openedProviderDetails + } + } + function resultClicked(providerId, resourceUrl) { + if (providerId === "files" && resourceUrl.toString().length > 0) { + ++activatedResults + } + } + function retryLoadMore(providerId) { + if (providerId === "files") { + ++retriedPages + } + } + function loadMore(providerId) { + if (providerId === "files") { + ++loadedPages + } + } + } + + UnifiedSearchInputContainer { + id: input + width: parent.width + height: 44 + } + + UnifiedSearchResultDelegate { + id: resultDelegate + + y: 60 + width: 500 + searchModel: fakeSearchModel + providerName: "Files" + providerId: "files" + providerIcon: "" + resultTitle: "README.md" + subline: "Documents" + resourceUrlRole: "https://cloud.example.test/f/1" + darkIcons: "" + lightIcons: "" + darkIconsIsThumbnail: false + lightIconsIsThumbnail: false + darkImagePlaceholder: "" + lightImagePlaceholder: "" + isRounded: false + resultType: UnifiedSearchResultsListModel.ProviderHeader + isSelected: false + isPartialMatch: false + hasOverflow: true + isLoading: false + } + + WizardButton { + id: wizardHoverButton + + x: 500 + y: 170 + width: 120 + text: qsTr("Hover") + } + + WizardMenuItem { + id: wizardMenuHoverItem + + x: 350 + y: 170 + width: 120 + text: qsTr("Menu hover") + icon.source: "qrc:/client/theme/black/folder.svg" + tintIcon: true + iconTintColor: Style.wizardPrimaryText + } + + SignalSpy { id: clearSpy; target: input; signalName: "clearText" } + SignalSpy { id: moveSpy; target: input; signalName: "moveSelection" } + SignalSpy { id: activateSpy; target: input; signalName: "activateSelection" } + + TestCase { + name: "SearchQmlModule" + when: windowShown + + function init() { + resultDelegate.resultType = UnifiedSearchResultsListModel.ProviderHeader + resultDelegate.isSelected = false + resultDelegate.isLoading = false + fakeSearchModel.loadedPages = 0 + fakeSearchModel.retriedPages = 0 + } + + function test_searchWindowLoadsFromPackagedModule() { + const searchWindow = productionSearchWindow.createObject(null) + verify(searchWindow !== null) + compare(searchWindow.height, Style.searchWindowHeight) + verify(searchWindow.height >= 620 + Style.unifiedSearchItemHeight) + searchWindow.destroy() + } + + function test_nothingFoundContentIsGroupedInTheCenter() { + const model = windowSearchModel.createObject(this, { + searchState: UnifiedSearchResultsListModel.NothingFound, + searchTerm: "Nextcloud" + }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const nothingFoundView = findChild(searchWindow, "nothingFoundView") + const content = findChild(searchWindow, "nothingFoundContent") + const icon = findChild(searchWindow, "nothingFoundIcon") + const message = findChild(searchWindow, "nothingFoundMessage") + const query = findChild(searchWindow, "nothingFoundQuery") + + verify(nothingFoundView !== null) + verify(content !== null) + verify(icon !== null) + verify(message !== null) + verify(query !== null) + tryVerify(() => nothingFoundView.height > 0 && content.height > 0) + compare(query.text, "Nextcloud") + verify(content.height < nothingFoundView.height / 2) + + const contentCenter = content.mapToItem(nothingFoundView, + content.width / 2, + content.height / 2) + fuzzyCompare(contentCenter.x, nothingFoundView.width / 2, 1) + fuzzyCompare(contentCenter.y, nothingFoundView.height / 2, 1) + + const iconBottom = icon.mapToItem(content, 0, icon.height).y + const messageTop = message.mapToItem(content, 0, 0).y + const messageBottom = message.mapToItem(content, 0, message.height).y + const queryTop = query.mapToItem(content, 0, 0).y + fuzzyCompare(messageTop - iconBottom, Style.standardSpacing, 1) + fuzzyCompare(queryTop - messageBottom, Style.standardSpacing, 1) + + searchWindow.destroy() + model.destroy() + } + + function test_selectionBindingSurvivesViewAndModelChanges() { + const firstModel = windowSearchModel.createObject(this, { selectedRow: 0 }) + const secondModel = windowSearchModel.createObject(this, { selectedRow: 1 }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: firstModel, + visible: true + }) + const resultsList = findChild(searchWindow, "searchResultsList") + + verify(resultsList !== null) + tryCompare(resultsList, "count", 2) + tryVerify(() => resultsList.itemAtIndex(0) !== null) + verify(resultsList.itemAtIndex(0).loadedItem !== null) + compare(resultsList.itemAtIndex(0).loadedItem.objectName, "searchResultRow") + compare(resultsList.currentIndex, 0) + + firstModel.viewMode = UnifiedSearchResultsListModel.ProviderDetail + wait(0) + searchWindow.searchModel = secondModel + tryCompare(resultsList, "currentIndex", 1) + + searchWindow.destroy() + firstModel.destroy() + secondModel.destroy() + } + + function test_partialFailureFooterIsAggregateOnly() { + const model = windowSearchModel.createObject(this, { hasPartialFailure: true }) + const searchWindow = productionSearchWindow.createObject(null, { searchModel: model }) + const footer = findChild(searchWindow, "partialFailureFooter") + + verify(footer !== null) + compare(footer.visible, true) + model.viewMode = UnifiedSearchResultsListModel.ProviderDetail + tryCompare(footer, "visible", false) + model.viewMode = UnifiedSearchResultsListModel.Aggregate + tryCompare(footer, "visible", true) + + searchWindow.destroy() + model.destroy() + } + + function test_hiddenLoadingFooterDoesNotReserveSpace() { + const model = windowSearchModel.createObject(this) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const footer = findChild(searchWindow, "searchResultsLoadingFooter") + + verify(footer !== null) + compare(footer.visible, false) + compare(footer.height, 0) + model.isSearchInProgress = true + tryVerify(() => footer.height > 0) + + searchWindow.destroy() + model.destroy() + } + + function test_fetchMoreShowsSearchProgressIndicator() { + const model = windowSearchModel.createObject(this) + const searchWindow = productionSearchWindow.createObject(null, { searchModel: model }) + const progressIndicator = findChild(searchWindow, "searchProgressIndicator") + + verify(progressIndicator !== null) + compare(progressIndicator.visible, false) + model.currentFetchMoreInProgressProviderId = "files" + tryCompare(progressIndicator, "visible", true) + compare(progressIndicator.running, true) + + searchWindow.destroy() + model.destroy() + } + + function test_detailHeaderCentersProviderAndShowsBackArrow() { + const model = windowSearchModel.createObject(this, { + viewMode: UnifiedSearchResultsListModel.ProviderDetail, + detailProviderName: "A provider name that is intentionally much too long for the available search header" + }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const header = findChild(searchWindow, "searchDetailHeader") + const title = findChild(searchWindow, "searchDetailProviderTitle") + const backButton = findChild(searchWindow, "searchDetailBackButton") + + verify(header !== null) + verify(title !== null) + verify(backButton !== null) + compare(title.font.bold, true) + verify(title.font.pixelSize > Style.unifiedSearchResultTitleFontSize) + const titleCenter = title.mapToItem(header, title.width / 2, title.height / 2) + fuzzyCompare(titleCenter.x, header.width / 2, 1) + const backButtonRight = backButton.mapToItem(header, backButton.width, 0).x + const titleLeft = title.mapToItem(header, 0, 0).x + verify(titleLeft > backButtonRight) + verify(title.implicitWidth > title.width) + verify(backButton.icon.source.toString().includes("arrow-left.svg")) + compare(backButton.icon.width, Style.smallIconSize) + compare(backButton.icon.height, Style.smallIconSize) + + searchWindow.destroy() + model.destroy() + } + + function test_filterButtonsUseWizardHoverAndTrailingCarets() { + const model = windowSearchModel.createObject(this) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const typeButton = findChild(searchWindow, "typeFilterButton") + const dateButton = findChild(searchWindow, "dateFilterButton") + const peopleButton = findChild(searchWindow, "peopleFilterButton") + + verify(typeButton !== null) + verify(dateButton !== null) + verify(peopleButton !== null) + const todayMenuItem = findChild(searchWindow, "dateTodayMenuItem") + verify(todayMenuItem !== null) + compare(todayMenuItem.hoverEnabled, true) + for (const button of [typeButton, dateButton, peopleButton]) { + verify(button.trailingIconSource.toString().includes("caret-down.svg")) + const caret = findChild(button, "wizardButtonTrailingIcon") + verify(caret !== null) + const caretPosition = caret.mapToItem(button, 0, 0) + verify(caretPosition.x > button.width / 2) + fuzzyCompare(caretPosition.y + caret.height / 2, button.height / 2, 1) + } + + searchWindow.destroy() + model.destroy() + } + + function test_filterMenusUseStandardDropdownGeometry() { + const model = windowSearchModel.createObject(this, { dateFilterAvailable: true }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const typeButton = findChild(searchWindow, "typeFilterButton") + const typeMenu = findChild(searchWindow, "typeFilterMenu") + const dateButton = findChild(searchWindow, "dateFilterButton") + const dateMenu = findChild(searchWindow, "dateFilterMenu") + + verify(typeButton !== null) + verify(typeMenu !== null) + verify(dateButton !== null) + verify(dateMenu !== null) + compare(dateMenu.width, dateButton.width) + verify(dateMenu.width > Style.standardPrimaryButtonHeight) + compare(typeMenu.background.radius, Style.mediumRoundedButtonRadius) + compare(dateMenu.background.radius, Style.mediumRoundedButtonRadius) + compare(typeMenu.background.border.width, Style.normalBorderWidth) + compare(dateMenu.background.border.width, Style.normalBorderWidth) + + mouseClick(typeButton) + tryCompare(typeMenu, "opened", true) + compare(typeMenu.y, typeButton.height + Style.smallSpacing) + mouseClick(typeButton) + tryCompare(typeMenu, "opened", false) + mouseClick(dateButton) + tryCompare(dateMenu, "opened", true) + compare(dateMenu.y, dateButton.height + Style.smallSpacing) + verify(dateMenu.height > Style.standardPrimaryButtonHeight) + mouseClick(dateButton) + tryCompare(dateMenu, "opened", false) + + searchWindow.destroy() + model.destroy() + } + + function providerEntries(count) { + const providers = [] + for (let index = 0; index < count; ++index) { + providers.push({ + id: "provider-" + index, + name: "Provider " + index, + icon: "", + selected: false + }) + } + return providers + } + + function test_typeFilterMenuShowsEightRowsWithoutScrolling() { + const model = windowSearchModel.createObject(this, { + providers: providerEntries(Style.unifiedSearchProviderMenuMaximumVisibleRows) + }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const typeButton = findChild(searchWindow, "typeFilterButton") + const typeMenu = findChild(searchWindow, "typeFilterMenu") + + verify(typeButton !== null) + verify(typeMenu !== null) + mouseClick(typeButton) + tryCompare(typeMenu, "opened", true) + compare(typeMenu.height, typeMenu.implicitHeight) + compare(typeMenu.contentItem.interactive, false) + + mouseClick(typeButton) + tryCompare(typeMenu, "opened", false) + searchWindow.destroy() + model.destroy() + } + + function test_typeFilterMenuScrollsBeyondEightRows() { + const model = windowSearchModel.createObject(this, { + providers: providerEntries(Style.unifiedSearchProviderMenuMaximumVisibleRows + 1) + }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const typeButton = findChild(searchWindow, "typeFilterButton") + const typeMenu = findChild(searchWindow, "typeFilterMenu") + const maximumHeight = Style.unifiedSearchProviderMenuMaximumVisibleRows + * Style.standardPrimaryButtonHeight + typeMenu.topPadding + typeMenu.bottomPadding + + verify(typeButton !== null) + verify(typeMenu !== null) + mouseClick(typeButton) + tryCompare(typeMenu, "opened", true) + compare(typeMenu.height, maximumHeight) + verify(typeMenu.implicitHeight > typeMenu.height) + verify(typeMenu.contentItem.contentHeight > typeMenu.contentItem.height) + compare(typeMenu.contentItem.interactive, true) + + mouseClick(typeButton) + tryCompare(typeMenu, "opened", false) + searchWindow.destroy() + model.destroy() + } + + function test_categoryFiltersAreVisibleBeforeSearchBegins() { + const model = windowSearchModel.createObject(this, { searchTerm: "" }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const filterFlow = findChild(searchWindow, "categoryFilterFlow") + const typeButton = findChild(searchWindow, "typeFilterButton") + const dateButton = findChild(searchWindow, "dateFilterButton") + const peopleButton = findChild(searchWindow, "peopleFilterButton") + + verify(filterFlow !== null) + compare(filterFlow.visible, true) + compare(typeButton.visible, true) + compare(dateButton.visible, true) + compare(peopleButton.visible, true) + + searchWindow.destroy() + model.destroy() + } + + function test_activeFilterUsesCompactPillButton() { + const model = windowSearchModel.createObject(this, { + activeFilters: [{ + type: "date", + id: "last7days", + label: "Last 7 days", + icon: "" + }] + }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const activeFilterFlow = findChild(searchWindow, "activeFilterFlow") + const dateButton = findChild(searchWindow, "dateFilterButton") + function activeFilterChip() { + for (let index = 0; index < activeFilterFlow.children.length; ++index) { + if (activeFilterFlow.children[index].objectName === "activeFilterChip") { + return activeFilterFlow.children[index] + } + } + return null + } + + verify(activeFilterFlow !== null) + tryVerify(() => activeFilterChip() !== null) + const chip = activeFilterChip() + verify(dateButton !== null) + compare(chip.implicitHeight, Style.wizardChipButtonHeight) + verify(chip.implicitHeight < dateButton.implicitHeight) + compare(chip.background.radius, Style.veryRoundedButtonRadius) + compare(chip.leftPadding, dateButton.leftPadding) + compare(chip.rightPadding, chip.leftPadding) + + searchWindow.destroy() + model.destroy() + } + + } + + TestCase { + name: "UnifiedSearchInput" + when: windowShown + + function init() { + input.text = "" + input.isSearchInProgress = false + input.forceActiveFocus() + clearSpy.clear() + moveSpy.clear() + activateSpy.clear() + } + + function test_keyboardNavigationKeepsInputFocus() { + keyClick(Qt.Key_Down) + keyClick(Qt.Key_End) + keyClick(Qt.Key_Return) + compare(moveSpy.count, 2) + compare(activateSpy.count, 1) + verify(input.activeFocus) + } + + function test_activeFocusKeepsNeutralFrame() { + verify(input.activeFocus) + compare(input.background.border.width, 1) + compare(input.background.border.color, Style.wizardFieldBorder) + } + + function test_progressIndicatorDoesNotReplaceSearchIcon() { + const searchIcon = findChild(input, "searchLeadingIcon") + const progressIndicator = findChild(input, "searchProgressIndicator") + const clearButton = findChild(input, "clearSearchButton") + + verify(searchIcon !== null) + verify(progressIndicator !== null) + verify(clearButton !== null) + verify(progressIndicator.imageSource.includes("change.svg")) + input.text = "loading" + compare(searchIcon.visible, true) + compare(progressIndicator.visible, false) + + input.isSearchInProgress = true + tryCompare(progressIndicator, "visible", true) + compare(progressIndicator.running, true) + compare(searchIcon.visible, true) + const searchIconPosition = searchIcon.mapToItem(input, 0, 0) + const progressPosition = progressIndicator.mapToItem(input, 0, 0) + const clearButtonPosition = clearButton.mapToItem(input, 0, 0) + verify(progressPosition.x > searchIconPosition.x) + verify(progressPosition.x + progressIndicator.width <= clearButtonPosition.x) + verify(input.width - input.rightPadding < progressPosition.x) + + input.isSearchInProgress = false + tryCompare(progressIndicator, "visible", false) + compare(progressIndicator.running, false) + compare(searchIcon.visible, true) + } + + function test_textEditingSignalIsIndependentFromClear() { + keyClick(Qt.Key_C) + keyClick(Qt.Key_A) + keyClick(Qt.Key_L) + keyClick(Qt.Key_E) + keyClick(Qt.Key_N) + keyClick(Qt.Key_D) + keyClick(Qt.Key_A) + keyClick(Qt.Key_R) + compare(input.text, "calendar") + compare(clearSpy.count, 0) + } + + function test_clearActionReplacesFilterAction() { + const clearButton = findChild(input, "clearSearchButton") + + verify(clearButton !== null) + compare(clearButton.visible, false) + keyClick(Qt.Key_C) + compare(clearButton.visible, true) + verify(clearButton.icon.source.toString().includes("clear.svg")) + mouseClick(clearButton) + compare(clearSpy.count, 1) + } + } + + TestCase { + name: "WizardButton" + when: windowShown + + function test_hoverUsesSharedWizardSurface() { + mouseMove(input, 1, 1) + wait(0) + const restingColor = wizardHoverButton.background.color.toString() + const hoverArea = findChild(wizardHoverButton, "wizardButtonHoverArea") + + verify(hoverArea !== null) + mouseMove(wizardHoverButton, 2, 2) + tryCompare(hoverArea, "containsMouse", true) + verify(wizardHoverButton.background.color.toString() !== restingColor) + } + + function test_tintedLeadingIconUsesRequestedColor() { + wizardHoverButton.iconBeforeText = true + wizardHoverButton.tintIcon = true + wizardHoverButton.iconTintColor = Style.wizardPrimaryText + wizardHoverButton.iconSource = "qrc:/client/theme/black/folder.svg" + wait(0) + + const iconTint = findChild(wizardHoverButton, "wizardButtonLeadingIconTint") + verify(iconTint !== null) + compare(iconTint.visible, true) + compare(iconTint.colorization, 1.0) + compare(iconTint.colorizationColor.toString(), Style.wizardPrimaryText.toString()) + } + + function test_trailingIconCannotOverlapLongText() { + wizardHoverButton.iconBeforeText = false + wizardHoverButton.iconSource = "" + wizardHoverButton.trailingIconSource = "qrc:/client/theme/black/caret-down.svg" + wizardHoverButton.text = "An intentionally long translated button label" + wait(0) + + const textItem = findChild(wizardHoverButton, "wizardButtonText") + const trailingIcon = findChild(wizardHoverButton, "wizardButtonTrailingIcon") + verify(textItem !== null) + verify(trailingIcon !== null) + tryVerify(() => { + const textRight = textItem.mapToItem(wizardHoverButton, textItem.width, 0).x + const iconLeft = trailingIcon.mapToItem(wizardHoverButton, 0, 0).x + return textRight < iconLeft + }) + } + } + + TestCase { + name: "WizardMenuItem" + when: windowShown + + function test_hoverUsesSharedWizardMenuSurface() { + mouseMove(input, 1, 1) + wait(0) + const restingColor = wizardMenuHoverItem.background.color.toString() + + mouseMove(wizardMenuHoverItem, 2, 2) + tryCompare(wizardMenuHoverItem, "hovered", true) + verify(wizardMenuHoverItem.background.color.toString() !== restingColor) + } + + function test_tintedIconUsesRequestedColor() { + const iconTint = findChild(wizardMenuHoverItem, "wizardMenuItemIconTint") + verify(iconTint !== null) + compare(iconTint.visible, true) + compare(iconTint.colorization, 1.0) + compare(iconTint.colorizationColor.toString(), Style.wizardPrimaryText.toString()) + } + } + + TestCase { + name: "UnifiedSearchResultDelegate" + when: windowShown + + function init() { + resultDelegate.resultType = UnifiedSearchResultsListModel.ProviderHeader + resultDelegate.hasOverflow = true + resultDelegate.isSelected = false + fakeSearchModel.openedProviderDetails = 0 + fakeSearchModel.activatedResults = 0 + fakeSearchModel.loadedPages = 0 + fakeSearchModel.retriedPages = 0 + wait(0) + } + + function test_providerHeaderReceivesDelegateDataAndGeometry() { + verify(resultDelegate.loadedItem !== null) + compare(resultDelegate.loadedItem.objectName, "providerHeaderRow") + compare(resultDelegate.loadedItem.width, resultDelegate.width) + compare(resultDelegate.height, 44) + compare(resultDelegate.loadedItem.text, "More from Files →") + compare(resultDelegate.loadedItem.font.bold, false) + compare(resultDelegate.loadedItem.font.pixelSize, Style.unifiedSearchResultTitleFontSize) + compare(resultDelegate.loadedItem.leftPadding, 0) + compare(resultDelegate.loadedItem.hoverEnabled, true) + compare(findChild(resultDelegate.loadedItem, "providerHeaderIconTint"), null) + + mouseMove(input, 1, 1) + mouseMove(input, 2, 2) + tryCompare(resultDelegate.loadedItem, "hovered", false) + compare(resultDelegate.loadedItem.background.color.toString(), "#00000000") + mouseMove(resultDelegate.loadedItem, 1, 1) + mouseMove(resultDelegate.loadedItem, 2, 2) + tryCompare(resultDelegate.loadedItem, "hovered", true) + compare(resultDelegate.loadedItem.background.color.toString(), Style.listItemHoverBackground.toString()) + + mousePress(resultDelegate.loadedItem) + compare(fakeSearchModel.openedProviderDetails, 1) + mouseRelease(resultDelegate.loadedItem) + compare(fakeSearchModel.openedProviderDetails, 1) + } + + function test_plainProviderHeaderStaysRegularAndNonInteractive() { + resultDelegate.hasOverflow = false + wait(0) + + compare(resultDelegate.loadedItem.text, "Files") + compare(resultDelegate.loadedItem.font.bold, false) + compare(resultDelegate.loadedItem.font.pixelSize, Style.unifiedSearchResultTitleFontSize) + compare(resultDelegate.loadedItem.leftPadding, 0) + compare(resultDelegate.loadedItem.hoverEnabled, false) + } + + function test_resultRowReceivesDelegateDataAndGeometry() { + resultDelegate.resultType = UnifiedSearchResultsListModel.Default + wait(0) + + verify(resultDelegate.loadedItem !== null) + compare(resultDelegate.loadedItem.objectName, "searchResultRow") + compare(resultDelegate.loadedItem.width, resultDelegate.width) + compare(resultDelegate.height, Style.unifiedSearchItemHeight) + verify(resultDelegate.height <= 44) + compare(resultDelegate.loadedItem.leftPadding, 0) + compare(resultDelegate.loadedItem.rightPadding, 0) + compare(resultDelegate.loadedItem.topPadding, 0) + compare(resultDelegate.loadedItem.bottomPadding, 0) + compare(resultDelegate.loadedItem.contentItem.height, resultDelegate.loadedItem.height) + compare(resultDelegate.loadedItem.background.height, resultDelegate.loadedItem.height) + compare(resultDelegate.loadedItem.contentItem.Accessible.ignored, true) + + const title = findChild(resultDelegate.loadedItem, "searchResultTitle") + const subline = findChild(resultDelegate.loadedItem, "searchResultSubline") + const textContainer = findChild(resultDelegate.loadedItem, "searchResultTextContainer") + verify(title !== null) + verify(subline !== null) + verify(textContainer !== null) + compare(title.font.pixelSize, Style.unifiedSearchResultTitleFontSize) + compare(subline.font.pixelSize, title.font.pixelSize) + compare(textContainer.spacing, Style.unifiedSearchResultTextSpacing) + + resultDelegate.isSelected = true + tryCompare(resultDelegate.loadedItem.background, "color", Style.wizardSecondaryButtonPressed) + resultDelegate.isSelected = false + mouseMove(resultDelegate.loadedItem, 2, 2) + tryCompare(resultDelegate.loadedItem, "hovered", true) + compare(resultDelegate.loadedItem.background.color.toString(), Style.listItemHoverBackground.toString()) + + mouseClick(resultDelegate.loadedItem) + compare(fakeSearchModel.activatedResults, 1) + } + + function test_loadMoreUsesAFlatItemRow() { + resultDelegate.resultType = UnifiedSearchResultsListModel.FetchMoreTrigger + wait(0) + + verify(resultDelegate.loadedItem !== null) + compare(resultDelegate.loadedItem.objectName, "searchPagingRow") + compare(resultDelegate.loadedItem.text, qsTr("Load more results")) + const pagingLabel = findChild(resultDelegate.loadedItem, "searchPagingLabel") + verify(pagingLabel !== null) + compare(pagingLabel.font.bold, false) + verify(resultDelegate.loadedItem.icon.source.toString().includes("more.svg")) + verify(!resultDelegate.loadedItem.hasOwnProperty("primary")) + compare(resultDelegate.loadedItem.height, 44) + mouseMove(input, 1, 1) + wait(0) + compare(resultDelegate.loadedItem.background.color.toString(), "#00000000") + + mouseMove(resultDelegate.loadedItem, 2, 2) + tryCompare(resultDelegate.loadedItem, "hovered", true) + compare(resultDelegate.loadedItem.background.color.toString(), Style.listItemHoverBackground.toString()) + + mousePress(resultDelegate.loadedItem) + compare(fakeSearchModel.loadedPages, 1) + mouseRelease(resultDelegate.loadedItem) + compare(fakeSearchModel.loadedPages, 1) + } + + function test_retryPagingUsesAFlatItemRow() { + resultDelegate.resultType = UnifiedSearchResultsListModel.RetryFetchMoreTrigger + wait(0) + + verify(resultDelegate.loadedItem !== null) + compare(resultDelegate.loadedItem.objectName, "searchPagingRow") + compare(resultDelegate.loadedItem.text, qsTr("Retry loading more results")) + verify(!resultDelegate.loadedItem.hasOwnProperty("primary")) + compare(resultDelegate.loadedItem.height, 44) + + mousePress(resultDelegate.loadedItem) + compare(fakeSearchModel.retriedPages, 1) + mouseRelease(resultDelegate.loadedItem) + compare(fakeSearchModel.retriedPages, 1) + } + } +} diff --git a/test/testcustomstateicons.cpp b/test/testcustomstateicons.cpp index 74aa22ff7866b..591eb77ecd2fd 100644 --- a/test/testcustomstateicons.cpp +++ b/test/testcustomstateicons.cpp @@ -41,7 +41,7 @@ class TestCustomStateIcons : public QObject { Q_OBJECT -private slots: +private Q_SLOTS: void testSvgContrast_data() { QTest::addColumn("fileName"); diff --git a/test/testiconutils.cpp b/test/testiconutils.cpp index 3098827dedff9..e5973383ae4e5 100644 --- a/test/testiconutils.cpp +++ b/test/testiconutils.cpp @@ -43,6 +43,13 @@ private Q_SLOTS: QVERIFY(!OCC::Ui::IconUtils::drawSvgWithCustomFillColor(blackSvgDirPath + QStringLiteral("/") + blackImages.at(0), QColorConstants::Svg::green).isNull()); + const auto imageFromZeroWidthRequest = OCC::Ui::IconUtils::drawSvgWithCustomFillColor( + blackSvgDirPath + QStringLiteral("/") + blackImages.at(0), + QColorConstants::Svg::green, + nullptr, + QSize(0, 24)); + QVERIFY(!imageFromZeroWidthRequest.isNull()); + const QString whiteSvgDirPath{QString{OCC::Theme::themePrefix} + QStringLiteral("white")}; const QDir whiteSvgDir(whiteSvgDirPath); const QStringList whiteImages = whiteSvgDir.entryList(QStringList("*.svg")); diff --git a/test/testunifiedsearchlistmodel.cpp b/test/testunifiedsearchlistmodel.cpp index e20b19f9e7fca..e1bdcd912f73a 100644 --- a/test/testunifiedsearchlistmodel.cpp +++ b/test/testunifiedsearchlistmodel.cpp @@ -8,6 +8,7 @@ #include "account.h" #include "accountstate.h" #include "syncenginetestutils.h" +#include "testhelper.h" #include #include @@ -134,15 +135,25 @@ class FakeSearchResultsStorage } // initialize the JSON response containing the fake list of providers and their properties - void initProvidersResponse() + void initProvidersResponse(const QString &excludedProviderId = {}) { QList providersList; for (const auto &fakeProviderInitInfo : fakeProvidersInitInfo) { + if (fakeProviderInitInfo._id == excludedProviderId) { + continue; + } providersList.push_back(QVariantMap{ {QStringLiteral("id"), fakeProviderInitInfo._id}, + {QStringLiteral("appId"), fakeProviderInitInfo._id}, {QStringLiteral("name"), fakeProviderInitInfo._name}, {QStringLiteral("order"), fakeProviderInitInfo._order}, + {QStringLiteral("filters"), QVariantMap{ + {QStringLiteral("term"), QVariantMap{}}, + {QStringLiteral("since"), QVariantMap{}}, + {QStringLiteral("until"), QVariantMap{}}, + {QStringLiteral("person"), QVariantMap{}}, + }}, }); } @@ -242,6 +253,65 @@ class FakeSearchResultsStorage .toJson(QJsonDocument::Compact); } + if (searchTerm == QStringLiteral("[app-icons]")) { + auto entries = QVariantList{}; + if (providerId == QStringLiteral("settings_apps")) { + const auto titles = QStringList{ + QStringLiteral("Dashboard"), + QStringLiteral("Talk"), + QStringLiteral("Activity"), + QStringLiteral("Unknown app"), + }; + for (const auto &title : titles) { + entries.push_back(QVariantMap{ + {QStringLiteral("thumbnailUrl"), QString()}, + {QStringLiteral("title"), title}, + {QStringLiteral("subline"), QString()}, + {QStringLiteral("resourceUrl"), QStringLiteral("http://example.de/index.php/apps/dashboard/")}, + {QStringLiteral("icon"), QStringLiteral("icon-arrow-right")}, + {QStringLiteral("rounded"), false}, + }); + } + } + const auto dataMap = QVariantMap{ + {QStringLiteral("name"), _searchResultsData[providerId]._name}, + {QStringLiteral("isPaginated"), false}, + {QStringLiteral("cursor"), QVariant()}, + {QStringLiteral("entries"), entries}, + }; + const auto ocsMap = QVariantMap{{QStringLiteral("meta"), _metaSuccess}, {QStringLiteral("data"), dataMap}}; + return QJsonDocument::fromVariant(QVariantMap{{QStringLiteral("ocs"), ocsMap}}).toJson(QJsonDocument::Compact); + } + + if (searchTerm == QStringLiteral("[small-page]")) { + auto entries = resultsForProvider(providerId, 0); + while (entries.size() > 2) { + entries.removeLast(); + } + const QVariantMap dataMap = {{QStringLiteral("name"), _searchResultsData[providerId]._name}, + {QStringLiteral("isPaginated"), true}, {QStringLiteral("cursor"), cursor + entries.size()}, + {QStringLiteral("entries"), entries}}; + const QVariantMap ocsMap = {{QStringLiteral("meta"), _metaSuccess}, {QStringLiteral("data"), dataMap}}; + return QJsonDocument::fromVariant(QVariantMap{{QStringLiteral("ocs"), ocsMap}}) + .toJson(QJsonDocument::Compact); + } + + if (searchTerm == QStringLiteral("[oversized-page]")) { + auto entries = QList(); + const auto availableEntries = resultsForProvider(providerId, 0); + if (!availableEntries.isEmpty()) { + for (auto index = 0; index < 25; ++index) { + entries.push_back(availableEntries.constFirst()); + } + } + const QVariantMap dataMap = {{QStringLiteral("name"), _searchResultsData[providerId]._name}, + {QStringLiteral("isPaginated"), false}, {QStringLiteral("cursor"), QVariant()}, + {QStringLiteral("entries"), entries}}; + 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; @@ -283,9 +353,10 @@ class TestUnifiedSearchListmodel : public QObject QScopedPointer fakeQnam; OCC::AccountPtr account; - QScopedPointer accountState; + QScopedPointer accountState; QScopedPointer model; QScopedPointer modelTester; + QList requestedUrls; QScopedPointer fakeDesktopServicesUrlHandler; @@ -304,10 +375,11 @@ private Q_SLOTS: account->setCredentials(new FakeCredentials{fakeQnam.data()}); account->setUrl(QUrl(("http://example.de"))); - accountState.reset(new OCC::AccountState(account)); + accountState.reset(new FakeAccountState(account)); fakeQnam->setOverride([this](QNetworkAccessManager::Operation op, const QNetworkRequest &req, QIODevice *device) { Q_UNUSED(device); + requestedUrls.push_back(req.url()); QNetworkReply *reply = nullptr; const auto urlQuery = QUrlQuery(req.url()); @@ -370,15 +442,10 @@ private Q_SLOTS: 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 @@ -395,21 +462,8 @@ private Q_SLOTS: // 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); + QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd() + && !model->isSearchInProgress() && model->rowCount() > 0, 2000); } void testSetSearchTermResultsNotFound() @@ -421,21 +475,9 @@ private Q_SLOTS: // 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); + QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd() + && !model->isSearchInProgress(), 2000); + QCOMPARE(model->rowCount(), 0); } void testFetchMoreClicked() @@ -460,12 +502,30 @@ private Q_SLOTS: // make sure search has finished QVERIFY(!model->isSearchInProgress()); + QString providerIdFetchMoreTriggered; + for (auto row = 0; row < model->rowCount(); ++row) { + if (model->data(model->index(row), OCC::UnifiedSearchResultsListModel::TypeRole) + == OCC::UnifiedSearchResult::Type::ProviderHeader + && model->data(model->index(row), OCC::UnifiedSearchResultsListModel::HasOverflowRole).toBool()) { + providerIdFetchMoreTriggered = model->data(model->index(row), OCC::UnifiedSearchResultsListModel::ProviderIdRole).toString(); + break; + } + } + QVERIFY(!providerIdFetchMoreTriggered.isEmpty()); + model->openProviderDetail(providerIdFetchMoreTriggered); + QCOMPARE(model->viewMode(), OCC::UnifiedSearchResultsListModel::ViewMode::ProviderDetail); const auto numRowsInModelPrev = model->rowCount(); + QStringList stableKeysBeforePaging; + for (auto row = 0; row < numRowsInModelPrev - 1; ++row) { + stableKeysBeforePaging.push_back(model->data(model->index(row), + OCC::UnifiedSearchResultsListModel::StableKeyRole).toString()); + } + QSignalSpy modelReset(model.data(), &QAbstractItemModel::modelReset); + QSignalSpy rowsInserted(model.data(), &QAbstractItemModel::rowsInserted); + QSignalSpy rowsRemoved(model.data(), &QAbstractItemModel::rowsRemoved); - // 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); @@ -479,47 +539,24 @@ private Q_SLOTS: } // 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); + QVERIFY(currentFetchMoreInProgressProviderIdChanged.count() > 0); + QTRY_VERIFY_WITH_TIMEOUT(model->currentFetchMoreInProgressProviderId().isEmpty(), 1000); + QVERIFY(model->rowCount() > numRowsInModelPrev); + QCOMPARE(modelReset.count(), 0); + QVERIFY(rowsInserted.count() > 0); + QCOMPARE(rowsRemoved.count(), 0); + for (auto row = 0; row < stableKeysBeforePaging.size(); ++row) { + QCOMPARE(model->data(model->index(row), OCC::UnifiedSearchResultsListModel::StableKeyRole).toString(), + stableKeysBeforePaging[row]); + } // 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; - } + QTRY_VERIFY_WITH_TIMEOUT(model->currentFetchMoreInProgressProviderId().isEmpty(), 1000); } - - QCOMPARE(rowsRemoved.count(), 1); bool isFetchMoreTriggerFound = false; @@ -538,6 +575,178 @@ private Q_SLOTS: } } + void testSmallPaginatedFirstPageCanOpenAndLoadMore() + { + model->setSearchTerm(QStringLiteral("[small-page]")); + QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd() + && !model->isSearchInProgress() && model->rowCount() > 0, 2000); + + auto providerId = QString(); + for (auto row = 0; row < model->rowCount(); ++row) { + const auto index = model->index(row); + if (model->data(index, OCC::UnifiedSearchResultsListModel::TypeRole).toInt() + == OCC::UnifiedSearchResult::Type::ProviderHeader + && model->data(index, OCC::UnifiedSearchResultsListModel::HasOverflowRole).toBool()) { + providerId = model->data(index, OCC::UnifiedSearchResultsListModel::ProviderIdRole).toString(); + break; + } + } + QVERIFY(!providerId.isEmpty()); + + model->openProviderDetail(providerId); + QCOMPARE(model->viewMode(), OCC::UnifiedSearchResultsListModel::ViewMode::ProviderDetail); + const auto rowsBeforeLoading = model->rowCount(); + QVERIFY(rowsBeforeLoading <= 3); + model->loadMore(providerId); + QTRY_VERIFY_WITH_TIMEOUT(model->currentFetchMoreInProgressProviderId().isEmpty(), 1000); + QVERIFY(model->rowCount() > rowsBeforeLoading); + } + + void testNonConformingProviderPageIsCapped() + { + model->setSearchTerm(QStringLiteral("[oversized-page]")); + QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd() + && !model->isSearchInProgress() && model->rowCount() > 0, 2000); + + auto providerId = QString(); + for (auto row = 0; row < model->rowCount(); ++row) { + const auto index = model->index(row); + if (model->data(index, OCC::UnifiedSearchResultsListModel::TypeRole).toInt() + == OCC::UnifiedSearchResult::Type::ProviderHeader) { + providerId = model->data(index, OCC::UnifiedSearchResultsListModel::ProviderIdRole).toString(); + break; + } + } + QVERIFY(!providerId.isEmpty()); + model->openProviderDetail(providerId); + QCOMPARE(model->viewMode(), OCC::UnifiedSearchResultsListModel::ViewMode::ProviderDetail); + QCOMPARE(model->rowCount(), 10); + } + + void testAggregateProjectionAndKeyboardSelection() + { + model->setSearchTerm(QStringLiteral("projection")); + QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd() + && !model->isSearchInProgress() && model->rowCount() > 0, 2000); + + QHash resultsByProvider; + auto loadMoreFound = false; + for (auto row = 0; row < model->rowCount(); ++row) { + const auto index = model->index(row); + const auto type = model->data(index, OCC::UnifiedSearchResultsListModel::TypeRole).toInt(); + if (type == OCC::UnifiedSearchResult::Type::Default) { + ++resultsByProvider[model->data(index, OCC::UnifiedSearchResultsListModel::ProviderIdRole).toString()]; + } else if (type == OCC::UnifiedSearchResult::Type::FetchMoreTrigger) { + loadMoreFound = true; + } + } + for (const auto count : std::as_const(resultsByProvider)) QVERIFY(count <= 3); + QVERIFY(!loadMoreFound); + QVERIFY(model->selectedRow() >= 0); + const auto firstSelected = model->selectedRow(); + model->moveSelection(OCC::UnifiedSearchResultsListModel::SelectionDirection::Next); + QVERIFY(model->selectedRow() >= firstSelected); + model->moveSelection(OCC::UnifiedSearchResultsListModel::SelectionDirection::Last); + const auto lastSelected = model->selectedRow(); + model->moveSelection(OCC::UnifiedSearchResultsListModel::SelectionDirection::Next); + QCOMPARE(model->selectedRow(), lastSelected); + } + + void testAppsProviderUsesNavigationAppIcons() + { + const auto navigationApps = QVariantList{ + QVariantMap{{QStringLiteral("name"), QStringLiteral("Dashboard")}, + {QStringLiteral("href"), QStringLiteral("http://example.de/index.php/apps/dashboard/")}, + {QStringLiteral("id"), QStringLiteral("dashboard")}, + {QStringLiteral("icon"), QStringLiteral("http://example.de/apps/dashboard/img/app.svg")}}, + QVariantMap{{QStringLiteral("name"), QStringLiteral("Talk")}, + {QStringLiteral("href"), QStringLiteral("http://example.de/call/")}, + {QStringLiteral("id"), QStringLiteral("spreed")}, + {QStringLiteral("icon"), QStringLiteral("http://example.de/apps/spreed/img/app.svg")}}, + QVariantMap{{QStringLiteral("name"), QStringLiteral("Activity")}, + {QStringLiteral("href"), QStringLiteral("http://example.de/index.php/apps/activity/")}, + {QStringLiteral("id"), QStringLiteral("activity")}, + {QStringLiteral("icon"), QStringLiteral("http://example.de/apps/activity/img/app.svg")}}, + }; + const auto navigationResponse = QJsonDocument::fromVariant(QVariantMap{ + {QStringLiteral("ocs"), QVariantMap{{QStringLiteral("data"), navigationApps}}}, + }); + QVERIFY(QMetaObject::invokeMethod(accountState.data(), + "slotNavigationAppsFetched", + Qt::DirectConnection, + Q_ARG(QJsonDocument, navigationResponse), + Q_ARG(int, 200))); + + model->setSearchTerm(QString()); + model->closeProviderDetail(); + model->clearTypeFilters(); + model->clearDateFilter(); + model->clearPersonFilter(); + model->setSearchTerm(QStringLiteral("[app-icons]")); + QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd() + && !model->isSearchInProgress() && model->rowCount() > 0, 2000); + + model->openProviderDetail(QStringLiteral("settings_apps")); + QCOMPARE(model->rowCount(), 4); + + const auto expectedIconUrls = QHash{ + {QStringLiteral("Dashboard"), QStringLiteral("http://example.de/apps/dashboard/img/app.svg")}, + {QStringLiteral("Talk"), QStringLiteral("http://example.de/apps/spreed/img/app.svg")}, + {QStringLiteral("Activity"), QStringLiteral("http://example.de/apps/activity/img/app.svg")}, + }; + for (auto row = 0; row < model->rowCount(); ++row) { + const auto index = model->index(row); + const auto title = model->data(index, OCC::UnifiedSearchResultsListModel::TitleRole).toString(); + if (expectedIconUrls.contains(title)) { + QCOMPARE(model->data(index, OCC::UnifiedSearchResultsListModel::DarkIconsRole).toString(), + expectedIconUrls.value(title) + QStringLiteral("/white")); + QCOMPARE(model->data(index, OCC::UnifiedSearchResultsListModel::LightIconsRole).toString(), + expectedIconUrls.value(title) + QStringLiteral("/black")); + } else if (title == QStringLiteral("Unknown app")) { + QVERIFY(model->data(index, OCC::UnifiedSearchResultsListModel::DarkIconsRole) + .toString() + .endsWith(QStringLiteral("change.svg"))); + QVERIFY(model->data(index, OCC::UnifiedSearchResultsListModel::LightIconsRole) + .toString() + .endsWith(QStringLiteral("change.svg"))); + } + } + } + + void testFiltersAndRequestParameters() + { + model->setSearchTerm(QString()); + model->clearTypeFilters(); + model->clearDateFilter(); + model->clearPersonFilter(); + requestedUrls.clear(); + + model->toggleProviderFilter(QStringLiteral("files")); + model->setDatePreset(QStringLiteral("last7days")); + model->setPersonFilter(QStringLiteral("ada"), QStringLiteral("Ada Lovelace")); + model->setSearchTerm(QStringLiteral("filtered")); + QTRY_VERIFY_WITH_TIMEOUT(!model->isSearchInProgress() && !model->waitingForSearchTermEditEnd(), 2000); + + QList searchRequests; + for (const auto &url : std::as_const(requestedUrls)) { + if (url.path().endsWith(QStringLiteral("/search"))) searchRequests.push_back(url); + } + QCOMPARE(searchRequests.size(), 1); + QVERIFY(searchRequests.constFirst().path().contains(QStringLiteral("/files/search"))); + const QUrlQuery query(searchRequests.constFirst()); + QCOMPARE(query.queryItemValue(QStringLiteral("term")), QStringLiteral("filtered")); + QCOMPARE(query.queryItemValue(QStringLiteral("limit")), QStringLiteral("10")); + QCOMPARE(query.queryItemValue(QStringLiteral("person")), QStringLiteral("ada")); + QVERIFY(!query.queryItemValue(QStringLiteral("since")).isEmpty()); + QVERIFY(!query.queryItemValue(QStringLiteral("until")).isEmpty()); + QVERIFY(!query.hasQueryItem(QStringLiteral("from"))); + + model->setSearchTerm(QString()); + model->clearTypeFilters(); + model->clearDateFilter(); + model->clearPersonFilter(); + } + void testSearchResultlicked() { // make sure the model is empty @@ -547,21 +756,8 @@ private Q_SLOTS: // 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); + QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd() + && !model->isSearchInProgress() && model->rowCount() > 0, 2000); QDesktopServices::setUrlHandler("http", fakeDesktopServicesUrlHandler.data(), "resultClicked"); QDesktopServices::setUrlHandler("https", fakeDesktopServicesUrlHandler.data(), "resultClicked"); @@ -737,6 +933,32 @@ private Q_SLOTS: model->setSearchTerm(QStringLiteral("")); } + void testDisconnectAndRediscoveryResetProviderState() + { + model->setSearchTerm(QString()); + model->clearTypeFilters(); + QVERIFY(model->providersReady()); + model->toggleProviderFilter(QStringLiteral("files")); + QCOMPARE(model->activeFilters().size(), 1); + QSignalSpy providersReadyChanged(model.data(), &OCC::UnifiedSearchResultsListModel::providersReadyChanged); + + accountState->setStateForTesting(OCC::AccountState::Disconnected); + QVERIFY(QMetaObject::invokeMethod(accountState.data(), "isConnectedChanged", Qt::DirectConnection)); + QCOMPARE(model->providersReady(), false); + QVERIFY(providersReadyChanged.count() > 0); + + FakeSearchResultsStorage::instance()->initProvidersResponse(QStringLiteral("files")); + accountState->setStateForTesting(OCC::AccountState::Connected); + QVERIFY(QMetaObject::invokeMethod(accountState.data(), "isConnectedChanged", Qt::DirectConnection)); + QTRY_VERIFY_WITH_TIMEOUT(model->providersReady(), 1000); + QCOMPARE(model->activeFilters().size(), 0); + for (const auto &providerValue : model->providers()) { + QVERIFY(providerValue.toMap().value(QStringLiteral("id")).toString() != QStringLiteral("files")); + } + + FakeSearchResultsStorage::instance()->initProvidersResponse(); + } + void cleanupTestCase() { FakeSearchResultsStorage::destroy(); diff --git a/test/testunifiedsearchpeoplemodel.cpp b/test/testunifiedsearchpeoplemodel.cpp new file mode 100644 index 0000000000000..b4006cb8257df --- /dev/null +++ b/test/testunifiedsearchpeoplemodel.cpp @@ -0,0 +1,202 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "gui/search/unifiedsearchpeoplemodel.h" + +#include "account.h" +#include "accountstate.h" +#include "syncenginetestutils.h" +#include "testhelper.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +const auto response = QByteArrayLiteral(R"({"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"exact":{"users":[{"label":"Ada Lovelace","value":{"shareWith":"ada","shareType":0}}]},"users":[{"label":"Ada duplicate","value":{"shareWith":"ada","shareType":0}},{"label":"Alan Turing","value":{"shareWith":"alan","shareType":0}}]}}})"); +const auto errorResponse = QByteArrayLiteral(R"({"ocs":{"meta":{"status":"failure","statuscode":500,"message":"Failure"},"data":{}}})"); +} + +class TestUnifiedSearchPeopleModel : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void retryIsExposedAsSlot() + { + const auto methodIndex = OCC::UnifiedSearchPeopleModel::staticMetaObject.indexOfSlot("retry()"); + QVERIFY(methodIndex >= 0); + } + + void emptyQueryShowsSignedInUserWithoutRequest() + { + auto qnam = std::unique_ptr(new FakeQNAM({})); + auto account = OCC::Account::create(); + account->setCredentials(new FakeCredentials(qnam.get())); + account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/"))); + account->setDavUser(QStringLiteral("current-user")); + auto state = std::make_unique(account); + auto requestCount = 0; + qnam->setOverride([&](QNetworkAccessManager::Operation operation, const QNetworkRequest &request, QIODevice *) { + ++requestCount; + return static_cast(new FakePayloadReply(operation, request, response, qnam.get())); + }); + + OCC::UnifiedSearchPeopleModel model(nullptr, 0); + QAbstractItemModelTester tester(&model); + model.setAccountState(state.get()); + + QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 1, 1000); + QCOMPARE(requestCount, 0); + QCOMPARE(model.data(model.index(0), OCC::UnifiedSearchPeopleModel::UserIdRole).toString(), QStringLiteral("current-user")); + QVERIFY(model.errorString().isEmpty()); + } + + void parsesUsersAndUsesDocumentedParameters() + { + auto qnam = std::unique_ptr(new FakeQNAM({})); + auto account = OCC::Account::create(); + account->setCredentials(new FakeCredentials(qnam.get())); + account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/"))); + account->setDavUser(QStringLiteral("me")); + auto state = std::make_unique(account); + QUrl requestedUrl; + qnam->setOverride([&](QNetworkAccessManager::Operation operation, const QNetworkRequest &request, QIODevice *) { + requestedUrl = request.url(); + return static_cast(new FakePayloadReply(operation, request, response, qnam.get())); + }); + + OCC::UnifiedSearchPeopleModel model(nullptr, 0); + QAbstractItemModelTester tester(&model); + model.setAccountState(state.get()); + model.setSearchTerm(QStringLiteral("a")); + QTRY_VERIFY_WITH_TIMEOUT(!model.busy() && model.rowCount() == 2, 1000); + + QCOMPARE(requestedUrl.path(), QStringLiteral("/ocs/v2.php/apps/files_sharing/api/v1/sharees")); + const QUrlQuery query(requestedUrl); + QCOMPARE(query.queryItemValue(QStringLiteral("shareType")), QStringLiteral("0")); + QCOMPARE(query.queryItemValue(QStringLiteral("lookup")), QStringLiteral("false")); + QCOMPARE(query.queryItemValue(QStringLiteral("page")), QStringLiteral("1")); + QCOMPARE(query.queryItemValue(QStringLiteral("perPage")), QStringLiteral("50")); + QCOMPARE(model.data(model.index(0), OCC::UnifiedSearchPeopleModel::UserIdRole).toString(), QStringLiteral("ada")); + QCOMPARE(model.data(model.index(1), OCC::UnifiedSearchPeopleModel::UserIdRole).toString(), QStringLiteral("alan")); + QCOMPARE(model.data(model.index(0), OCC::UnifiedSearchPeopleModel::AvatarUrlRole).toString(), + QStringLiteral("https://cloud.example.test/index.php/avatar/ada/64")); + } + + void injectsMatchingSignedInUser() + { + auto qnam = std::unique_ptr(new FakeQNAM({})); + auto account = OCC::Account::create(); + account->setCredentials(new FakeCredentials(qnam.get())); + account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/"))); + account->setDavUser(QStringLiteral("current-user")); + auto state = std::make_unique(account); + qnam->setOverride([&](QNetworkAccessManager::Operation operation, const QNetworkRequest &request, QIODevice *) { + return static_cast(new FakePayloadReply(operation, request, QByteArrayLiteral(R"({"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"exact":{"users":[]},"users":[]}}})"), qnam.get())); + }); + OCC::UnifiedSearchPeopleModel model(nullptr, 0); + model.setAccountState(state.get()); + model.setSearchTerm(QStringLiteral("current")); + QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 1, 1000); + QCOMPARE(model.data(model.index(0), OCC::UnifiedSearchPeopleModel::UserIdRole).toString(), QStringLiteral("current-user")); + } + + void queryReplacementAndFailureClearOldPeople() + { + auto qnam = std::unique_ptr(new FakeQNAM({})); + auto account = OCC::Account::create(); + account->setCredentials(new FakeCredentials(qnam.get())); + account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/"))); + account->setDavUser(QStringLiteral("me")); + auto state = std::make_unique(account); + qnam->setOverride([&](QNetworkAccessManager::Operation operation, const QNetworkRequest &request, QIODevice *) { + const auto query = QUrlQuery(request.url()).queryItemValue(QStringLiteral("search")); + if (query == QStringLiteral("broken")) { + return static_cast(new FakeErrorReply(operation, request, qnam.get(), 500, errorResponse)); + } + return static_cast(new FakePayloadReply(operation, request, response, qnam.get())); + }); + + OCC::UnifiedSearchPeopleModel model(nullptr, 0); + QAbstractItemModelTester tester(&model); + model.setAccountState(state.get()); + model.setSearchTerm(QStringLiteral("a")); + QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 2, 1000); + + model.setSearchTerm(QStringLiteral("broken")); + QCOMPARE(model.rowCount(), 0); + QTRY_VERIFY_WITH_TIMEOUT(!model.busy() && !model.errorString().isEmpty(), 1000); + QCOMPARE(model.rowCount(), 0); + } + + void accountDestructionClearsPeopleAndPointer() + { + auto qnam = std::unique_ptr(new FakeQNAM({})); + auto account = OCC::Account::create(); + account->setCredentials(new FakeCredentials(qnam.get())); + account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/"))); + account->setDavUser(QStringLiteral("current-user")); + auto state = std::make_unique(account); + + OCC::UnifiedSearchPeopleModel model(nullptr, 0); + QAbstractItemModelTester tester(&model); + model.setAccountState(state.get()); + QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 1, 1000); + + state->setStateForTesting(OCC::AccountState::Disconnected); + QVERIFY(QMetaObject::invokeMethod(state.get(), "isConnectedChanged", Qt::DirectConnection)); + QCOMPARE(model.rowCount(), 0); + QVERIFY(!model.errorString().isEmpty()); + + state.reset(); + QCOMPARE(model.accountState(), nullptr); + QCOMPARE(model.rowCount(), 0); + QVERIFY(!model.errorString().isEmpty()); + } + + void capsNonConformingResponses() + { + auto qnam = std::unique_ptr(new FakeQNAM({})); + auto account = OCC::Account::create(); + account->setCredentials(new FakeCredentials(qnam.get())); + account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/"))); + auto state = std::make_unique(account); + + auto users = QJsonArray(); + for (auto index = 0; index < 75; ++index) { + users.push_back(QJsonObject{ + {QStringLiteral("label"), QStringLiteral("User %1").arg(index)}, + {QStringLiteral("value"), QJsonObject{{QStringLiteral("shareWith"), QStringLiteral("user-%1").arg(index)}}}, + }); + } + const auto oversizedResponse = QJsonDocument(QJsonObject{ + {QStringLiteral("ocs"), QJsonObject{ + {QStringLiteral("meta"), QJsonObject{{QStringLiteral("statuscode"), 200}}}, + {QStringLiteral("data"), QJsonObject{ + {QStringLiteral("exact"), QJsonObject{{QStringLiteral("users"), QJsonArray()}}}, + {QStringLiteral("users"), users}, + }}, + }}, + }).toJson(QJsonDocument::Compact); + qnam->setOverride([&](QNetworkAccessManager::Operation operation, const QNetworkRequest &request, QIODevice *) { + return static_cast(new FakePayloadReply(operation, request, oversizedResponse, qnam.get())); + }); + + OCC::UnifiedSearchPeopleModel model(nullptr, 0); + model.setAccountState(state.get()); + model.setSearchTerm(QStringLiteral("user")); + QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 50, 1000); + } +}; + +QTEST_MAIN(TestUnifiedSearchPeopleModel) +#include "testunifiedsearchpeoplemodel.moc" diff --git a/theme.qrc.in b/theme.qrc.in index 501cae84d3d83..408d8a16e34cf 100644 --- a/theme.qrc.in +++ b/theme.qrc.in @@ -192,6 +192,7 @@ theme/black/caret-down.svg theme/black/change.svg theme/black/clear.svg + theme/black/filter.svg theme/black/close.svg theme/black/comment.svg theme/black/confirm.svg @@ -269,6 +270,8 @@ theme/white/error.svg + theme/arrow-left.svg + theme/arrow-right.svg theme/sync-arrow.svg theme/close.svg theme/files.svg diff --git a/theme/Style/Style.qml b/theme/Style/Style.qml index 84adf5c075e70..a941c8db3accb 100644 --- a/theme/Style/Style.qml +++ b/theme/Style/Style.qml @@ -49,6 +49,7 @@ QtObject { readonly property color wizardErrorText: darkMode ? "#ff7b86" : "#b00020" readonly property color wizardAvatarPlaceholder: darkMode ? "#3b464d" : "#dfe8ee" readonly property color wizardSelectedText: "#ffffff" + readonly property color listItemHoverBackground: darkMode ? "#3a3a3a" : "#f2f2f2" // ErrorBox colors readonly property color errorBoxBackgroundColor: Qt.rgba(0.89, 0.18, 0.18, 1) @@ -126,6 +127,11 @@ QtObject { readonly property int wizardWindowMargin: 24 readonly property int wizardWindowTopMargin: standardSpacing readonly property int wizardFooterButtonHeight: iconButtonWidth + readonly property int wizardButtonHorizontalPadding: 18 + readonly property int wizardButtonContentSpacing: 6 + readonly property int wizardButtonFontPixelSize: pixelSize + 3 + readonly property int wizardChipButtonHeight: variableSize(26) + readonly property int wizardMenuItemHorizontalPadding: wizardSectionSpacing readonly property int wizardFooterSpacing: trayAccountPopupActionVerticalPadding readonly property int wizardSectionSpacing: trayAccountPopupRowPadding readonly property int wizardDialogMaximumWidth: 420 @@ -149,7 +155,7 @@ QtObject { readonly property int assistantWindowWidth: 640 readonly property int assistantWindowHeight: 620 readonly property int searchWindowWidth: 640 - readonly property int searchWindowHeight: 620 + readonly property int searchWindowHeight: 670 readonly property int userStatusWindowWidth: 560 readonly property int userStatusWindowHeight: 700 readonly property int userStatusWindowMinimumHeight: 560 @@ -220,15 +226,32 @@ QtObject { // Visual behaviour property bool hoverEffectsEnabled: true - // unified search constants - readonly property int unifiedSearchItemHeight: trayWindowHeaderHeight + // unified search constants + readonly property int unifiedSearchDetailHeaderHeight: standardPrimaryButtonHeight + readonly property int unifiedSearchFilterButtonMinimumWidth: 140 + readonly property real unifiedSearchFilterMenuWidthFactor: 1.5 + readonly property int unifiedSearchProviderMenuMaximumVisibleRows: 8 + readonly property int unifiedSearchPeoplePopupHorizontalMargin: standardPrimaryButtonHeight + readonly property int unifiedSearchPeoplePopupHeight: 340 + readonly property int unifiedSearchPeoplePopupTopMargin: 150 + readonly property int unifiedSearchProviderHeaderHeight: 44 + readonly property int unifiedSearchPartialMatchesHeaderHeight: standardPrimaryButtonHeight + readonly property int unifiedSearchPagingRowHeight: unifiedSearchProviderHeaderHeight + readonly property int unifiedSearchLoadingPlaceholderCount: 3 + readonly property real unifiedSearchLoadingPlaceholderInitialWidthRatio: 0.72 + readonly property real unifiedSearchLoadingPlaceholderWidthStep: 0.07 + readonly property real unifiedSearchLoadingPlaceholderOpacity: 0.55 + readonly property real unifiedSearchPartialMatchOpacity: 0.72 + readonly property int unifiedSearchItemHeight: Math.max(44, + unifiedSearchResultTitleFontSize * 2 + unifiedSearchResultTextSpacing + 8) readonly property int unifiedSearchResultTextLeftMargin: 18 readonly property int unifiedSearchResultTextRightMargin: 16 readonly property int unifiedSearchResultIconWidth: trayListItemIconSize * (1 - thumbnailImageSizeReduction) readonly property int unifiedSearchResultSmallIconWidth: trayListItemIconSize * (1 - thumbnailImageSizeReduction * 2) readonly property int unifiedSearchResultIconLeftMargin: 12 - readonly property int unifiedSearchResultTitleFontSize: topLinePixelSize - readonly property int unifiedSearchResultSublineFontSize: subLinePixelSize + readonly property int unifiedSearchResultTitleFontSize: pixelSize + extraSmallSpacing + readonly property int unifiedSearchResultSublineFontSize: unifiedSearchResultTitleFontSize + readonly property int unifiedSearchResultTextSpacing: extraSmallSpacing readonly property int unifiedSearchResultSectionItemLeftPadding: 16 readonly property int unifiedSearchResultSectionItemVerticalPadding: 8 readonly property int unifiedSearchResultNothingFoundHorizontalMargin: 10 diff --git a/theme/arrow-left.svg b/theme/arrow-left.svg new file mode 100644 index 0000000000000..d2992de683874 --- /dev/null +++ b/theme/arrow-left.svg @@ -0,0 +1,7 @@ + + + + diff --git a/theme/arrow-right.svg b/theme/arrow-right.svg new file mode 100644 index 0000000000000..b93e479f6161c --- /dev/null +++ b/theme/arrow-right.svg @@ -0,0 +1,7 @@ + + + + diff --git a/theme/black/filter.svg b/theme/black/filter.svg new file mode 100644 index 0000000000000..75cbe818fa6c2 --- /dev/null +++ b/theme/black/filter.svg @@ -0,0 +1,3 @@ + + +