From 37a8232f710f84a65bfabe89a54ea850bcfc0a04 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 29 Jul 2026 17:29:27 +0800 Subject: [PATCH 001/104] fix(activity): Add ActivityFileMenu Signed-off-by: Claudio Cambra --- resources.qrc | 1 + src/gui/activity/qml/ActivityFileMenu.qml | 50 +++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/gui/activity/qml/ActivityFileMenu.qml diff --git a/resources.qrc b/resources.qrc index a9e58cda6e896..2e61ab5fc39b0 100644 --- a/resources.qrc +++ b/resources.qrc @@ -40,6 +40,7 @@ src/gui/tray/TrayWindowHeader.qml src/gui/activity/qml/ActivityItemContextMenu.qml src/gui/activity/qml/ActivityItemActions.qml + src/gui/activity/qml/ActivityFileMenu.qml src/gui/activity/qml/ActivityItemContent.qml src/gui/activity/qml/TalkReplyTextField.qml src/gui/tray/CallNotificationDialog.qml diff --git a/src/gui/activity/qml/ActivityFileMenu.qml b/src/gui/activity/qml/ActivityFileMenu.qml new file mode 100644 index 0000000000000..d87a31b447a0a --- /dev/null +++ b/src/gui/activity/qml/ActivityFileMenu.qml @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls +import "../../tray" + +AutoSizingMenu { + id: root + + required property string filePath + required property bool serverHasIntegration + required property int itemFontPixelSize + + signal fileDetailsRequested(string filePath) + signal fileActionsRequested(string filePath) + + closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape + height: implicitHeight + + Action { + id: fileDetailsAction + objectName: "fileDetailsAction" + text: qsTr("File details") + onTriggered: root.fileDetailsRequested(root.filePath) + } + + Action { + id: fileActionsAction + objectName: "fileActionsAction" + text: qsTr("File actions") + onTriggered: root.fileActionsRequested(root.filePath) + } + + MenuItem { + action: fileDetailsAction + font.pixelSize: root.itemFontPixelSize + hoverEnabled: true + } + + MenuItem { + action: fileActionsAction + visible: root.serverHasIntegration + height: visible ? implicitHeight : 0 + font.pixelSize: root.itemFontPixelSize + hoverEnabled: true + } +} From ff2f01ce47fe9f7440078434ded1a7d12e38a3b4 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 29 Jul 2026 17:30:01 +0800 Subject: [PATCH 002/104] fix(activity): Use ActivityFileMenu within ActivityItemContent Signed-off-by: Claudio Cambra --- src/gui/activity/qml/ActivityItemContent.qml | 27 ++++++-------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/gui/activity/qml/ActivityItemContent.qml b/src/gui/activity/qml/ActivityItemContent.qml index 822259d97d734..33e2944989aed 100644 --- a/src/gui/activity/qml/ActivityItemContent.qml +++ b/src/gui/activity/qml/ActivityItemContent.qml @@ -191,26 +191,15 @@ RowLayout { visible: model.showFileDetails onClicked: fileMoreButtonMenu.visible ? fileMoreButtonMenu.close() : fileMoreButtonMenu.popup() - AutoSizingMenu { + ActivityFileMenu { id: fileMoreButtonMenu - closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape - - MenuItem { - height: visible ? implicitHeight : 0 - text: qsTr("File details") - font.pixelSize: Style.topLinePixelSize - hoverEnabled: true - onClicked: Systray.presentShareViewInTray(model.openablePath) - } - - MenuItem { - visible: model.serverHasIntegration - height: visible ? implicitHeight : 0 - text: qsTr("File actions") - font.pixelSize: Style.topLinePixelSize - hoverEnabled: true - onClicked: Systray.presentFileActionsViewInSystray(model.openablePath) - } + + filePath: root.activityData.openablePath + serverHasIntegration: root.activityData.serverHasIntegration + itemFontPixelSize: Style.topLinePixelSize + + onFileDetailsRequested: path => Systray.presentShareViewInTray(path) + onFileActionsRequested: path => Systray.presentFileActionsViewInSystray(path) } } From eadf67880ba964e43df4e9d1a920fff4c1037ec5 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 29 Jul 2026 17:31:06 +0800 Subject: [PATCH 003/104] chore(activity): Remove unused Style import in AutoSizingMenu Signed-off-by: Claudio Cambra --- src/gui/tray/AutoSizingMenu.qml | 1 - 1 file changed, 1 deletion(-) diff --git a/src/gui/tray/AutoSizingMenu.qml b/src/gui/tray/AutoSizingMenu.qml index e3cc79871d459..835bcaa1bdd57 100644 --- a/src/gui/tray/AutoSizingMenu.qml +++ b/src/gui/tray/AutoSizingMenu.qml @@ -5,7 +5,6 @@ import QtQuick import QtQuick.Controls -import Style Menu { popupType: Popup.Window From 0e3b09c11ae39e2924e3e326b414a738ab3d5be4 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 29 Jul 2026 17:45:22 +0800 Subject: [PATCH 004/104] fix(activity): Add activity file menu test Signed-off-by: Claudio Cambra --- test/CMakeLists.txt | 4 + .../activityfilemenu/testactivityfilemenu.qml | 78 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 test/qml/activityfilemenu/testactivityfilemenu.qml diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index bbfd290ec6c14..217a7d6a1f172 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -101,6 +101,10 @@ nextcloud_add_test(UnifiedSearchListmodel) nextcloud_add_test(ActivityListModel) nextcloud_add_test(SortedActivityListModel) nextcloud_add_test(ActivityData) +add_test(NAME ActivityFileMenuQmlTest + COMMAND Qt6::qmltestrunner + -input "${CMAKE_CURRENT_SOURCE_DIR}/qml/activityfilemenu/testactivityfilemenu.qml" +) nextcloud_add_test(TalkReply) nextcloud_add_test(LockFile) nextcloud_add_test(ShareModel) diff --git a/test/qml/activityfilemenu/testactivityfilemenu.qml b/test/qml/activityfilemenu/testactivityfilemenu.qml new file mode 100644 index 0000000000000..73cd06d348b86 --- /dev/null +++ b/test/qml/activityfilemenu/testactivityfilemenu.qml @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtTest +import "../../../src/gui/activity/qml" + +TestCase { + id: testCase + + name: "ActivityFileMenu" + when: windowShown + + property ActivityFileMenu menu + + SignalSpy { + id: fileDetailsRequestedSpy + signalName: "fileDetailsRequested" + } + + SignalSpy { + id: fileActionsRequestedSpy + signalName: "fileActionsRequested" + } + + Component { + id: menuComponent + + ActivityFileMenu { + filePath: "/sync/folder/file.txt" + serverHasIntegration: true + itemFontPixelSize: 14 + } + } + + function init() + { + menu = createTemporaryObject(menuComponent, testCase); + verify(menu); + + fileDetailsRequestedSpy.target = menu; + fileActionsRequestedSpy.target = menu; + } + + function test_usesFullImplicitHeight() + { + compare(menu.height, menu.implicitHeight); + verify(menu.height > 0); + } + + function test_fileDetailsItemEmitsCapturedPath() + { + menu.popup(); + tryCompare(menu, "opened", true); + + const fileDetailsItem = menu.itemAt(0); + verify(fileDetailsItem); + mouseClick(fileDetailsItem); + + compare(fileDetailsRequestedSpy.count, 1); + compare(fileDetailsRequestedSpy.signalArguments[0][0], "/sync/folder/file.txt"); + } + + function test_fileActionsItemEmitsCapturedPath() + { + menu.popup(); + tryCompare(menu, "opened", true); + + const fileActionsItem = menu.itemAt(1); + verify(fileActionsItem); + mouseClick(fileActionsItem); + + compare(fileActionsRequestedSpy.count, 1); + compare(fileActionsRequestedSpy.signalArguments[0][0], "/sync/folder/file.txt"); + } +} From 74369462826b9bf3fc6a2d370cee9021ab5e5376 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 29 Jul 2026 17:54:32 +0800 Subject: [PATCH 005/104] fix(activity): Add ActivityFileMenuButton Signed-off-by: Claudio Cambra --- resources.qrc | 1 + .../activity/qml/ActivityFileMenuButton.qml | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/gui/activity/qml/ActivityFileMenuButton.qml diff --git a/resources.qrc b/resources.qrc index 2e61ab5fc39b0..cb4cc6218d7ff 100644 --- a/resources.qrc +++ b/resources.qrc @@ -41,6 +41,7 @@ src/gui/activity/qml/ActivityItemContextMenu.qml src/gui/activity/qml/ActivityItemActions.qml src/gui/activity/qml/ActivityFileMenu.qml + src/gui/activity/qml/ActivityFileMenuButton.qml src/gui/activity/qml/ActivityItemContent.qml src/gui/activity/qml/TalkReplyTextField.qml src/gui/tray/CallNotificationDialog.qml diff --git a/src/gui/activity/qml/ActivityFileMenuButton.qml b/src/gui/activity/qml/ActivityFileMenuButton.qml new file mode 100644 index 0000000000000..5e6d16afd1fef --- /dev/null +++ b/src/gui/activity/qml/ActivityFileMenuButton.qml @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls + +Button { + id: root + + required property string filePath + required property bool serverHasIntegration + required property int itemFontPixelSize + required property int buttonWidth + required property int buttonHeight + required property int buttonIconSize + + readonly property alias menu: fileMenu + + signal fileDetailsRequested(string filePath) + signal fileActionsRequested(string filePath) + + width: buttonWidth + height: buttonHeight + + icon.name: "view-more-symbolic" + icon.source: "image://svgimage-custom-color/more.svg/" + palette.buttonText + icon.width: buttonIconSize + icon.height: buttonIconSize + + ToolTip { + popupType: Qt.platform.os === "windows" ? Popup.Item : Popup.Native + text: qsTr("Open file details") + visible: parent.hovered + } + + display: Button.IconOnly + onClicked: fileMenu.visible ? fileMenu.close() : fileMenu.popup() + + ActivityFileMenu { + id: fileMenu + + filePath: root.filePath + serverHasIntegration: root.serverHasIntegration + itemFontPixelSize: root.itemFontPixelSize + + onFileDetailsRequested: path => root.fileDetailsRequested(path) + onFileActionsRequested: path => root.fileActionsRequested(path) + } +} From 022071ed6ac3b13adbdf6c83b6e3ff74744aabf4 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 29 Jul 2026 17:55:36 +0800 Subject: [PATCH 006/104] fix(activity): Use file button in activity item content Signed-off-by: Claudio Cambra --- src/gui/activity/qml/ActivityItemContent.qml | 36 +++++--------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/src/gui/activity/qml/ActivityItemContent.qml b/src/gui/activity/qml/ActivityItemContent.qml index 33e2944989aed..253c164e7c522 100644 --- a/src/gui/activity/qml/ActivityItemContent.qml +++ b/src/gui/activity/qml/ActivityItemContent.qml @@ -170,37 +170,19 @@ RowLayout { Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter spacing: Style.extraSmallSpacing - Button { + ActivityFileMenuButton { id: fileDetailsButton - width: Style.activityListButtonWidth - height: Style.activityListButtonHeight - - icon.name: 'view-more-symbolic' - icon.source: "image://svgimage-custom-color/more.svg/" + palette.buttonText - icon.width: Style.activityListButtonIconSize - icon.height: Style.activityListButtonIconSize - - ToolTip { - popupType: Qt.platform.os === "windows" ? Popup.Item : Qt.platform.os === "windows" ? Popup.Item : Popup.Native - text: qsTr("Open file details") - visible: parent.hovered - } - - display: Button.IconOnly + buttonWidth: Style.activityListButtonWidth + buttonHeight: Style.activityListButtonHeight + buttonIconSize: Style.activityListButtonIconSize + itemFontPixelSize: Style.topLinePixelSize + filePath: root.activityData.openablePath + serverHasIntegration: root.activityData.serverHasIntegration visible: model.showFileDetails - onClicked: fileMoreButtonMenu.visible ? fileMoreButtonMenu.close() : fileMoreButtonMenu.popup() - - ActivityFileMenu { - id: fileMoreButtonMenu - filePath: root.activityData.openablePath - serverHasIntegration: root.activityData.serverHasIntegration - itemFontPixelSize: Style.topLinePixelSize - - onFileDetailsRequested: path => Systray.presentShareViewInTray(path) - onFileActionsRequested: path => Systray.presentFileActionsViewInSystray(path) - } + onFileDetailsRequested: path => Systray.presentShareViewInTray(path) + onFileActionsRequested: path => Systray.presentFileActionsViewInSystray(path) } Button { From 618040e25ff640f0aded48aecec91153f96ea54c Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 29 Jul 2026 18:15:03 +0800 Subject: [PATCH 007/104] fix(activity): Include button in UI test Signed-off-by: Claudio Cambra --- .../activityfilemenu/testactivityfilemenu.qml | 123 ++++++++++-------- 1 file changed, 72 insertions(+), 51 deletions(-) diff --git a/test/qml/activityfilemenu/testactivityfilemenu.qml b/test/qml/activityfilemenu/testactivityfilemenu.qml index 73cd06d348b86..6274c0106c2bc 100644 --- a/test/qml/activityfilemenu/testactivityfilemenu.qml +++ b/test/qml/activityfilemenu/testactivityfilemenu.qml @@ -7,72 +7,93 @@ import QtQuick import QtTest import "../../../src/gui/activity/qml" -TestCase { - id: testCase +Item { + id: testRoot - name: "ActivityFileMenu" - when: windowShown + width: 200 + height: 100 - property ActivityFileMenu menu + TestCase { + id: testCase - SignalSpy { - id: fileDetailsRequestedSpy - signalName: "fileDetailsRequested" - } + name: "ActivityFileMenu" + when: windowShown - SignalSpy { - id: fileActionsRequestedSpy - signalName: "fileActionsRequested" - } + property ActivityFileMenuButton button - Component { - id: menuComponent + SignalSpy { + id: fileDetailsRequestedSpy + signalName: "fileDetailsRequested" + } - ActivityFileMenu { - filePath: "/sync/folder/file.txt" - serverHasIntegration: true - itemFontPixelSize: 14 + SignalSpy { + id: fileActionsRequestedSpy + signalName: "fileActionsRequested" } - } - function init() - { - menu = createTemporaryObject(menuComponent, testCase); - verify(menu); + Component { + id: buttonComponent + + ActivityFileMenuButton { + filePath: "/sync/folder/file.txt" + serverHasIntegration: true + itemFontPixelSize: 14 + buttonWidth: 44 + buttonHeight: 32 + buttonIconSize: 16 + } + } - fileDetailsRequestedSpy.target = menu; - fileActionsRequestedSpy.target = menu; - } + function init() + { + button = createTemporaryObject(buttonComponent, testRoot); + verify(button); - function test_usesFullImplicitHeight() - { - compare(menu.height, menu.implicitHeight); - verify(menu.height > 0); - } + fileDetailsRequestedSpy.target = button; + fileActionsRequestedSpy.target = button; + } - function test_fileDetailsItemEmitsCapturedPath() - { - menu.popup(); - tryCompare(menu, "opened", true); + function test_usesFullImplicitHeight() + { + compare(button.menu.height, button.menu.implicitHeight); + verify(button.menu.height > 0); + } - const fileDetailsItem = menu.itemAt(0); - verify(fileDetailsItem); - mouseClick(fileDetailsItem); + function openMenu() + { + mouseClick(button); + tryCompare(button.menu, "opened", true); + } - compare(fileDetailsRequestedSpy.count, 1); - compare(fileDetailsRequestedSpy.signalArguments[0][0], "/sync/folder/file.txt"); - } + function test_buttonOpensMenu() + { + verify(!button.menu.opened); + openMenu(); + verify(button.menu.opened); + } + + function test_fileDetailsItemEmitsCapturedPath() + { + openMenu(); - function test_fileActionsItemEmitsCapturedPath() - { - menu.popup(); - tryCompare(menu, "opened", true); + const fileDetailsItem = button.menu.itemAt(0); + verify(fileDetailsItem); + mouseClick(fileDetailsItem); - const fileActionsItem = menu.itemAt(1); - verify(fileActionsItem); - mouseClick(fileActionsItem); + compare(fileDetailsRequestedSpy.count, 1); + compare(fileDetailsRequestedSpy.signalArguments[0][0], "/sync/folder/file.txt"); + } + + function test_fileActionsItemEmitsCapturedPath() + { + openMenu(); - compare(fileActionsRequestedSpy.count, 1); - compare(fileActionsRequestedSpy.signalArguments[0][0], "/sync/folder/file.txt"); + const fileActionsItem = button.menu.itemAt(1); + verify(fileActionsItem); + mouseClick(fileActionsItem); + + compare(fileActionsRequestedSpy.count, 1); + compare(fileActionsRequestedSpy.signalArguments[0][0], "/sync/folder/file.txt"); + } } } From ec80ef5b420221d2a523d95577bde23c386b7e01 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 29 Jul 2026 19:21:26 +0800 Subject: [PATCH 008/104] fix(activity): Fix activity file menu sizing Signed-off-by: Claudio Cambra --- src/gui/activity/qml/ActivityFileMenu.qml | 18 +++++++----------- .../activityfilemenu/testactivityfilemenu.qml | 10 +++++++--- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/gui/activity/qml/ActivityFileMenu.qml b/src/gui/activity/qml/ActivityFileMenu.qml index d87a31b447a0a..465648d92e05b 100644 --- a/src/gui/activity/qml/ActivityFileMenu.qml +++ b/src/gui/activity/qml/ActivityFileMenu.qml @@ -7,7 +7,7 @@ import QtQuick import QtQuick.Controls import "../../tray" -AutoSizingMenu { +Menu { id: root required property string filePath @@ -18,16 +18,15 @@ AutoSizingMenu { signal fileActionsRequested(string filePath) closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape - height: implicitHeight - Action { + property Action fileDetailsAction: Action { id: fileDetailsAction objectName: "fileDetailsAction" text: qsTr("File details") onTriggered: root.fileDetailsRequested(root.filePath) } - Action { + property Action fileActionsAction: Action { id: fileActionsAction objectName: "fileActionsAction" text: qsTr("File actions") @@ -35,16 +34,13 @@ AutoSizingMenu { } MenuItem { - action: fileDetailsAction - font.pixelSize: root.itemFontPixelSize - hoverEnabled: true + id: fileDetailsMenuItem + action: root.fileDetailsAction } MenuItem { - action: fileActionsAction + id: fileActionsMenuItem + action: root.fileActionsAction visible: root.serverHasIntegration - height: visible ? implicitHeight : 0 - font.pixelSize: root.itemFontPixelSize - hoverEnabled: true } } diff --git a/test/qml/activityfilemenu/testactivityfilemenu.qml b/test/qml/activityfilemenu/testactivityfilemenu.qml index 6274c0106c2bc..84dc2db2b2770 100644 --- a/test/qml/activityfilemenu/testactivityfilemenu.qml +++ b/test/qml/activityfilemenu/testactivityfilemenu.qml @@ -53,10 +53,14 @@ Item { fileActionsRequestedSpy.target = button; } - function test_usesFullImplicitHeight() + function test_contentFitsWithoutScrolling() { - compare(button.menu.height, button.menu.implicitHeight); - verify(button.menu.height > 0); + button.menu.popup(); + tryCompare(button.menu, "opened", true); + compare(button.menu.count, 2); + const lastItem = button.menu.itemAt(button.menu.count - 1); + verify(lastItem); + verify(button.menu.availableHeight >= lastItem.y + lastItem.height); } function openMenu() From 15a300122989e1d6425dab0af6dce832eb57314a Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Mon, 27 Jul 2026 12:08:15 +0200 Subject: [PATCH 009/104] fix(gui): correctly link search QML module The UnifiedSearchResultsListModel needs to be available when linking to anything that needs Systray; so linking should be done on the nextcloud target instead of nextcloudCore to avoid future circular linking dependencies Signed-off-by: Jyrki Gadinger --- src/gui/CMakeLists.txt | 9 +++++---- src/gui/main.cpp | 3 +++ src/gui/owncloudgui.cpp | 3 --- src/gui/search/unifiedsearchresultslistmodel.h | 4 ++++ test/nextcloud_add_test.cmake | 9 +++++++++ 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index eb40bf61f527c..15f1d6b266088 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -577,9 +577,6 @@ target_link_libraries(nextcloudCore Qt::QuickWidgets KF6::Archive KDAB::kdsingleapplication - - nextcloudGuiSearch - nextcloudGuiSearchplugin ) if(KF6GuiAddons_FOUND) @@ -691,7 +688,11 @@ set_target_properties(nextcloud PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${BIN_OUTPUT_DIRECTORY} ) -target_link_libraries(nextcloud PRIVATE nextcloudCore) +target_link_libraries(nextcloud PRIVATE + nextcloudGuiSearch + nextcloudGuiSearchplugin + nextcloudCore +) if(TARGET PkgConfig::CLOUDPROVIDERS) message("Building with libcloudproviderssupport") diff --git a/src/gui/main.cpp b/src/gui/main.cpp index 04b148f3d39f2..1e022f68bc82a 100644 --- a/src/gui/main.cpp +++ b/src/gui/main.cpp @@ -32,6 +32,9 @@ #include #include #include +#include + +Q_IMPORT_QML_PLUGIN(com_nextcloud_desktopclient_searchPlugin) using namespace OCC; diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index 6e6f60619e625..fe13b18a3d7f3 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -35,7 +35,6 @@ #include "activity/sortedactivitylistmodel.h" #include "activity/syncstatussummary.h" #include "tray/trayaccountappsmodel.h" -#include "search/unifiedsearchresultslistmodel.h" #include "integration/fileactionsmodel.h" #include "governance/applygovernancelabel.h" #include "governance/deletegovernancelabel.h" @@ -167,7 +166,6 @@ ownCloudGui::ownCloudGui(Application *parent) qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "QAbstractItemModel", "QAbstractItemModel"); qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "activity", "Activity"); qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "talkNotificationData", "TalkNotificationData"); - qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "UnifiedSearchResultsListModel", "UnifiedSearchResultsListModel"); qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "userStatus", "Access to Status enum"); qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "sharee", "Access to Type enum"); qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "ClientSideEncryptionTokenSelector", "Access to the certificate selector"); @@ -177,7 +175,6 @@ ownCloudGui::ownCloudGui(Application *parent) qRegisterMetaType("ActivityListModel*"); qRegisterMetaType("SyncStatusSummary*"); - qRegisterMetaType("UnifiedSearchResultsListModel*"); qRegisterMetaType("UserStatus"); qRegisterMetaType("SharePtr"); qRegisterMetaType("ShareePtr"); diff --git a/src/gui/search/unifiedsearchresultslistmodel.h b/src/gui/search/unifiedsearchresultslistmodel.h index 48e5085b4de8f..0b0817344ab65 100644 --- a/src/gui/search/unifiedsearchresultslistmodel.h +++ b/src/gui/search/unifiedsearchresultslistmodel.h @@ -10,6 +10,7 @@ #include #include +#include namespace OCC { class AccountState; @@ -24,6 +25,9 @@ class UnifiedSearchResultsListModel : public QAbstractListModel { Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("created by Systray") + Q_PROPERTY(bool isSearchInProgress READ isSearchInProgress NOTIFY isSearchInProgressChanged) Q_PROPERTY(QString currentFetchMoreInProgressProviderId READ currentFetchMoreInProgressProviderId NOTIFY currentFetchMoreInProgressProviderIdChanged) diff --git a/test/nextcloud_add_test.cmake b/test/nextcloud_add_test.cmake index 489ba31c2587f..2dabea9011c8c 100644 --- a/test/nextcloud_add_test.cmake +++ b/test/nextcloud_add_test.cmake @@ -20,6 +20,9 @@ macro(nextcloud_build_test test_class) Qt::Test Qt::Quick Qt::Core5Compat + + nextcloudGuiSearch + nextcloudGuiSearchplugin ) if (WIN32) @@ -58,6 +61,9 @@ macro(nextcloud_add_test test_class) Qt::Test Qt::Quick Qt::Core5Compat + + nextcloudGuiSearch + nextcloudGuiSearchplugin ) if (WIN32) @@ -110,6 +116,9 @@ macro(nextcloud_add_benchmark test_class) Qt::Xml Qt::Network Qt::Core5Compat + + nextcloudGuiSearch + nextcloudGuiSearchplugin ) IF(BUILD_UPDATER) From 078fe5f73abee3aa359f22cf8d28bfff559fe7c6 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Wed, 1 Apr 2026 15:11:18 +0200 Subject: [PATCH 010/104] feat(sharing): add new QML module for the new sharing UI Signed-off-by: Jyrki Gadinger --- src/gui/CMakeLists.txt | 3 +++ src/gui/main.cpp | 1 + src/gui/sharing/CMakeLists.txt | 12 ++++++++++++ src/gui/sharing/ShareDialog.qml | 26 ++++++++++++++++++++++++++ src/gui/systray.cpp | 28 ++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+) create mode 100644 src/gui/sharing/CMakeLists.txt create mode 100644 src/gui/sharing/ShareDialog.qml diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 15f1d6b266088..06d34d5fd3e01 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -289,6 +289,7 @@ IF(BUILD_UPDATER) endif() add_subdirectory(search) +add_subdirectory(sharing) IF( APPLE ) list(APPEND client_SRCS cocoainitializer_mac.mm) @@ -691,6 +692,8 @@ set_target_properties(nextcloud PROPERTIES target_link_libraries(nextcloud PRIVATE nextcloudGuiSearch nextcloudGuiSearchplugin + nextcloudGuiSharing + nextcloudGuiSharingplugin nextcloudCore ) diff --git a/src/gui/main.cpp b/src/gui/main.cpp index 1e022f68bc82a..c32df8ce438b1 100644 --- a/src/gui/main.cpp +++ b/src/gui/main.cpp @@ -35,6 +35,7 @@ #include Q_IMPORT_QML_PLUGIN(com_nextcloud_desktopclient_searchPlugin) +Q_IMPORT_QML_PLUGIN(com_nextcloud_desktopclient_sharingPlugin) using namespace OCC; diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt new file mode 100644 index 0000000000000..8156514378ac1 --- /dev/null +++ b/src/gui/sharing/CMakeLists.txt @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: GPL-2.0-or-later + +add_library(nextcloudGuiSharing STATIC) +ecm_add_qml_module(nextcloudGuiSharing + URI com.nextcloud.desktopclient.sharing + GENERATE_PLUGIN_SOURCE + QML_FILES + ShareDialog.qml +) + +target_link_libraries(nextcloudGuiSharing PRIVATE nextcloudCore) diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml new file mode 100644 index 0000000000000..25196f3090071 --- /dev/null +++ b/src/gui/sharing/ShareDialog.qml @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Window +import QtQuick.Layouts +import QtQuick.Controls + +ApplicationWindow { + id: root + + property var accountState + property string localPath: "" + + LayoutMirroring.enabled: Application.layoutDirection === Qt.RightToLeft + LayoutMirroring.childrenInherit: true + + width: 400 + height: 500 + minimumWidth: 300 + minimumHeight: 300 + + title: qsTr("The new sharing dialog!") +} diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index fccdb9813d55a..257bde582e954 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -867,6 +867,34 @@ void Systray::createFileDetailsDialog(const QString &localPath) {"localPath", localPath}, }; + if (folder->accountState()->account()->capabilities().sharing().isAvailable()) { + // we have a server with the new unified sharing system, let's show the new fancy one + // TODO: reduce code duplication + + QQmlComponent fileDetailsDialog(trayEngine(), "com.nextcloud.desktopclient.sharing"_L1, "ShareDialog"_L1); + + if (!fileDetailsDialog.isError()) { + const auto createdDialog = fileDetailsDialog.createWithInitialProperties(initialProperties); + const auto dialog = qobject_cast(createdDialog); + + if(!dialog) { + qCWarning(lcSystray) << "File details dialog window resulted in creation of object that was not a window!"; + return; + } + + _fileDetailDialogs.append(dialog); + + dialog->show(); + dialog->raise(); + dialog->requestActivate(); + + } else { + qCWarning(lcSystray) << fileDetailsDialog.errorString(); + } + + return; + } + QQmlComponent fileDetailsDialog(trayEngine(), QStringLiteral("qrc:/qml/src/gui/filedetails/FileDetailsWindow.qml")); if (!fileDetailsDialog.isError()) { From 474dcb4cc60c1453c0cadb8fef89a05ae6e2e008 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Wed, 1 Apr 2026 17:11:47 +0200 Subject: [PATCH 011/104] feat(sharing): add prototype for new sharing UI still need to figure out how to style the widgets as close to the provided design as possible (or if we can trade it for a more "native" look and feel while still keeping the original vision in mind) also need to clarify when the input fields/properties will be visible Signed-off-by: Jyrki Gadinger --- src/gui/sharing/ShareDialog.qml | 132 +++++++++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 3 deletions(-) diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 25196f3090071..be9920ac7d9bb 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -8,19 +8,145 @@ import QtQuick.Window import QtQuick.Layouts import QtQuick.Controls +import com.nextcloud.desktopclient +import Style +import "qrc:/qml/src/gui/tray" + ApplicationWindow { id: root + visible: true property var accountState property string localPath: "" + property string shortLocalPath: "" - LayoutMirroring.enabled: Application.layoutDirection === Qt.RightToLeft - LayoutMirroring.childrenInherit: true + readonly property int windowRadius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius width: 400 height: 500 minimumWidth: 300 minimumHeight: 300 - title: qsTr("The new sharing dialog!") + title: qsTr("Share \"%1\"").arg(root.shortLocalPath) + + ColumnLayout { + id: windowContent + anchors.fill: parent + anchors.margins: Style.standardSpacing + + RowLayout { + id: windowHeader + Layout.fillWidth: true + spacing: Style.standardSpacing + + EnforcedPlainTextLabel { + id: headerLocalPath + text: qsTr("Share \"%1\"").arg(root.shortLocalPath) + elide: Text.ElideRight + font.bold: true + font.pixelSize: Style.pixelSize + color: palette.text + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + } + + Button { + id: settingsButton + flat: true + padding: Style.extraSmallSpacing + spacing: 0 + icon.source: "image://svgimage-custom-color/settings.svg/" + palette.windowText + icon.width: Style.extraSmallIconSize + icon.height: Style.extraSmallIconSize + Layout.alignment: Qt.AlignTop | Qt.AlignRight + Layout.rightMargin: Style.extraSmallSpacing + Layout.topMargin: Style.extraSmallSpacing + background: Rectangle { + color: "transparent" + radius: root.windowRadius + border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 + border.color: palette.dark + anchors.fill: parent + Layout.margins: Style.extraSmallSpacing + } + } + + Button { + id: closeButton + flat: true + padding: Style.extraSmallSpacing + spacing: 0 + icon.source: "image://svgimage-custom-color/close.svg/" + palette.windowText + icon.width: Style.extraSmallIconSize + icon.height: Style.extraSmallIconSize + Layout.alignment: Qt.AlignTop | Qt.AlignRight + Layout.rightMargin: Style.extraSmallSpacing + Layout.topMargin: Style.extraSmallSpacing + onClicked: root.close() + background: Rectangle { + color: "transparent" + radius: root.windowRadius + border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 + border.color: palette.dark + anchors.fill: parent + Layout.margins: Style.extraSmallSpacing + } + } + } + + TabBar { + id: bar + Layout.fillWidth: true + TabButton { + id: viewInvitedPeople + text: qsTr("Invited people") + } + TabButton { + id: viewAnyone + text: qsTr("Anyone") + } + } + + StackLayout { + currentIndex: bar.currentIndex + ColumnLayout { + Label { + text: qsTr("Add people") + } + Label { + text: qsTr("Participants") + } + TextArea { + Layout.fillWidth: true + placeholderText: qsTr("Note to recipients") + } + } + ColumnLayout { + Label { + text: qsTr("Anyone with the link") + } + TextArea { + Layout.fillWidth: true + placeholderText: qsTr("Note to recipients") + } + } + } + + RowLayout { + Button { + Layout.fillWidth: true + + text: qsTr("Copy link") + } + Button { + Layout.fillWidth: true + + text: qsTr("Send") + visible: !viewAnyone.checked + enabled: !viewAnyone.checked + } + } + } + + } From a6c997f30962f07b39a0914500a77d33609325f3 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Mon, 20 Apr 2026 16:34:51 +0200 Subject: [PATCH 012/104] feat(sharing): add new SharingManager class parse available types exposed through the capabilities in there Signed-off-by: Jyrki Gadinger --- src/gui/sharing/ShareDialog.qml | 1 + src/gui/systray.cpp | 31 +++---- src/libsync/CMakeLists.txt | 6 ++ src/libsync/account.cpp | 11 +++ src/libsync/account.h | 10 +++ src/libsync/capabilities.cpp | 1 - src/libsync/sharing/feature.cpp | 44 +++++++++ src/libsync/sharing/feature.h | 35 ++++++++ src/libsync/sharing/sharetype.cpp | 38 ++++++++ src/libsync/sharing/sharetype.h | 34 +++++++ src/libsync/sharing/sharingmanager.cpp | 120 +++++++++++++++++++++++++ src/libsync/sharing/sharingmanager.h | 54 +++++++++++ 12 files changed, 369 insertions(+), 16 deletions(-) create mode 100644 src/libsync/sharing/feature.cpp create mode 100644 src/libsync/sharing/feature.h create mode 100644 src/libsync/sharing/sharetype.cpp create mode 100644 src/libsync/sharing/sharetype.h create mode 100644 src/libsync/sharing/sharingmanager.cpp create mode 100644 src/libsync/sharing/sharingmanager.h diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index be9920ac7d9bb..8ace27b0adff0 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -17,6 +17,7 @@ ApplicationWindow { visible: true property var accountState + property QtObject sharingManager property string localPath: "" property string shortLocalPath: "" diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index 257bde582e954..00db09f44e1ef 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -865,32 +865,33 @@ void Systray::createFileDetailsDialog(const QString &localPath) const QVariantMap initialProperties{ {"accountState", QVariant::fromValue(folder->accountState())}, {"localPath", localPath}, + {"sharingManager", QVariant::fromValue(folder->accountState()->account()->sharing())}, }; - if (folder->accountState()->account()->capabilities().sharing().isAvailable()) { + if (folder->accountState()->account()->sharing()->isAvailable()) { // we have a server with the new unified sharing system, let's show the new fancy one // TODO: reduce code duplication QQmlComponent fileDetailsDialog(trayEngine(), "com.nextcloud.desktopclient.sharing"_L1, "ShareDialog"_L1); - if (!fileDetailsDialog.isError()) { - const auto createdDialog = fileDetailsDialog.createWithInitialProperties(initialProperties); - const auto dialog = qobject_cast(createdDialog); + if (fileDetailsDialog.isError()) { + qCWarning(lcSystray) << fileDetailsDialog.errorString(); + return; + } - if(!dialog) { - qCWarning(lcSystray) << "File details dialog window resulted in creation of object that was not a window!"; - return; - } + const auto createdDialog = fileDetailsDialog.createWithInitialProperties(initialProperties); + const auto dialog = qobject_cast(createdDialog); - _fileDetailDialogs.append(dialog); + if (!dialog) { + qCWarning(lcSystray) << "File details dialog window resulted in creation of object that was not a window!"; + return; + } - dialog->show(); - dialog->raise(); - dialog->requestActivate(); + _fileDetailDialogs.append(dialog); - } else { - qCWarning(lcSystray) << fileDetailsDialog.errorString(); - } + dialog->show(); + dialog->raise(); + dialog->requestActivate(); return; } diff --git a/src/libsync/CMakeLists.txt b/src/libsync/CMakeLists.txt index be060e8ce275e..3f5dcabb922d4 100644 --- a/src/libsync/CMakeLists.txt +++ b/src/libsync/CMakeLists.txt @@ -110,6 +110,12 @@ set(libsync_SRCS propagateuploadencrypted.cpp propagatedownloadencrypted.h propagatedownloadencrypted.cpp + sharing/feature.h + sharing/feature.cpp + sharing/sharetype.h + sharing/sharetype.cpp + sharing/sharingmanager.h + sharing/sharingmanager.cpp syncengine.h syncengine.cpp syncfileitem.h diff --git a/src/libsync/account.cpp b/src/libsync/account.cpp index a9ff3c9adda4c..6256d548fd002 100644 --- a/src/libsync/account.cpp +++ b/src/libsync/account.cpp @@ -53,6 +53,8 @@ #include "creds/abstractcredentials.h" +#include "sharing/sharingmanager.h" + using namespace QKeychain; namespace { @@ -98,6 +100,7 @@ AccountPtr Account::create() AccountPtr acc = AccountPtr(new Account); acc->setSharedThis(acc); acc->_e2e.setAccount(acc); + acc->_sharingManager = new Sharing::SharingManager{acc, acc.get()}; return acc; } @@ -778,6 +781,9 @@ void Account::setCapabilities(const QVariantMap &caps) trySetupPushNotifications(); trySetupClientStatusReporting(); + if (_sharingManager) { + _sharingManager->updateFromCapabilities(caps); + } } void Account::setupUserStatusConnector() @@ -1589,4 +1595,9 @@ void Account::updateServerHasIntegration() _serverHasIntegration = capabilities().serverHasClientIntegration(); } +Sharing::SharingManager *Account::sharing() const +{ + return _sharingManager; +} + } // namespace OCC diff --git a/src/libsync/account.h b/src/libsync/account.h index a7aa5b46b9004..22c9da5532277 100644 --- a/src/libsync/account.h +++ b/src/libsync/account.h @@ -13,6 +13,7 @@ #include "clientstatusreporting.h" #include "common/utility.h" #include "common/vfs.h" +#include "sharing/sharingmanager.h" #include "syncfileitem.h" #include "updatechannel.h" @@ -56,6 +57,10 @@ class PushNotifications; class UserStatusConnector; class SyncJournalDb; +namespace Sharing { + class SharingManager; +} + /** * @brief Reimplement this to handle SSL errors from libsync * @ingroup libsync @@ -440,6 +445,8 @@ class OWNCLOUDSYNC_EXPORT Account : public QObject [[nodiscard]] bool serverHasIntegration() const; + [[nodiscard]] Sharing::SharingManager *sharing() const; + public slots: /// Used when forgetting credentials void clearQNAMCache(); @@ -602,6 +609,9 @@ private slots: bool _serverHasValidSubscription = false; UpdateChannel _enterpriseUpdateChannel = UpdateChannel::Invalid; QByteArray _encryptionCertificateFingerprint; + + Sharing::SharingManager *_sharingManager = nullptr; + #ifdef BUILD_FILE_PROVIDER_MODULE QString _fileProviderDomainIdentifier; QByteArray _lastRootETag; // Runtime-only, not persisted diff --git a/src/libsync/capabilities.cpp b/src/libsync/capabilities.cpp index c1474e6d22ac9..c15d393e22821 100644 --- a/src/libsync/capabilities.cpp +++ b/src/libsync/capabilities.cpp @@ -19,7 +19,6 @@ namespace OCC { Q_LOGGING_CATEGORY(lcServerCapabilities, "nextcloud.sync.server.capabilities", QtInfoMsg) - Capabilities::Capabilities(const QVariantMap &capabilities) : _capabilities(capabilities) { diff --git a/src/libsync/sharing/feature.cpp b/src/libsync/sharing/feature.cpp new file mode 100644 index 0000000000000..031c5b64e3b4c --- /dev/null +++ b/src/libsync/sharing/feature.cpp @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + + +#include "feature.h" + +#include + +using namespace Qt::StringLiterals; + +using namespace OCC::Sharing; + +QSharedPointer Feature::fromCapability(const QVariantMap &object) +{ + const auto type = object.value("type"_L1).toString(); + const auto compatibleSourceTypes = object.value("compatible_source_types"_L1).toStringList(); + const auto compatibleRecipientTypes = object.value("compatible_recipient_types"_L1).toStringList(); + + return QSharedPointer{new Feature{type, compatibleSourceTypes, compatibleRecipientTypes}}; +} + + +Feature::Feature(const QString &type, const QStringList &compatibleSourceTypes, const QStringList &compatibleRecipientTypes) + : _type{type} + , _compatibleSourceTypes{compatibleSourceTypes} + , _compatibleRecipientTypes{compatibleRecipientTypes} +{} + +QString Feature::type() const +{ + return _type; +} + +QStringList Feature::compatibleSourceTypes() const +{ + return _compatibleSourceTypes; +} + +QStringList Feature::compatibleRecipientTypes() const +{ + return _compatibleRecipientTypes; +} diff --git a/src/libsync/sharing/feature.h b/src/libsync/sharing/feature.h new file mode 100644 index 0000000000000..6f1c53992f909 --- /dev/null +++ b/src/libsync/sharing/feature.h @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include +#include + +namespace OCC::Sharing { + +class Feature : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString type READ type) + +public: + [[nodiscard]] static QSharedPointer fromCapability(const QVariantMap &object); + + [[nodiscard]] QString type() const; + [[nodiscard]] QStringList compatibleSourceTypes() const; + [[nodiscard]] QStringList compatibleRecipientTypes() const; + +private: + QString _type; + QStringList _compatibleSourceTypes; + QStringList _compatibleRecipientTypes; + + explicit Feature(const QString &type, const QStringList &compatibleSourceTypes, const QStringList &compatibleRecipientTypes); +}; + +} + diff --git a/src/libsync/sharing/sharetype.cpp b/src/libsync/sharing/sharetype.cpp new file mode 100644 index 0000000000000..3c15c62c66dda --- /dev/null +++ b/src/libsync/sharing/sharetype.cpp @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + + +#include "sharetype.h" + +#include + +using namespace Qt::StringLiterals; + +using namespace OCC::Sharing; + +QSharedPointer ShareType::fromCapability(const QVariantMap &object) +{ + const auto type = object.value("type"_L1).toString(); + const auto displayName = object.value("displayName"_L1).toString(); + + auto shareType = new ShareType{type, displayName}; + + return QSharedPointer{shareType}; +} + +ShareType::ShareType(const QString &type, const QString &displayName) + : _type{type} + , _displayName{displayName} +{} + +QString ShareType::type() const +{ + return _type; +} + +QString ShareType::displayName() const +{ + return _displayName; +} diff --git a/src/libsync/sharing/sharetype.h b/src/libsync/sharing/sharetype.h new file mode 100644 index 0000000000000..03c8671345640 --- /dev/null +++ b/src/libsync/sharing/sharetype.h @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include +#include + +namespace OCC::Sharing { + +class ShareType : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString type READ type) + Q_PROPERTY(QString displayName READ displayName) + +public: + [[nodiscard]] static QSharedPointer fromCapability(const QVariantMap &object); + + [[nodiscard]] QString type() const; + [[nodiscard]] QString displayName() const; + +private: + QString _type; + QString _displayName; + + explicit ShareType(const QString &type, const QString &displayName); +}; + +} + diff --git a/src/libsync/sharing/sharingmanager.cpp b/src/libsync/sharing/sharingmanager.cpp new file mode 100644 index 0000000000000..e3ab1b453ede4 --- /dev/null +++ b/src/libsync/sharing/sharingmanager.cpp @@ -0,0 +1,120 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "sharingmanager.h" + +#include +#include + +using namespace Qt::StringLiterals; + +using namespace OCC::Sharing; + +namespace +{ +template +void parseTypes(const QVariantMap &capability, const QString &key, QMap> &typeMap) { + const auto propertyList = capability.value(key).toList(); + + typeMap.clear(); + for (const auto &typeObject : propertyList) { + const auto object = typeObject.toMap(); + auto typeInstance = T::fromCapability(object); + if (!typeInstance) { + continue; + } + + typeMap.insert(typeInstance->type(), typeInstance); + } +}; +} + +SharingManager::SharingManager(AccountPtr account, QObject *parent) + : QObject{parent} + , _account{account} +{ + +} + +void SharingManager::updateFromCapabilities(const QVariantMap &capabilities) +{ + if (const auto property = capabilities.value("sharing"_L1); !property.isValid()) { + // no sharing capability present, we're done here + setAvailable(false); + return; + } + setAvailable(true); + + const auto capability = capabilities.value("sharing"_L1).toMap(); + + if (const auto property = capability.value("api_versions"_L1); property.isValid() && property.canConvert()) { + _apiVersions = property.value(); + } + + parseTypes(capability, "source_types"_L1, _sourceTypes); + parseTypes(capability, "recipient_types"_L1, _recipientTypes); + parseTypes(capability, "features"_L1, _features); +} + +QList> SharingManager::availableFeatures(const QStringList &sourceTypes, const QStringList &recipientTypes) const +{ + QList> features; + + const auto sourceTypesSet = QSet(sourceTypes.cbegin(), sourceTypes.cend()); + const auto recipientTypesSet = QSet(recipientTypes.cbegin(), recipientTypes.cend()); + + for (const auto &feature : std::as_const(_features)) { + if (const auto compatibleSourceTypes = feature->compatibleSourceTypes(); + !QSet(compatibleSourceTypes.cbegin(), compatibleSourceTypes.cend()).intersects(sourceTypesSet)) { + continue; + } + if (const auto compatibleRecipientTypes = feature->compatibleRecipientTypes(); + !QSet(compatibleRecipientTypes.cbegin(), compatibleRecipientTypes.cend()).intersects(sourceTypesSet)) { + continue; + } + + features.append(feature); + } + + return features; +} + +bool SharingManager::isFeatureAvailable(const QString &feature, const QStringList &sourceTypes, const QStringList &recipientTypes) const +{ + if (!_features.contains(feature)) { + return false; + } + const auto featureObject = _features.value(feature); + + const auto sourceTypesSet = QSet(sourceTypes.cbegin(), sourceTypes.cend()); + const auto recipientTypesSet = QSet(recipientTypes.cbegin(), recipientTypes.cend()); + + if (const auto compatibleSourceTypes = featureObject->compatibleSourceTypes(); + !QSet(compatibleSourceTypes.cbegin(), compatibleSourceTypes.cend()).intersects(sourceTypesSet)) { + return false; + } + + if (const auto compatibleRecipientTypes = featureObject->compatibleRecipientTypes(); + !QSet(compatibleRecipientTypes.cbegin(), compatibleRecipientTypes.cend()).intersects(sourceTypesSet)) { + return false; + } + + return true; +} + +bool SharingManager::isAvailable() const +{ + return _available; +} + +void SharingManager::setAvailable(bool available) +{ + if (_available == available) { + return; + } + + _available = available; + Q_EMIT availableChanged(); +} diff --git a/src/libsync/sharing/sharingmanager.h b/src/libsync/sharing/sharingmanager.h new file mode 100644 index 0000000000000..73278d6cee107 --- /dev/null +++ b/src/libsync/sharing/sharingmanager.h @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include + +#include "accountfwd.h" + +#include "sharetype.h" +#include "feature.h" + +namespace OCC::Sharing { + +class SharingManager : public QObject +{ + Q_OBJECT + + Q_PROPERTY(bool available READ isAvailable NOTIFY availableChanged) + +public: + explicit SharingManager(AccountPtr account, QObject *parent = nullptr); + + void updateFromCapabilities(const QVariantMap &capabilities); + + [[nodiscard]] Q_INVOKABLE QList> availableFeatures(const QStringList &sourceTypes, const QStringList &recipientTypes) const; + [[nodiscard]] Q_INVOKABLE bool isFeatureAvailable(const QString &feature, const QStringList &sourceTypes, const QStringList &recipientTypes) const; + + /** + * \brief Whether the new sharing system is available (announced via capabilities). + */ + [[nodiscard]] bool isAvailable() const; + +Q_SIGNALS: + void availableChanged(); + +private: + AccountPtr _account; + + bool _available = false; + + QStringList _apiVersions; + QMap> _sourceTypes; + QMap> _recipientTypes; + QMap> _features; + + void setAvailable(bool available); +}; + +} + From 4029d7458ccabcf1ff95a4d413e2c6babf82557d Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Mon, 20 Apr 2026 16:37:31 +0200 Subject: [PATCH 013/104] feat(sharing): add view for share settings Signed-off-by: Jyrki Gadinger --- src/gui/sharing/ShareDialog.qml | 293 ++++++++++++++++++++++---------- 1 file changed, 199 insertions(+), 94 deletions(-) diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 8ace27b0adff0..cc0c5d6de3be5 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -3,6 +3,8 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Window import QtQuick.Layouts @@ -30,124 +32,227 @@ ApplicationWindow { title: qsTr("Share \"%1\"").arg(root.shortLocalPath) - ColumnLayout { - id: windowContent + StackView { + id: stack + initialItem: mainView anchors.fill: parent - anchors.margins: Style.standardSpacing - - RowLayout { - id: windowHeader - Layout.fillWidth: true - spacing: Style.standardSpacing - - EnforcedPlainTextLabel { - id: headerLocalPath - text: qsTr("Share \"%1\"").arg(root.shortLocalPath) - elide: Text.ElideRight - font.bold: true - font.pixelSize: Style.pixelSize - color: palette.text + } + + Component { + id: mainView + + ColumnLayout { + id: windowContent + anchors.fill: parent + anchors.margins: Style.standardSpacing + + RowLayout { + id: windowHeader Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft - } + spacing: Style.standardSpacing - Button { - id: settingsButton - flat: true - padding: Style.extraSmallSpacing - spacing: 0 - icon.source: "image://svgimage-custom-color/settings.svg/" + palette.windowText - icon.width: Style.extraSmallIconSize - icon.height: Style.extraSmallIconSize - Layout.alignment: Qt.AlignTop | Qt.AlignRight - Layout.rightMargin: Style.extraSmallSpacing - Layout.topMargin: Style.extraSmallSpacing - background: Rectangle { - color: "transparent" - radius: root.windowRadius - border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 - border.color: palette.dark - anchors.fill: parent - Layout.margins: Style.extraSmallSpacing + EnforcedPlainTextLabel { + id: headerLocalPath + text: qsTr("Share \"%1\"").arg(root.shortLocalPath) + elide: Text.ElideRight + font.bold: true + font.pixelSize: Style.pixelSize + color: palette.text + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft } - } - Button { - id: closeButton - flat: true - padding: Style.extraSmallSpacing - spacing: 0 - icon.source: "image://svgimage-custom-color/close.svg/" + palette.windowText - icon.width: Style.extraSmallIconSize - icon.height: Style.extraSmallIconSize - Layout.alignment: Qt.AlignTop | Qt.AlignRight - Layout.rightMargin: Style.extraSmallSpacing - Layout.topMargin: Style.extraSmallSpacing - onClicked: root.close() - background: Rectangle { - color: "transparent" - radius: root.windowRadius - border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 - border.color: palette.dark - anchors.fill: parent - Layout.margins: Style.extraSmallSpacing + Button { + id: settingsButton + flat: true + padding: Style.extraSmallSpacing + spacing: 0 + icon.source: "image://svgimage-custom-color/settings.svg/" + palette.windowText + icon.width: Style.extraSmallIconSize + icon.height: Style.extraSmallIconSize + Layout.alignment: Qt.AlignTop | Qt.AlignRight + Layout.rightMargin: Style.extraSmallSpacing + Layout.topMargin: Style.extraSmallSpacing + background: Rectangle { + color: "transparent" + radius: root.windowRadius + border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 + border.color: palette.dark + anchors.fill: parent + Layout.margins: Style.extraSmallSpacing + } + + onClicked: stack.push(settingsView) } - } - } - TabBar { - id: bar - Layout.fillWidth: true - TabButton { - id: viewInvitedPeople - text: qsTr("Invited people") - } - TabButton { - id: viewAnyone - text: qsTr("Anyone") + Button { + id: closeButton + flat: true + padding: Style.extraSmallSpacing + spacing: 0 + icon.source: "image://svgimage-custom-color/close.svg/" + palette.windowText + icon.width: Style.extraSmallIconSize + icon.height: Style.extraSmallIconSize + Layout.alignment: Qt.AlignTop | Qt.AlignRight + Layout.rightMargin: Style.extraSmallSpacing + Layout.topMargin: Style.extraSmallSpacing + background: Rectangle { + color: "transparent" + radius: root.windowRadius + border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 + border.color: palette.dark + anchors.fill: parent + Layout.margins: Style.extraSmallSpacing + } + + onClicked: root.close() + } } - } - StackLayout { - currentIndex: bar.currentIndex - ColumnLayout { - Label { - text: qsTr("Add people") + TabBar { + id: bar + Layout.fillWidth: true + TabButton { + id: viewInvitedPeople + text: qsTr("Invited people") } - Label { - text: qsTr("Participants") + TabButton { + id: viewAnyone + text: qsTr("Anyone") } - TextArea { - Layout.fillWidth: true - placeholderText: qsTr("Note to recipients") + } + + StackLayout { + currentIndex: bar.currentIndex + ColumnLayout { + Label { + text: qsTr("Add people") + } + Label { + text: qsTr("Participants") + } + TextArea { + Layout.fillWidth: true + placeholderText: qsTr("Note to recipients") + } + } + ColumnLayout { + Label { + text: qsTr("Anyone with the link") + } + TextArea { + Layout.fillWidth: true + placeholderText: qsTr("Note to recipients") + } } } - ColumnLayout { - Label { - text: qsTr("Anyone with the link") + + RowLayout { + Button { + Layout.fillWidth: true + + text: qsTr("Copy link") } - TextArea { + Button { Layout.fillWidth: true - placeholderText: qsTr("Note to recipients") + + text: qsTr("Send") + visible: !viewAnyone.checked + enabled: !viewAnyone.checked } } } + } + + Component { + id: settingsView + + ColumnLayout { + id: windowContent + anchors.fill: parent + anchors.margins: Style.standardSpacing - RowLayout { - Button { + RowLayout { + id: windowHeader Layout.fillWidth: true + spacing: Style.standardSpacing - text: qsTr("Copy link") + + Button { + id: backButton + flat: true + padding: Style.extraSmallSpacing + spacing: 0 + icon.source: "image://svgimage-custom-color/confirm.svg/" + palette.windowText // TODO: back button icon! + icon.width: Style.extraSmallIconSize + icon.height: Style.extraSmallIconSize + Layout.alignment: Qt.AlignTop | Qt.AlignRight + Layout.rightMargin: Style.extraSmallSpacing + Layout.topMargin: Style.extraSmallSpacing + background: Rectangle { + color: "transparent" + radius: root.windowRadius + border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 + border.color: palette.dark + anchors.fill: parent + Layout.margins: Style.extraSmallSpacing + } + + onClicked: stack.pop() + } + + ColumnLayout { + EnforcedPlainTextLabel { + id: headerSettings + text: qsTr("Sharing settings") + elide: Text.ElideRight + font.bold: true + font.pixelSize: Style.pixelSize + color: palette.text + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + } + EnforcedPlainTextLabel { + id: headerSettingsLocalPath + text: root.shortLocalPath + elide: Text.ElideRight + font.bold: false + font.pixelSize: Style.pixelSize + color: palette.text + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + } + } + + Button { + id: closeButton + flat: true + padding: Style.extraSmallSpacing + spacing: 0 + icon.source: "image://svgimage-custom-color/close.svg/" + palette.windowText + icon.width: Style.extraSmallIconSize + icon.height: Style.extraSmallIconSize + Layout.alignment: Qt.AlignTop | Qt.AlignRight + Layout.rightMargin: Style.extraSmallSpacing + Layout.topMargin: Style.extraSmallSpacing + background: Rectangle { + color: "transparent" + radius: root.windowRadius + border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 + border.color: palette.dark + anchors.fill: parent + Layout.margins: Style.extraSmallSpacing + } + + onClicked: root.close() + } } - Button { - Layout.fillWidth: true - text: qsTr("Send") - visible: !viewAnyone.checked - enabled: !viewAnyone.checked + ColumnLayout { + Label { + text: qsTr("Show files in grid view") + } } } } - - } From 56b2400a741b1b9965162c664e0d858635ef2829 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Mon, 20 Apr 2026 17:36:18 +0200 Subject: [PATCH 014/104] feat(sharing): split UI parts into separate components Signed-off-by: Jyrki Gadinger --- src/gui/sharing/CMakeLists.txt | 2 + src/gui/sharing/MainPage.qml | 88 +++++++++ src/gui/sharing/SettingsPage.qml | 38 ++++ src/gui/sharing/ShareDialog.qml | 312 +++++++++++-------------------- 4 files changed, 234 insertions(+), 206 deletions(-) create mode 100644 src/gui/sharing/MainPage.qml create mode 100644 src/gui/sharing/SettingsPage.qml diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index 8156514378ac1..b14050687c27b 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -6,6 +6,8 @@ ecm_add_qml_module(nextcloudGuiSharing URI com.nextcloud.desktopclient.sharing GENERATE_PLUGIN_SOURCE QML_FILES + MainPage.qml + SettingsPage.qml ShareDialog.qml ) diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml new file mode 100644 index 0000000000000..6ed46528ba843 --- /dev/null +++ b/src/gui/sharing/MainPage.qml @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Window +import QtQuick.Layouts +import QtQuick.Controls + +import com.nextcloud.desktopclient +import Style + +Page { + id: root + + property var accountState + property QtObject sharingManager + property string localPath: "" + property string shortLocalPath: "" + + title: qsTr("Share \"%1\"").arg(root.shortLocalPath) + + ColumnLayout { + id: windowContent + anchors.fill: parent + anchors.margins: Style.standardSpacing + + // TODO: the contents should be presented through a viewmodel + + RowLayout { + TabBar { + id: bar + Layout.fillWidth: true + TabButton { + id: viewInvitedPeople + text: qsTr("Invited people") + } + TabButton { + id: viewAnyone + text: qsTr("Anyone") + } + } + } + + StackLayout { + currentIndex: bar.currentIndex + ColumnLayout { + Label { + text: qsTr("Add people") + } + Label { + text: qsTr("Participants") + } + TextArea { + Layout.fillWidth: true + placeholderText: qsTr("Note to recipients") + } + } + ColumnLayout { + Label { + text: qsTr("Anyone with the link") + } + TextArea { + Layout.fillWidth: true + placeholderText: qsTr("Note to recipients") + } + } + } + + RowLayout { + Button { + Layout.fillWidth: true + + text: qsTr("Copy link") + } + Button { + Layout.fillWidth: true + + text: qsTr("Send") + visible: !viewAnyone.checked + enabled: !viewAnyone.checked + } + } + } +} diff --git a/src/gui/sharing/SettingsPage.qml b/src/gui/sharing/SettingsPage.qml new file mode 100644 index 0000000000000..b6a0dcbb99e90 --- /dev/null +++ b/src/gui/sharing/SettingsPage.qml @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Window +import QtQuick.Layouts +import QtQuick.Controls + +import com.nextcloud.desktopclient +import Style + +Page { + id: root + + property var accountState + property QtObject sharingManager + property string localPath: "" + property string shortLocalPath: "" + + title: qsTr("Sharing settings") + + ColumnLayout { + id: windowContent + anchors.fill: parent + anchors.margins: Style.standardSpacing + + ColumnLayout { + // TODO: these should be presented through a viewmodel + Label { + text: qsTr("Show files in grid view") + } + } + } +} diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index cc0c5d6de3be5..8c1c931828b0a 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -21,7 +21,7 @@ ApplicationWindow { property var accountState property QtObject sharingManager property string localPath: "" - property string shortLocalPath: "" + property string shortLocalPath: root.localPath.split("/").reverse()[0] readonly property int windowRadius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius @@ -30,229 +30,129 @@ ApplicationWindow { minimumWidth: 300 minimumHeight: 300 - title: qsTr("Share \"%1\"").arg(root.shortLocalPath) - - StackView { - id: stack - initialItem: mainView - anchors.fill: parent - } + title: mainPage.title + + header: RowLayout { + // TODO: extract this to a shared component + id: windowHeader + spacing: Style.standardSpacing + anchors.margins: Style.standardSpacing // TODO: this doesn't seem to do anything + + Button { + id: backButton + flat: true + padding: Style.extraSmallSpacing + spacing: 0 + icon.source: "image://svgimage-custom-color/confirm.svg/" + palette.windowText // TODO: back button icon! + icon.width: Style.extraSmallIconSize + icon.height: Style.extraSmallIconSize + Layout.alignment: Qt.AlignTop | Qt.AlignRight + Layout.rightMargin: Style.extraSmallSpacing + Layout.topMargin: Style.extraSmallSpacing + background: Rectangle { + color: "transparent" + radius: root.windowRadius + border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 + border.color: palette.dark + anchors.fill: parent + Layout.margins: Style.extraSmallSpacing + } - Component { - id: mainView + onClicked: stack.pop() + visible: stack.depth > 1 + } ColumnLayout { - id: windowContent - anchors.fill: parent - anchors.margins: Style.standardSpacing - - RowLayout { - id: windowHeader + EnforcedPlainTextLabel { + id: headerSettings + text: stack.currentItem.title + elide: Text.ElideRight + font.bold: true + font.pixelSize: Style.pixelSize + color: palette.text Layout.fillWidth: true - spacing: Style.standardSpacing - - EnforcedPlainTextLabel { - id: headerLocalPath - text: qsTr("Share \"%1\"").arg(root.shortLocalPath) - elide: Text.ElideRight - font.bold: true - font.pixelSize: Style.pixelSize - color: palette.text - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft - } - - Button { - id: settingsButton - flat: true - padding: Style.extraSmallSpacing - spacing: 0 - icon.source: "image://svgimage-custom-color/settings.svg/" + palette.windowText - icon.width: Style.extraSmallIconSize - icon.height: Style.extraSmallIconSize - Layout.alignment: Qt.AlignTop | Qt.AlignRight - Layout.rightMargin: Style.extraSmallSpacing - Layout.topMargin: Style.extraSmallSpacing - background: Rectangle { - color: "transparent" - radius: root.windowRadius - border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 - border.color: palette.dark - anchors.fill: parent - Layout.margins: Style.extraSmallSpacing - } - - onClicked: stack.push(settingsView) - } - - Button { - id: closeButton - flat: true - padding: Style.extraSmallSpacing - spacing: 0 - icon.source: "image://svgimage-custom-color/close.svg/" + palette.windowText - icon.width: Style.extraSmallIconSize - icon.height: Style.extraSmallIconSize - Layout.alignment: Qt.AlignTop | Qt.AlignRight - Layout.rightMargin: Style.extraSmallSpacing - Layout.topMargin: Style.extraSmallSpacing - background: Rectangle { - color: "transparent" - radius: root.windowRadius - border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 - border.color: palette.dark - anchors.fill: parent - Layout.margins: Style.extraSmallSpacing - } - - onClicked: root.close() - } + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft } - - TabBar { - id: bar + EnforcedPlainTextLabel { + id: headerSettingsLocalPath + text: root.shortLocalPath + elide: Text.ElideRight + font.bold: false + font.pixelSize: Style.pixelSize + color: palette.text Layout.fillWidth: true - TabButton { - id: viewInvitedPeople - text: qsTr("Invited people") - } - TabButton { - id: viewAnyone - text: qsTr("Anyone") - } + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + + visible: stack.depth > 1 } + } - StackLayout { - currentIndex: bar.currentIndex - ColumnLayout { - Label { - text: qsTr("Add people") - } - Label { - text: qsTr("Participants") - } - TextArea { - Layout.fillWidth: true - placeholderText: qsTr("Note to recipients") - } - } - ColumnLayout { - Label { - text: qsTr("Anyone with the link") - } - TextArea { - Layout.fillWidth: true - placeholderText: qsTr("Note to recipients") - } - } + Button { + id: settingsButton + flat: true + padding: Style.extraSmallSpacing + spacing: 0 + icon.source: "image://svgimage-custom-color/settings.svg/" + palette.windowText + icon.width: Style.extraSmallIconSize + icon.height: Style.extraSmallIconSize + Layout.alignment: Qt.AlignTop | Qt.AlignRight + Layout.rightMargin: Style.extraSmallSpacing + Layout.topMargin: Style.extraSmallSpacing + background: Rectangle { + color: "transparent" + radius: root.windowRadius + border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 + border.color: palette.dark + anchors.fill: parent + Layout.margins: Style.extraSmallSpacing } - RowLayout { - Button { - Layout.fillWidth: true + visible: stack.depth < 2 - text: qsTr("Copy link") - } - Button { - Layout.fillWidth: true + onClicked: stack.push(Qt.createComponent("com.nextcloud.desktopclient.sharing", "SettingsPage").createObject(root, { + accountState: root.accountState, + sharingManager: root.sharingManager, + shortLocalPath: root.shortLocalPath, + })) + } - text: qsTr("Send") - visible: !viewAnyone.checked - enabled: !viewAnyone.checked - } + Button { + id: closeButton + flat: true + padding: Style.extraSmallSpacing + spacing: 0 + icon.source: "image://svgimage-custom-color/close.svg/" + palette.windowText + icon.width: Style.extraSmallIconSize + icon.height: Style.extraSmallIconSize + Layout.alignment: Qt.AlignTop | Qt.AlignRight + Layout.rightMargin: Style.extraSmallSpacing + Layout.topMargin: Style.extraSmallSpacing + background: Rectangle { + color: "transparent" + radius: root.windowRadius + border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 + border.color: palette.dark + anchors.fill: parent + Layout.margins: Style.extraSmallSpacing } + + onClicked: root.close() } } - Component { - id: settingsView - - ColumnLayout { - id: windowContent - anchors.fill: parent - anchors.margins: Style.standardSpacing - - RowLayout { - id: windowHeader - Layout.fillWidth: true - spacing: Style.standardSpacing - - - Button { - id: backButton - flat: true - padding: Style.extraSmallSpacing - spacing: 0 - icon.source: "image://svgimage-custom-color/confirm.svg/" + palette.windowText // TODO: back button icon! - icon.width: Style.extraSmallIconSize - icon.height: Style.extraSmallIconSize - Layout.alignment: Qt.AlignTop | Qt.AlignRight - Layout.rightMargin: Style.extraSmallSpacing - Layout.topMargin: Style.extraSmallSpacing - background: Rectangle { - color: "transparent" - radius: root.windowRadius - border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 - border.color: palette.dark - anchors.fill: parent - Layout.margins: Style.extraSmallSpacing - } - - onClicked: stack.pop() - } - - ColumnLayout { - EnforcedPlainTextLabel { - id: headerSettings - text: qsTr("Sharing settings") - elide: Text.ElideRight - font.bold: true - font.pixelSize: Style.pixelSize - color: palette.text - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft - } - EnforcedPlainTextLabel { - id: headerSettingsLocalPath - text: root.shortLocalPath - elide: Text.ElideRight - font.bold: false - font.pixelSize: Style.pixelSize - color: palette.text - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft - } - } + StackView { + id: stack + anchors.fill: parent - Button { - id: closeButton - flat: true - padding: Style.extraSmallSpacing - spacing: 0 - icon.source: "image://svgimage-custom-color/close.svg/" + palette.windowText - icon.width: Style.extraSmallIconSize - icon.height: Style.extraSmallIconSize - Layout.alignment: Qt.AlignTop | Qt.AlignRight - Layout.rightMargin: Style.extraSmallSpacing - Layout.topMargin: Style.extraSmallSpacing - background: Rectangle { - color: "transparent" - radius: root.windowRadius - border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 - border.color: palette.dark - anchors.fill: parent - Layout.margins: Style.extraSmallSpacing - } + initialItem: mainPage + } - onClicked: root.close() - } - } + MainPage { + id: mainPage - ColumnLayout { - Label { - text: qsTr("Show files in grid view") - } - } - } + accountState: root.accountState + sharingManager: root.sharingManager + localPath: root.localPath + shortLocalPath: root.shortLocalPath } } From e8fbce100ca4271abfdda494539379d33ad092a4 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Mon, 20 Apr 2026 18:01:52 +0200 Subject: [PATCH 015/104] feat(sharing): prepare viewmodel for dynamic sharing items Signed-off-by: Jyrki Gadinger --- src/gui/sharing/CMakeLists.txt | 7 +++++++ src/gui/sharing/MainPage.qml | 12 ++++++++++++ src/gui/sharing/sharingmodel.cpp | 24 ++++++++++++++++++++++++ src/gui/sharing/sharingmodel.h | 28 ++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+) create mode 100644 src/gui/sharing/sharingmodel.cpp create mode 100644 src/gui/sharing/sharingmodel.h diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index b14050687c27b..d7ea10fc3abf6 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -2,6 +2,13 @@ # SPDX-License-Identifier: GPL-2.0-or-later add_library(nextcloudGuiSharing STATIC) + +target_sources(nextcloudGuiSharing + PRIVATE + sharingmodel.h + sharingmodel.cpp +) + ecm_add_qml_module(nextcloudGuiSharing URI com.nextcloud.desktopclient.sharing GENERATE_PLUGIN_SOURCE diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index 6ed46528ba843..db8c679d69427 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -23,6 +23,10 @@ Page { title: qsTr("Share \"%1\"").arg(root.shortLocalPath) + SharingModel { + id: theModel + } + ColumnLayout { id: windowContent anchors.fill: parent @@ -58,6 +62,14 @@ Page { Layout.fillWidth: true placeholderText: qsTr("Note to recipients") } + Repeater { + model: theModel + + delegate: Label { + required property string label + text: label + } + } } ColumnLayout { Label { diff --git a/src/gui/sharing/sharingmodel.cpp b/src/gui/sharing/sharingmodel.cpp new file mode 100644 index 0000000000000..30540e6533edb --- /dev/null +++ b/src/gui/sharing/sharingmodel.cpp @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "sharingmodel.h" + +using namespace Qt::StringLiterals; +using namespace OCC::Gui::Sharing; + +int SharingModel::rowCount(const QModelIndex &parent) const +{ + return 2; +} + +QVariant SharingModel::data(const QModelIndex &index, int role) const +{ + return "test item"; +} + +QHash SharingModel::roleNames() const +{ + return { { LabelRole, "label"_ba} }; +}; diff --git a/src/gui/sharing/sharingmodel.h b/src/gui/sharing/sharingmodel.h new file mode 100644 index 0000000000000..1161f056511ab --- /dev/null +++ b/src/gui/sharing/sharingmodel.h @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include + +namespace OCC::Gui::Sharing { + +class SharingModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + +public: + enum Roles { + LabelRole = Qt::UserRole, + }; + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; +}; + +} From 0aaf039bb6319f3e52550c52ce5d1590fe63ece6 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Tue, 21 Apr 2026 10:31:09 +0200 Subject: [PATCH 016/104] feat(sharing): prototype dynamic view based on listmodel Signed-off-by: Jyrki Gadinger --- src/gui/sharing/CMakeLists.txt | 1 + src/gui/sharing/FieldDelegate.qml | 77 +++++++++++++++++++++++++++++++ src/gui/sharing/MainPage.qml | 27 +++++------ src/gui/sharing/sharingmodel.cpp | 22 +++++++-- src/gui/sharing/sharingmodel.h | 10 ++++ 5 files changed, 119 insertions(+), 18 deletions(-) create mode 100644 src/gui/sharing/FieldDelegate.qml diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index d7ea10fc3abf6..b5fb9a063317c 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -13,6 +13,7 @@ ecm_add_qml_module(nextcloudGuiSharing URI com.nextcloud.desktopclient.sharing GENERATE_PLUGIN_SOURCE QML_FILES + FieldDelegate.qml MainPage.qml SettingsPage.qml ShareDialog.qml diff --git a/src/gui/sharing/FieldDelegate.qml b/src/gui/sharing/FieldDelegate.qml new file mode 100644 index 0000000000000..6ddb8f4fc2e61 --- /dev/null +++ b/src/gui/sharing/FieldDelegate.qml @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Window +import QtQuick.Layouts +import QtQuick.Controls + +import com.nextcloud.desktopclient +import Style + +Loader { + id: instantiator + required property var modelData + + sourceComponent: switch (modelData.type) { + case SharingModel.Switch: + return switchComponent; + case SharingModel.TextField: + return textFieldComponent; + case SharingModel.TextArea: + return textAreaComponent; + default: + return unknownItem; + } + + Component { + id: switchComponent + RowLayout { + Label { + text: modelData.label + Layout.fillWidth: true + } + Switch { + } + } + } + + Component { + id: textFieldComponent + ColumnLayout { + Label { + text: modelData.label + } + TextField { + Layout.fillWidth: true + placeholderText: modelData.placeholder + } + } + } + + Component { + id: textAreaComponent + ColumnLayout { + Label { + text: modelData.label + } + TextArea { + Layout.fillWidth: true + placeholderText: modelData.placeholder + } + } + } + + Component { + id: unknownItem + RowLayout { + Label { + text: "unknown!" + } + } + } +} diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index db8c679d69427..37b7819d40bfa 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -51,26 +51,23 @@ Page { StackLayout { currentIndex: bar.currentIndex - ColumnLayout { - Label { - text: qsTr("Add people") - } - Label { - text: qsTr("Participants") - } - TextArea { - Layout.fillWidth: true - placeholderText: qsTr("Note to recipients") - } - Repeater { + ScrollView { + ScrollBar.vertical.policy: propertyList.contentHeight > propertyList.height ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff + contentWidth: availableWidth + rightPadding: ScrollBar.vertical.policy == ScrollBar.AlwaysOn ? ScrollBar.vertical.width + Style.standardSpacing : 0 + + ListView { + id: propertyList + clip: true + model: theModel - delegate: Label { - required property string label - text: label + delegate: FieldDelegate { + width: propertyList.contentItem.width } } } + ColumnLayout { Label { text: qsTr("Anyone with the link") diff --git a/src/gui/sharing/sharingmodel.cpp b/src/gui/sharing/sharingmodel.cpp index 30540e6533edb..e8016831bc1e9 100644 --- a/src/gui/sharing/sharingmodel.cpp +++ b/src/gui/sharing/sharingmodel.cpp @@ -10,15 +10,31 @@ using namespace OCC::Gui::Sharing; int SharingModel::rowCount(const QModelIndex &parent) const { - return 2; + return 7; } QVariant SharingModel::data(const QModelIndex &index, int role) const { - return "test item"; + switch (role) { + case LabelRole: + return u"Item number %1"_s.arg(QString::number(index.row())); + case PropertyRole: + return u"prop%1"_s.arg(QString::number(index.row())); + case TypeRole: + return static_cast(index.row() % 3); + case PlaceholderRole: + return u"Placeholder for row %1"_s.arg(QString::number(index.row())); + default: + return {}; + } } QHash SharingModel::roleNames() const { - return { { LabelRole, "label"_ba} }; + return { + { LabelRole, "label"_ba}, + { PropertyRole, "property"_ba}, + { TypeRole, "type"_ba}, + { PlaceholderRole, "placeholder"_ba}, + }; }; diff --git a/src/gui/sharing/sharingmodel.h b/src/gui/sharing/sharingmodel.h index 1161f056511ab..4e0aeda09060c 100644 --- a/src/gui/sharing/sharingmodel.h +++ b/src/gui/sharing/sharingmodel.h @@ -18,8 +18,18 @@ class SharingModel : public QAbstractListModel public: enum Roles { LabelRole = Qt::UserRole, + PropertyRole, + TypeRole, + PlaceholderRole, }; + enum FieldTypes { + Switch, + TextField, + TextArea, + }; + Q_ENUM(FieldTypes) + int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; From 0164f44f992507a0b506b6942f084b4cace6f203 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Tue, 21 Apr 2026 11:33:08 +0200 Subject: [PATCH 017/104] feat(sharing): pass sharingModel from main dialog Signed-off-by: Jyrki Gadinger --- src/gui/sharing/MainPage.qml | 7 ++----- src/gui/sharing/SettingsPage.qml | 21 ++++++++++++++------- src/gui/sharing/ShareDialog.qml | 6 ++++++ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index 37b7819d40bfa..f8ab136b80f3d 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -20,13 +20,10 @@ Page { property QtObject sharingManager property string localPath: "" property string shortLocalPath: "" + required property SharingModel sharingModel title: qsTr("Share \"%1\"").arg(root.shortLocalPath) - SharingModel { - id: theModel - } - ColumnLayout { id: windowContent anchors.fill: parent @@ -60,7 +57,7 @@ Page { id: propertyList clip: true - model: theModel + model: root.sharingModel delegate: FieldDelegate { width: propertyList.contentItem.width diff --git a/src/gui/sharing/SettingsPage.qml b/src/gui/sharing/SettingsPage.qml index b6a0dcbb99e90..c115e91a50ffe 100644 --- a/src/gui/sharing/SettingsPage.qml +++ b/src/gui/sharing/SettingsPage.qml @@ -20,18 +20,25 @@ Page { property QtObject sharingManager property string localPath: "" property string shortLocalPath: "" + required property SharingModel sharingModel title: qsTr("Sharing settings") - ColumnLayout { - id: windowContent + ScrollView { anchors.fill: parent - anchors.margins: Style.standardSpacing - ColumnLayout { - // TODO: these should be presented through a viewmodel - Label { - text: qsTr("Show files in grid view") + ScrollBar.vertical.policy: propertyList.contentHeight > propertyList.height ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff + contentWidth: availableWidth + rightPadding: ScrollBar.vertical.policy == ScrollBar.AlwaysOn ? ScrollBar.vertical.width + Style.standardSpacing : 0 + + ListView { + id: propertyList + clip: true + + model: sharingModel + + delegate: FieldDelegate { + width: propertyList.contentItem.width } } } diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 8c1c931828b0a..b61720fa6d91d 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -25,6 +25,10 @@ ApplicationWindow { readonly property int windowRadius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius + SharingModel { + id: sharingModel + } + width: 400 height: 500 minimumWidth: 300 @@ -113,6 +117,7 @@ ApplicationWindow { accountState: root.accountState, sharingManager: root.sharingManager, shortLocalPath: root.shortLocalPath, + sharingModel: sharingModel, })) } @@ -154,5 +159,6 @@ ApplicationWindow { sharingManager: root.sharingManager localPath: root.localPath shortLocalPath: root.shortLocalPath + sharingModel: sharingModel } } From 35a5b9d7054afcfdff60a71e592747838f4b6a41 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Tue, 21 Apr 2026 11:52:47 +0200 Subject: [PATCH 018/104] feat(sharing): filter viewmodel depending on main/settings view Signed-off-by: Jyrki Gadinger --- src/gui/sharing/CMakeLists.txt | 2 + src/gui/sharing/MainPage.qml | 5 ++- src/gui/sharing/SettingsPage.qml | 6 ++- src/gui/sharing/sharingfiltermodel.cpp | 61 ++++++++++++++++++++++++++ src/gui/sharing/sharingfiltermodel.h | 42 ++++++++++++++++++ src/gui/sharing/sharingmodel.cpp | 4 ++ src/gui/sharing/sharingmodel.h | 2 + 7 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 src/gui/sharing/sharingfiltermodel.cpp create mode 100644 src/gui/sharing/sharingfiltermodel.h diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index b5fb9a063317c..89d27b6ff77ed 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -5,6 +5,8 @@ add_library(nextcloudGuiSharing STATIC) target_sources(nextcloudGuiSharing PRIVATE + sharingfiltermodel.h + sharingfiltermodel.cpp sharingmodel.h sharingmodel.cpp ) diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index f8ab136b80f3d..57e1c44e89cfe 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -57,7 +57,10 @@ Page { id: propertyList clip: true - model: root.sharingModel + model: SharingFilterModel { + filterType: SharingFilterModel.General + sourceModel: root.sharingModel + } delegate: FieldDelegate { width: propertyList.contentItem.width diff --git a/src/gui/sharing/SettingsPage.qml b/src/gui/sharing/SettingsPage.qml index c115e91a50ffe..cc5507cd81a19 100644 --- a/src/gui/sharing/SettingsPage.qml +++ b/src/gui/sharing/SettingsPage.qml @@ -35,7 +35,11 @@ Page { id: propertyList clip: true - model: sharingModel + model: SharingFilterModel { + filterType: SharingFilterModel.Settings + sourceModel: root.sharingModel + } + delegate: FieldDelegate { width: propertyList.contentItem.width diff --git a/src/gui/sharing/sharingfiltermodel.cpp b/src/gui/sharing/sharingfiltermodel.cpp new file mode 100644 index 0000000000000..a1acfcf8e1fcc --- /dev/null +++ b/src/gui/sharing/sharingfiltermodel.cpp @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "sharingfiltermodel.h" +#include "sharingmodel.h" + +using namespace Qt::StringLiterals; +using namespace OCC::Gui::Sharing; + +SharingFilterModel::SharingFilterModel(QObject *parent) + : QSortFilterProxyModel{parent} +{} + + +SharingFilterModel::FilterType SharingFilterModel::filterType() const +{ + return _filterType; +} + +void SharingFilterModel::setFilterType(SharingFilterModel::FilterType filterType) +{ + if (_filterType == filterType) { + return; + } + + beginFilterChange(); + _filterType = filterType; + endFilterChange(); + Q_EMIT filterTypeChanged(); +} + +bool SharingFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const +{ + const auto model = qobject_cast(sourceModel()); + if (!model) { + // not a SharingModel, no need to filter + return true; + } + + const auto index = model->index(sourceRow, 0, sourceParent); + if (!index.isValid()) { + return true; + } + + auto propertyRole = model->data(index, SharingModel::PropertyRole).toString(); + bool filterProperty = false; + if (propertyRole == "prop2" || propertyRole == "prop3" || propertyRole == "prop5") { + filterProperty = true; + } + + switch (_filterType) { + case General: + return !filterProperty; + case Settings: + return filterProperty; + } + + return true; +} diff --git a/src/gui/sharing/sharingfiltermodel.h b/src/gui/sharing/sharingfiltermodel.h new file mode 100644 index 0000000000000..349ff4247a32d --- /dev/null +++ b/src/gui/sharing/sharingfiltermodel.h @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include + +namespace OCC::Gui::Sharing { + +class SharingFilterModel : public QSortFilterProxyModel +{ + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(FilterType filterType READ filterType WRITE setFilterType NOTIFY filterTypeChanged) + +public: + enum FilterType { + General, + Settings, + }; + Q_ENUM(FilterType) + + explicit SharingFilterModel(QObject *parent = nullptr); + + [[nodiscard]] FilterType filterType() const; + void setFilterType(FilterType filterType); + +Q_SIGNALS: + void filterTypeChanged(); + +protected: + [[nodiscard]] bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; + +private: + FilterType _filterType = FilterType::General; +}; + +} diff --git a/src/gui/sharing/sharingmodel.cpp b/src/gui/sharing/sharingmodel.cpp index e8016831bc1e9..a61fd604154b3 100644 --- a/src/gui/sharing/sharingmodel.cpp +++ b/src/gui/sharing/sharingmodel.cpp @@ -8,6 +8,10 @@ using namespace Qt::StringLiterals; using namespace OCC::Gui::Sharing; +SharingModel::SharingModel(QObject *parent) + : QAbstractListModel{parent} +{} + int SharingModel::rowCount(const QModelIndex &parent) const { return 7; diff --git a/src/gui/sharing/sharingmodel.h b/src/gui/sharing/sharingmodel.h index 4e0aeda09060c..ab8208da4e74a 100644 --- a/src/gui/sharing/sharingmodel.h +++ b/src/gui/sharing/sharingmodel.h @@ -30,6 +30,8 @@ class SharingModel : public QAbstractListModel }; Q_ENUM(FieldTypes) + explicit SharingModel(QObject *parent = nullptr); + int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; From ad258269c459eb31c3a93b49f7462b4bded1ed89 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Tue, 21 Apr 2026 12:28:37 +0200 Subject: [PATCH 019/104] feat(sharing): add accountstate property to viewmodel Signed-off-by: Jyrki Gadinger --- src/gui/sharing/ShareDialog.qml | 1 + src/gui/sharing/sharingmodel.cpp | 26 ++++++++++++++++++++++++++ src/gui/sharing/sharingmodel.h | 13 +++++++++++++ 3 files changed, 40 insertions(+) diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index b61720fa6d91d..e51a92932f1ae 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -27,6 +27,7 @@ ApplicationWindow { SharingModel { id: sharingModel + accountState: root.accountState } width: 400 diff --git a/src/gui/sharing/sharingmodel.cpp b/src/gui/sharing/sharingmodel.cpp index a61fd604154b3..9ad14fbd540f1 100644 --- a/src/gui/sharing/sharingmodel.cpp +++ b/src/gui/sharing/sharingmodel.cpp @@ -6,6 +6,7 @@ #include "sharingmodel.h" using namespace Qt::StringLiterals; +using namespace OCC; using namespace OCC::Gui::Sharing; SharingModel::SharingModel(QObject *parent) @@ -14,11 +15,19 @@ SharingModel::SharingModel(QObject *parent) int SharingModel::rowCount(const QModelIndex &parent) const { + if (!_accountState) { + return 0; + } + return 7; } QVariant SharingModel::data(const QModelIndex &index, int role) const { + if (!_accountState) { + return {}; + } + switch (role) { case LabelRole: return u"Item number %1"_s.arg(QString::number(index.row())); @@ -42,3 +51,20 @@ QHash SharingModel::roleNames() const { PlaceholderRole, "placeholder"_ba}, }; }; + +AccountState *SharingModel::accountState() const +{ + return _accountState; +} + +void SharingModel::setAccountState(AccountState *accountState) +{ + if (_accountState == accountState) { + return; + } + + beginResetModel(); + _accountState = accountState; + Q_EMIT accountStateChanged(); + endResetModel(); +} diff --git a/src/gui/sharing/sharingmodel.h b/src/gui/sharing/sharingmodel.h index ab8208da4e74a..6e360d9199842 100644 --- a/src/gui/sharing/sharingmodel.h +++ b/src/gui/sharing/sharingmodel.h @@ -8,6 +8,8 @@ #include #include +#include "accountstate.h" + namespace OCC::Gui::Sharing { class SharingModel : public QAbstractListModel @@ -15,6 +17,8 @@ class SharingModel : public QAbstractListModel Q_OBJECT QML_ELEMENT + Q_PROPERTY(AccountState *accountState READ accountState WRITE setAccountState NOTIFY accountStateChanged) + public: enum Roles { LabelRole = Qt::UserRole, @@ -35,6 +39,15 @@ class SharingModel : public QAbstractListModel int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; + + [[nodiscard]] AccountState *accountState() const; + void setAccountState(AccountState *accountState); + +Q_SIGNALS: + void accountStateChanged(); + +private: + AccountState *_accountState = nullptr; }; } From 175220376c64061a0c4b98421c96f3e936b4229d Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Tue, 21 Apr 2026 12:36:38 +0200 Subject: [PATCH 020/104] feat(sharing): load model data from features capabilities another prototype stage ;-) the proper field format for each feature is still TBD Signed-off-by: Jyrki Gadinger --- src/gui/sharing/sharingmodel.cpp | 14 ++++++++++++-- src/libsync/sharing/sharingmanager.cpp | 15 +++++++++++++++ src/libsync/sharing/sharingmanager.h | 4 ++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/gui/sharing/sharingmodel.cpp b/src/gui/sharing/sharingmodel.cpp index 9ad14fbd540f1..992e23bde5836 100644 --- a/src/gui/sharing/sharingmodel.cpp +++ b/src/gui/sharing/sharingmodel.cpp @@ -5,6 +5,8 @@ #include "sharingmodel.h" +#include "account.h" + using namespace Qt::StringLiterals; using namespace OCC; using namespace OCC::Gui::Sharing; @@ -19,7 +21,10 @@ int SharingModel::rowCount(const QModelIndex &parent) const return 0; } - return 7; + // TODO: cache this after setting accountstate + const auto sharing = accountState()->account()->sharing(); + const auto features = sharing->features().values(); + return features.size(); } QVariant SharingModel::data(const QModelIndex &index, int role) const @@ -28,9 +33,14 @@ QVariant SharingModel::data(const QModelIndex &index, int role) const return {}; } + // TODO: cache this after setting accountstate + const auto sharing = accountState()->account()->sharing(); + const auto features = sharing->features().values(); + const auto feature = features.at(index.row()); + switch (role) { case LabelRole: - return u"Item number %1"_s.arg(QString::number(index.row())); + return feature->type(); case PropertyRole: return u"prop%1"_s.arg(QString::number(index.row())); case TypeRole: diff --git a/src/libsync/sharing/sharingmanager.cpp b/src/libsync/sharing/sharingmanager.cpp index e3ab1b453ede4..aec0da93c2177 100644 --- a/src/libsync/sharing/sharingmanager.cpp +++ b/src/libsync/sharing/sharingmanager.cpp @@ -118,3 +118,18 @@ void SharingManager::setAvailable(bool available) _available = available; Q_EMIT availableChanged(); } + +QMap> SharingManager::sourceTypes() const +{ + return _sourceTypes; +} + +QMap> SharingManager::recipientTypes() const +{ + return _recipientTypes; +} + +QMap> SharingManager::features() const +{ + return _features; +} diff --git a/src/libsync/sharing/sharingmanager.h b/src/libsync/sharing/sharingmanager.h index 73278d6cee107..2987063ae6d21 100644 --- a/src/libsync/sharing/sharingmanager.h +++ b/src/libsync/sharing/sharingmanager.h @@ -34,6 +34,10 @@ class SharingManager : public QObject */ [[nodiscard]] bool isAvailable() const; + [[nodiscard]] QMap> sourceTypes() const; + [[nodiscard]] QMap> recipientTypes() const; + [[nodiscard]] QMap> features() const; + Q_SIGNALS: void availableChanged(); From 4a3a15d0fc8e5b3a7bd26ee53c88f6d67a566358 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Tue, 21 Apr 2026 13:21:22 +0200 Subject: [PATCH 021/104] feat(sharing): display features depending on expected source types also fix an issue with determining whether a feature is available Signed-off-by: Jyrki Gadinger --- src/gui/sharing/MainPage.qml | 61 ++++++++++++++------------ src/gui/sharing/SettingsPage.qml | 35 +++++++++------ src/gui/sharing/ShareDialog.qml | 5 +++ src/gui/sharing/sharingfiltermodel.cpp | 40 ++++++++++++++--- src/gui/sharing/sharingfiltermodel.h | 6 +++ src/libsync/sharing/sharingmanager.cpp | 8 ++-- 6 files changed, 103 insertions(+), 52 deletions(-) diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index 57e1c44e89cfe..c452bb28ba1b8 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -22,6 +22,8 @@ Page { property string shortLocalPath: "" required property SharingModel sharingModel + signal recipientTypesChanged(recipientTypes: list) + title: qsTr("Share \"%1\"").arg(root.shortLocalPath) ColumnLayout { @@ -29,8 +31,6 @@ Page { anchors.fill: parent anchors.margins: Style.standardSpacing - // TODO: the contents should be presented through a viewmodel - RowLayout { TabBar { id: bar @@ -46,35 +46,38 @@ Page { } } - StackLayout { - currentIndex: bar.currentIndex - ScrollView { - ScrollBar.vertical.policy: propertyList.contentHeight > propertyList.height ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff - contentWidth: availableWidth - rightPadding: ScrollBar.vertical.policy == ScrollBar.AlwaysOn ? ScrollBar.vertical.width + Style.standardSpacing : 0 - - ListView { - id: propertyList - clip: true - - model: SharingFilterModel { - filterType: SharingFilterModel.General - sourceModel: root.sharingModel - } - - delegate: FieldDelegate { - width: propertyList.contentItem.width - } + ScrollView { + Layout.fillHeight: true + Layout.fillWidth: true + + ScrollBar.vertical.policy: propertyList.contentHeight > propertyList.height ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff + contentWidth: availableWidth + contentHeight: availableHeight + rightPadding: ScrollBar.vertical.policy == ScrollBar.AlwaysOn ? ScrollBar.vertical.width + Style.standardSpacing : 0 + + ListView { + id: propertyList + clip: true + + model: SharingFilterModel { + filterType: SharingFilterModel.General + sourceModel: root.sharingModel + recipientTypes: [ + [ + // TODO: this won't be hardcoded in the future + "OC\\Core\\Sharing\\RecipientType\\UserShareRecipientType", + "OC\\Core\\Sharing\\RecipientType\\GroupShareRecipientType", + ], + [ + "OC\\Core\\Sharing\\RecipientType\\TokenShareRecipientType", + ] + ][bar.currentIndex] + + onRecipientTypesChanged: root.recipientTypesChanged(this.recipientTypes) } - } - ColumnLayout { - Label { - text: qsTr("Anyone with the link") - } - TextArea { - Layout.fillWidth: true - placeholderText: qsTr("Note to recipients") + delegate: FieldDelegate { + width: propertyList.contentItem.width } } } diff --git a/src/gui/sharing/SettingsPage.qml b/src/gui/sharing/SettingsPage.qml index cc5507cd81a19..3e0493b2d3f36 100644 --- a/src/gui/sharing/SettingsPage.qml +++ b/src/gui/sharing/SettingsPage.qml @@ -21,28 +21,37 @@ Page { property string localPath: "" property string shortLocalPath: "" required property SharingModel sharingModel + required property list recipientTypes title: qsTr("Sharing settings") - ScrollView { + ColumnLayout { + id: windowContent anchors.fill: parent + anchors.margins: Style.standardSpacing - ScrollBar.vertical.policy: propertyList.contentHeight > propertyList.height ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff - contentWidth: availableWidth - rightPadding: ScrollBar.vertical.policy == ScrollBar.AlwaysOn ? ScrollBar.vertical.width + Style.standardSpacing : 0 + ScrollView { + Layout.fillHeight: true + Layout.fillWidth: true - ListView { - id: propertyList - clip: true + ScrollBar.vertical.policy: propertyList.contentHeight > propertyList.height ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff + contentWidth: availableWidth + rightPadding: ScrollBar.vertical.policy == ScrollBar.AlwaysOn ? ScrollBar.vertical.width + Style.standardSpacing : 0 - model: SharingFilterModel { - filterType: SharingFilterModel.Settings - sourceModel: root.sharingModel - } + ListView { + id: propertyList + clip: true + + model: SharingFilterModel { + filterType: SharingFilterModel.Settings + sourceModel: root.sharingModel + recipientTypes: root.recipientTypes + } - delegate: FieldDelegate { - width: propertyList.contentItem.width + delegate: FieldDelegate { + width: propertyList.contentItem.width + } } } } diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index e51a92932f1ae..62bed1c109ed1 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -25,6 +25,8 @@ ApplicationWindow { readonly property int windowRadius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius + property list recipientTypes: [] + SharingModel { id: sharingModel accountState: root.accountState @@ -119,6 +121,7 @@ ApplicationWindow { sharingManager: root.sharingManager, shortLocalPath: root.shortLocalPath, sharingModel: sharingModel, + recipientTypes: recipientTypes, })) } @@ -161,5 +164,7 @@ ApplicationWindow { localPath: root.localPath shortLocalPath: root.shortLocalPath sharingModel: sharingModel + + onRecipientTypesChanged: (recipientTypes) => { root.recipientTypes = recipientTypes } } } diff --git a/src/gui/sharing/sharingfiltermodel.cpp b/src/gui/sharing/sharingfiltermodel.cpp index a1acfcf8e1fcc..b7c9516b82b6d 100644 --- a/src/gui/sharing/sharingfiltermodel.cpp +++ b/src/gui/sharing/sharingfiltermodel.cpp @@ -6,6 +6,8 @@ #include "sharingfiltermodel.h" #include "sharingmodel.h" +#include "account.h" + using namespace Qt::StringLiterals; using namespace OCC::Gui::Sharing; @@ -31,6 +33,23 @@ void SharingFilterModel::setFilterType(SharingFilterModel::FilterType filterType Q_EMIT filterTypeChanged(); } +QStringList SharingFilterModel::recipientTypes() const +{ + return _recipientTypes; +} + +void SharingFilterModel::setRecipientTypes(const QStringList &recipientTypes) +{ + if (_recipientTypes == recipientTypes) { + return; + } + + beginFilterChange(); + _recipientTypes = recipientTypes; + endFilterChange(); + Q_EMIT recipientTypesChanged(); +} + bool SharingFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const { const auto model = qobject_cast(sourceModel()); @@ -39,22 +58,31 @@ bool SharingFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sour return true; } + const auto accountState = model->accountState(); + if (!accountState) { + return true; + } + const auto index = model->index(sourceRow, 0, sourceParent); if (!index.isValid()) { return true; } - auto propertyRole = model->data(index, SharingModel::PropertyRole).toString(); - bool filterProperty = false; - if (propertyRole == "prop2" || propertyRole == "prop3" || propertyRole == "prop5") { - filterProperty = true; + const auto sharing = accountState->account()->sharing(); + const auto featureRole = model->data(index, SharingModel::LabelRole).toString(); // TODO: this will not be label + + if (!sharing->isFeatureAvailable(featureRole, {"OCA\\Files\\Sharing\\SourceType\\NodeShareSourceType"}, _recipientTypes)) { + // this feature is not available in general for these recipient types + return false; } + bool isAdvancedProperty = !featureRole.contains("NoteShareFeature"); + switch (_filterType) { case General: - return !filterProperty; + return !isAdvancedProperty; case Settings: - return filterProperty; + return isAdvancedProperty; } return true; diff --git a/src/gui/sharing/sharingfiltermodel.h b/src/gui/sharing/sharingfiltermodel.h index 349ff4247a32d..afb73ce94a56c 100644 --- a/src/gui/sharing/sharingfiltermodel.h +++ b/src/gui/sharing/sharingfiltermodel.h @@ -16,6 +16,7 @@ class SharingFilterModel : public QSortFilterProxyModel QML_ELEMENT Q_PROPERTY(FilterType filterType READ filterType WRITE setFilterType NOTIFY filterTypeChanged) + Q_PROPERTY(QStringList recipientTypes READ recipientTypes WRITE setRecipientTypes NOTIFY recipientTypesChanged) public: enum FilterType { @@ -29,14 +30,19 @@ class SharingFilterModel : public QSortFilterProxyModel [[nodiscard]] FilterType filterType() const; void setFilterType(FilterType filterType); + [[nodiscard]] QStringList recipientTypes() const; + void setRecipientTypes(const QStringList &recipientTypes); + Q_SIGNALS: void filterTypeChanged(); + void recipientTypesChanged(); protected: [[nodiscard]] bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; private: FilterType _filterType = FilterType::General; + QStringList _recipientTypes = {}; }; } diff --git a/src/libsync/sharing/sharingmanager.cpp b/src/libsync/sharing/sharingmanager.cpp index aec0da93c2177..de637b749554e 100644 --- a/src/libsync/sharing/sharingmanager.cpp +++ b/src/libsync/sharing/sharingmanager.cpp @@ -67,11 +67,11 @@ QList> SharingManager::availableFeatures(const QStringLi for (const auto &feature : std::as_const(_features)) { if (const auto compatibleSourceTypes = feature->compatibleSourceTypes(); - !QSet(compatibleSourceTypes.cbegin(), compatibleSourceTypes.cend()).intersects(sourceTypesSet)) { + !sourceTypesSet.intersects(QSet(compatibleSourceTypes.cbegin(), compatibleSourceTypes.cend()))) { continue; } if (const auto compatibleRecipientTypes = feature->compatibleRecipientTypes(); - !QSet(compatibleRecipientTypes.cbegin(), compatibleRecipientTypes.cend()).intersects(sourceTypesSet)) { + !recipientTypesSet.intersects(QSet(compatibleRecipientTypes.cbegin(), compatibleRecipientTypes.cend()))) { continue; } @@ -92,12 +92,12 @@ bool SharingManager::isFeatureAvailable(const QString &feature, const QStringLis const auto recipientTypesSet = QSet(recipientTypes.cbegin(), recipientTypes.cend()); if (const auto compatibleSourceTypes = featureObject->compatibleSourceTypes(); - !QSet(compatibleSourceTypes.cbegin(), compatibleSourceTypes.cend()).intersects(sourceTypesSet)) { + !sourceTypesSet.intersects(QSet(compatibleSourceTypes.cbegin(), compatibleSourceTypes.cend()))) { return false; } if (const auto compatibleRecipientTypes = featureObject->compatibleRecipientTypes(); - !QSet(compatibleRecipientTypes.cbegin(), compatibleRecipientTypes.cend()).intersects(sourceTypesSet)) { + !recipientTypesSet.intersects(QSet(compatibleRecipientTypes.cbegin(), compatibleRecipientTypes.cend()))) { return false; } From fffc76a0bbbc4b8cbaa0377ae78a5c45b964c920 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Tue, 21 Apr 2026 13:37:09 +0200 Subject: [PATCH 022/104] fix(sharing): correct background on hover in header buttons Signed-off-by: Jyrki Gadinger --- src/gui/sharing/ShareDialog.qml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 62bed1c109ed1..5a67ed8d45962 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -59,7 +59,7 @@ ApplicationWindow { background: Rectangle { color: "transparent" radius: root.windowRadius - border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 + border.width: parent.hovered ? Style.trayWindowBorderWidth : 0 border.color: palette.dark anchors.fill: parent Layout.margins: Style.extraSmallSpacing @@ -108,7 +108,7 @@ ApplicationWindow { background: Rectangle { color: "transparent" radius: root.windowRadius - border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 + border.width: parent.hovered ? Style.trayWindowBorderWidth : 0 border.color: palette.dark anchors.fill: parent Layout.margins: Style.extraSmallSpacing @@ -139,7 +139,7 @@ ApplicationWindow { background: Rectangle { color: "transparent" radius: root.windowRadius - border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 + border.width: parent.hovered ? Style.trayWindowBorderWidth : 0 border.color: palette.dark anchors.fill: parent Layout.margins: Style.extraSmallSpacing From 88f43f41c581a04438b9320eed32ad861e39bd6d Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Mon, 27 Apr 2026 16:40:15 +0200 Subject: [PATCH 023/104] feat(sharing): move recipient types chooser to dialog header Signed-off-by: Jyrki Gadinger --- src/gui/sharing/MainPage.qml | 35 +---- src/gui/sharing/ShareDialog.qml | 235 +++++++++++++++++++------------- 2 files changed, 145 insertions(+), 125 deletions(-) diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index c452bb28ba1b8..16f20059871f6 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -21,8 +21,7 @@ Page { property string localPath: "" property string shortLocalPath: "" required property SharingModel sharingModel - - signal recipientTypesChanged(recipientTypes: list) + required property list recipientTypes title: qsTr("Share \"%1\"").arg(root.shortLocalPath) @@ -31,21 +30,6 @@ Page { anchors.fill: parent anchors.margins: Style.standardSpacing - RowLayout { - TabBar { - id: bar - Layout.fillWidth: true - TabButton { - id: viewInvitedPeople - text: qsTr("Invited people") - } - TabButton { - id: viewAnyone - text: qsTr("Anyone") - } - } - } - ScrollView { Layout.fillHeight: true Layout.fillWidth: true @@ -62,18 +46,7 @@ Page { model: SharingFilterModel { filterType: SharingFilterModel.General sourceModel: root.sharingModel - recipientTypes: [ - [ - // TODO: this won't be hardcoded in the future - "OC\\Core\\Sharing\\RecipientType\\UserShareRecipientType", - "OC\\Core\\Sharing\\RecipientType\\GroupShareRecipientType", - ], - [ - "OC\\Core\\Sharing\\RecipientType\\TokenShareRecipientType", - ] - ][bar.currentIndex] - - onRecipientTypesChanged: root.recipientTypesChanged(this.recipientTypes) + recipientTypes: root.recipientTypes } delegate: FieldDelegate { @@ -92,8 +65,8 @@ Page { Layout.fillWidth: true text: qsTr("Send") - visible: !viewAnyone.checked - enabled: !viewAnyone.checked + visible: root.recipientTypes.length > 1 + enabled: root.recipientTypes.length > 1 } } } diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 5a67ed8d45962..0e20cda9eda25 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -25,7 +25,17 @@ ApplicationWindow { readonly property int windowRadius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius - property list recipientTypes: [] + property list recipientTypes: shareType.checkedButton?.recipientTypes || [] + + ButtonGroup { + id: shareType + + onClicked: (button) => { + console.log(`clicked on button ${button}`) + console.log(`root: ${root.recipientTypes}`) + // root.recipientTypes = button.recipientTypes + } + } SharingModel { id: sharingModel @@ -39,113 +49,151 @@ ApplicationWindow { title: mainPage.title - header: RowLayout { - // TODO: extract this to a shared component + header: ColumnLayout { id: windowHeader spacing: Style.standardSpacing anchors.margins: Style.standardSpacing // TODO: this doesn't seem to do anything - - Button { - id: backButton - flat: true - padding: Style.extraSmallSpacing - spacing: 0 - icon.source: "image://svgimage-custom-color/confirm.svg/" + palette.windowText // TODO: back button icon! - icon.width: Style.extraSmallIconSize - icon.height: Style.extraSmallIconSize - Layout.alignment: Qt.AlignTop | Qt.AlignRight - Layout.rightMargin: Style.extraSmallSpacing - Layout.topMargin: Style.extraSmallSpacing - background: Rectangle { - color: "transparent" - radius: root.windowRadius - border.width: parent.hovered ? Style.trayWindowBorderWidth : 0 - border.color: palette.dark - anchors.fill: parent - Layout.margins: Style.extraSmallSpacing + RowLayout { + // TODO: extract this to a shared component + + Button { + id: backButton + flat: true + padding: Style.extraSmallSpacing + spacing: 0 + icon.source: "image://svgimage-custom-color/confirm.svg/" + palette.windowText // TODO: back button icon! + icon.width: Style.extraSmallIconSize + icon.height: Style.extraSmallIconSize + Layout.alignment: Qt.AlignTop | Qt.AlignRight + Layout.rightMargin: Style.extraSmallSpacing + Layout.topMargin: Style.extraSmallSpacing + background: Rectangle { + color: "transparent" + radius: root.windowRadius + border.width: parent.hovered ? Style.trayWindowBorderWidth : 0 + border.color: palette.dark + anchors.fill: parent + Layout.margins: Style.extraSmallSpacing + } + + onClicked: stack.pop() + visible: stack.depth > 1 } - onClicked: stack.pop() - visible: stack.depth > 1 - } + ColumnLayout { + EnforcedPlainTextLabel { + id: headerSettings + text: stack.currentItem.title + elide: Text.ElideRight + font.bold: true + font.pixelSize: Style.pixelSize + color: palette.text + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + } + EnforcedPlainTextLabel { + id: headerSettingsLocalPath + text: root.shortLocalPath + elide: Text.ElideRight + font.bold: false + font.pixelSize: Style.pixelSize + color: palette.text + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + + visible: stack.depth > 1 + } + } - ColumnLayout { - EnforcedPlainTextLabel { - id: headerSettings - text: stack.currentItem.title - elide: Text.ElideRight - font.bold: true - font.pixelSize: Style.pixelSize - color: palette.text - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + Button { + id: settingsButton + flat: true + padding: Style.extraSmallSpacing + spacing: 0 + icon.source: "image://svgimage-custom-color/settings.svg/" + palette.windowText + icon.width: Style.extraSmallIconSize + icon.height: Style.extraSmallIconSize + Layout.alignment: Qt.AlignTop | Qt.AlignRight + Layout.rightMargin: Style.extraSmallSpacing + Layout.topMargin: Style.extraSmallSpacing + background: Rectangle { + color: "transparent" + radius: root.windowRadius + border.width: parent.hovered ? Style.trayWindowBorderWidth : 0 + border.color: palette.dark + anchors.fill: parent + Layout.margins: Style.extraSmallSpacing + } + + visible: stack.depth < 2 + + onClicked: stack.push(Qt.createComponent("com.nextcloud.desktopclient.sharing", "SettingsPage").createObject(root, { + accountState: root.accountState, + sharingManager: root.sharingManager, + shortLocalPath: root.shortLocalPath, + sharingModel: sharingModel, + recipientTypes: Qt.binding(() => root.recipientTypes), + })) } - EnforcedPlainTextLabel { - id: headerSettingsLocalPath - text: root.shortLocalPath - elide: Text.ElideRight - font.bold: false - font.pixelSize: Style.pixelSize - color: palette.text - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft - visible: stack.depth > 1 + Button { + id: closeButton + flat: true + padding: Style.extraSmallSpacing + spacing: 0 + icon.source: "image://svgimage-custom-color/close.svg/" + palette.windowText + icon.width: Style.extraSmallIconSize + icon.height: Style.extraSmallIconSize + Layout.alignment: Qt.AlignTop | Qt.AlignRight + Layout.rightMargin: Style.extraSmallSpacing + Layout.topMargin: Style.extraSmallSpacing + background: Rectangle { + color: "transparent" + radius: root.windowRadius + border.width: parent.hovered ? Style.trayWindowBorderWidth : 0 + border.color: palette.dark + anchors.fill: parent + Layout.margins: Style.extraSmallSpacing + } + + onClicked: root.close() } } - Button { - id: settingsButton - flat: true - padding: Style.extraSmallSpacing - spacing: 0 - icon.source: "image://svgimage-custom-color/settings.svg/" + palette.windowText - icon.width: Style.extraSmallIconSize - icon.height: Style.extraSmallIconSize - Layout.alignment: Qt.AlignTop | Qt.AlignRight - Layout.rightMargin: Style.extraSmallSpacing - Layout.topMargin: Style.extraSmallSpacing - background: Rectangle { - color: "transparent" - radius: root.windowRadius - border.width: parent.hovered ? Style.trayWindowBorderWidth : 0 - border.color: palette.dark - anchors.fill: parent - Layout.margins: Style.extraSmallSpacing - } + RowLayout { + Layout.fillWidth: true - visible: stack.depth < 2 + Button { + Layout.fillWidth: true - onClicked: stack.push(Qt.createComponent("com.nextcloud.desktopclient.sharing", "SettingsPage").createObject(root, { - accountState: root.accountState, - sharingManager: root.sharingManager, - shortLocalPath: root.shortLocalPath, - sharingModel: sharingModel, - recipientTypes: recipientTypes, - })) - } + id: viewInvitedPeople + text: qsTr("Invited people") - Button { - id: closeButton - flat: true - padding: Style.extraSmallSpacing - spacing: 0 - icon.source: "image://svgimage-custom-color/close.svg/" + palette.windowText - icon.width: Style.extraSmallIconSize - icon.height: Style.extraSmallIconSize - Layout.alignment: Qt.AlignTop | Qt.AlignRight - Layout.rightMargin: Style.extraSmallSpacing - Layout.topMargin: Style.extraSmallSpacing - background: Rectangle { - color: "transparent" - radius: root.windowRadius - border.width: parent.hovered ? Style.trayWindowBorderWidth : 0 - border.color: palette.dark - anchors.fill: parent - Layout.margins: Style.extraSmallSpacing + readonly property list recipientTypes: [ + // TODO: this won't be hardcoded in the future + "OC\\Core\\Sharing\\RecipientType\\UserShareRecipientType", + "OC\\Core\\Sharing\\RecipientType\\GroupShareRecipientType", + ] + + checkable: true + checked: true + ButtonGroup.group: shareType } - onClicked: root.close() + Button { + Layout.fillWidth: true + + id: viewAnyone + text: qsTr("Anyone") + + readonly property list recipientTypes: [ + // TODO: this won't be hardcoded in the future + "OC\\Core\\Sharing\\RecipientType\\TokenShareRecipientType", + ] + + checkable: true + ButtonGroup.group: shareType + } } } @@ -164,7 +212,6 @@ ApplicationWindow { localPath: root.localPath shortLocalPath: root.shortLocalPath sharingModel: sharingModel - - onRecipientTypesChanged: (recipientTypes) => { root.recipientTypes = recipientTypes } + recipientTypes: root.recipientTypes } } From dff4e30945b79c3d27ff8bfe6c0c27bb9b6a74ad Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Mon, 27 Apr 2026 17:16:48 +0200 Subject: [PATCH 024/104] fix(sharing): correct margins of window header Signed-off-by: Jyrki Gadinger --- src/gui/sharing/MainPage.qml | 1 - src/gui/sharing/SettingsPage.qml | 1 - src/gui/sharing/ShareDialog.qml | 28 +++++++++++++--------------- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index 16f20059871f6..d1e4091b19bf7 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -28,7 +28,6 @@ Page { ColumnLayout { id: windowContent anchors.fill: parent - anchors.margins: Style.standardSpacing ScrollView { Layout.fillHeight: true diff --git a/src/gui/sharing/SettingsPage.qml b/src/gui/sharing/SettingsPage.qml index 3e0493b2d3f36..b8075aa7c62d6 100644 --- a/src/gui/sharing/SettingsPage.qml +++ b/src/gui/sharing/SettingsPage.qml @@ -28,7 +28,6 @@ Page { ColumnLayout { id: windowContent anchors.fill: parent - anchors.margins: Style.standardSpacing ScrollView { Layout.fillHeight: true diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 0e20cda9eda25..a0fb020eced07 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -29,12 +29,6 @@ ApplicationWindow { ButtonGroup { id: shareType - - onClicked: (button) => { - console.log(`clicked on button ${button}`) - console.log(`root: ${root.recipientTypes}`) - // root.recipientTypes = button.recipientTypes - } } SharingModel { @@ -49,12 +43,15 @@ ApplicationWindow { title: mainPage.title - header: ColumnLayout { - id: windowHeader + ColumnLayout { spacing: Style.standardSpacing - anchors.margins: Style.standardSpacing // TODO: this doesn't seem to do anything + anchors.fill: parent + anchors.margins: Style.standardSpacing + RowLayout { // TODO: extract this to a shared component + id: windowHeader + Layout.fillWidth: true Button { id: backButton @@ -195,16 +192,17 @@ ApplicationWindow { ButtonGroup.group: shareType } } - } - StackView { - id: stack - anchors.fill: parent + StackView { + Layout.fillWidth: true + Layout.fillHeight: true + id: stack - initialItem: mainPage + initialItem: mainPage + } } - MainPage { + MainPage { id: mainPage accountState: root.accountState From e3fcb009538707a4ad0e0b14e97f552379b48487 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Tue, 28 Apr 2026 13:26:54 +0200 Subject: [PATCH 025/104] feat(sharing): add recipient search Signed-off-by: Jyrki Gadinger --- src/gui/sharing/CMakeLists.txt | 2 + src/gui/sharing/FieldDelegate.qml | 42 +++++++++++ src/gui/sharing/MainPage.qml | 1 + src/gui/sharing/SettingsPage.qml | 1 + src/gui/sharing/recipientsearchmodel.cpp | 95 ++++++++++++++++++++++++ src/gui/sharing/recipientsearchmodel.h | 53 +++++++++++++ src/gui/sharing/sharingfiltermodel.cpp | 4 + src/gui/sharing/sharingmodel.cpp | 20 ++++- src/gui/sharing/sharingmodel.h | 1 + src/libsync/sharing/sharingmanager.cpp | 13 ++++ src/libsync/sharing/sharingmanager.h | 3 + 11 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 src/gui/sharing/recipientsearchmodel.cpp create mode 100644 src/gui/sharing/recipientsearchmodel.h diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index 89d27b6ff77ed..48e1625ee4e9f 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -5,6 +5,8 @@ add_library(nextcloudGuiSharing STATIC) target_sources(nextcloudGuiSharing PRIVATE + recipientsearchmodel.h + recipientsearchmodel.cpp sharingfiltermodel.h sharingfiltermodel.cpp sharingmodel.h diff --git a/src/gui/sharing/FieldDelegate.qml b/src/gui/sharing/FieldDelegate.qml index 6ddb8f4fc2e61..18ba2bdabde5c 100644 --- a/src/gui/sharing/FieldDelegate.qml +++ b/src/gui/sharing/FieldDelegate.qml @@ -16,6 +16,7 @@ import Style Loader { id: instantiator required property var modelData + required property var accountState sourceComponent: switch (modelData.type) { case SharingModel.Switch: @@ -24,6 +25,8 @@ Loader { return textFieldComponent; case SharingModel.TextArea: return textAreaComponent; + case SharingModel.RecipientsField: + return recipientsFieldComponent; default: return unknownItem; } @@ -66,6 +69,45 @@ Loader { } } + Component { + id: recipientsFieldComponent + ColumnLayout { + Label { + text: modelData.label + } + SearchField { + id: searchField + // TODO: only available with Qt 6.10 + Layout.fillWidth: true + // no placeholderText on SearchField, really? + + suggestionModel: RecipientSearchModel { + accountState: instantiator.accountState + query: searchField.text + } + textRole: "query" + delegate: ItemDelegate { + id: recipientDelegate + text: displayName + + contentItem: Row { + Label { + // TODO: use plaintext-only fields + text: recipientDelegate.value + } + Label { + text: recipientDelegate.text + // TODO: use plaintext-only fields + } + } + + required property string displayName + required property string value + } + } + } + } + Component { id: unknownItem RowLayout { diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index d1e4091b19bf7..32bc289ca1bbb 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -49,6 +49,7 @@ Page { } delegate: FieldDelegate { + accountState: root.accountState width: propertyList.contentItem.width } } diff --git a/src/gui/sharing/SettingsPage.qml b/src/gui/sharing/SettingsPage.qml index b8075aa7c62d6..24291221c17ee 100644 --- a/src/gui/sharing/SettingsPage.qml +++ b/src/gui/sharing/SettingsPage.qml @@ -49,6 +49,7 @@ Page { delegate: FieldDelegate { + accountState: root.accountState width: propertyList.contentItem.width } } diff --git a/src/gui/sharing/recipientsearchmodel.cpp b/src/gui/sharing/recipientsearchmodel.cpp new file mode 100644 index 0000000000000..9decf89df13be --- /dev/null +++ b/src/gui/sharing/recipientsearchmodel.cpp @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "recipientsearchmodel.h" + +#include "account.h" + +using namespace Qt::StringLiterals; +using namespace OCC; +using namespace OCC::Gui::Sharing; + +RecipientSearchModel::RecipientSearchModel(QObject *parent) + : QAbstractListModel{parent} +{} + +int RecipientSearchModel::rowCount(const QModelIndex &parent) const +{ + return _searchResults.size(); +} + +QVariant RecipientSearchModel::data(const QModelIndex &index, int role) const +{ + const auto item = _searchResults.at(index.row()); + + switch (role) { + case TypeRole: + return item.toObject().value("type"_L1).toString(); + case ValueRole: + return item.toObject().value("value"_L1).toString(); + case DisplayNameRole: + return item.toObject().value("display_name"_L1).toString(); + default: + return {}; + } +} + +QHash RecipientSearchModel::roleNames() const +{ + return { + { TypeRole, "type"_ba}, + { ValueRole, "value"_ba}, + { DisplayNameRole, "displayName"_ba}, + }; +}; + +AccountState *RecipientSearchModel::accountState() const +{ + return _accountState; +} + +void RecipientSearchModel::setAccountState(AccountState *accountState) +{ + if (_accountState == accountState) { + return; + } + + beginResetModel(); + _accountState = accountState; + Q_EMIT accountStateChanged(); + endResetModel(); +} + +QString RecipientSearchModel::query() const +{ + return _query; +} + +void RecipientSearchModel::setQuery(const QString &query) +{ + if (_query == query) { + return; + } + + qCritical() << "query set to" << query; + _query = query; + Q_EMIT queryChanged(); + + if (_query.isEmpty()) { + beginResetModel(); + _searchResults = {}; + endResetModel(); + return; + } + + // TODO: start timer for search job + auto job = _accountState->account()->sharing()->createSearchJob(_query, 0, 10, this); + connect(job, &OCC::JsonApiJob::jsonReceived, this, [this](const QJsonDocument &json, int statusCode) -> void { + beginResetModel(); + _searchResults = json.object().value("ocs"_L1).toObject().value("data"_L1).toArray(); + endResetModel(); + }); + job->start(); +} diff --git a/src/gui/sharing/recipientsearchmodel.h b/src/gui/sharing/recipientsearchmodel.h new file mode 100644 index 0000000000000..ddea6d80e9440 --- /dev/null +++ b/src/gui/sharing/recipientsearchmodel.h @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include + +#include +#include "accountstate.h" + +namespace OCC::Gui::Sharing { + +class RecipientSearchModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(AccountState *accountState READ accountState WRITE setAccountState NOTIFY accountStateChanged) + Q_PROPERTY(QString query READ query WRITE setQuery NOTIFY queryChanged) + +public: + enum Roles { + TypeRole = Qt::UserRole, + ValueRole, + DisplayNameRole, + }; + + explicit RecipientSearchModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + [[nodiscard]] AccountState *accountState() const; + void setAccountState(AccountState *accountState); + + [[nodiscard]] QString query() const; + void setQuery(const QString &query); + +Q_SIGNALS: + void accountStateChanged(); + void queryChanged(); + +private: + AccountState *_accountState = nullptr; + QJsonArray _searchResults; + QString _query; +}; + +} diff --git a/src/gui/sharing/sharingfiltermodel.cpp b/src/gui/sharing/sharingfiltermodel.cpp index b7c9516b82b6d..1c89a9f635abf 100644 --- a/src/gui/sharing/sharingfiltermodel.cpp +++ b/src/gui/sharing/sharingfiltermodel.cpp @@ -70,6 +70,10 @@ bool SharingFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sour const auto sharing = accountState->account()->sharing(); const auto featureRole = model->data(index, SharingModel::LabelRole).toString(); // TODO: this will not be label + if (featureRole == "Add people"_L1) { + // TODO: same as above for now; >1 because link shares are only one recipient types + return _filterType == General && _recipientTypes.size() > 1; + } if (!sharing->isFeatureAvailable(featureRole, {"OCA\\Files\\Sharing\\SourceType\\NodeShareSourceType"}, _recipientTypes)) { // this feature is not available in general for these recipient types diff --git a/src/gui/sharing/sharingmodel.cpp b/src/gui/sharing/sharingmodel.cpp index 992e23bde5836..4c0cdcb0fb2be 100644 --- a/src/gui/sharing/sharingmodel.cpp +++ b/src/gui/sharing/sharingmodel.cpp @@ -24,7 +24,7 @@ int SharingModel::rowCount(const QModelIndex &parent) const // TODO: cache this after setting accountstate const auto sharing = accountState()->account()->sharing(); const auto features = sharing->features().values(); - return features.size(); + return features.size() + 1; // TODO: +1 for the special feature at the beginning for the recipients } QVariant SharingModel::data(const QModelIndex &index, int role) const @@ -33,10 +33,26 @@ QVariant SharingModel::data(const QModelIndex &index, int role) const return {}; } + if (index.row() == 0) { + // special feature for the recipients search + switch (role) { + case LabelRole: + return "Add people"_L1; + case PropertyRole: + return "recipients"_L1; + case TypeRole: + return FieldTypes::RecipientsField; + case PlaceholderRole: + return "Name, team, email, or federated cloud ID"_L1; + default: + return {}; + } + } + // TODO: cache this after setting accountstate const auto sharing = accountState()->account()->sharing(); const auto features = sharing->features().values(); - const auto feature = features.at(index.row()); + const auto feature = features.at(index.row() - 1); // TODO: -1 to adjust the special feature for searching switch (role) { case LabelRole: diff --git a/src/gui/sharing/sharingmodel.h b/src/gui/sharing/sharingmodel.h index 6e360d9199842..3d82f9466fdb0 100644 --- a/src/gui/sharing/sharingmodel.h +++ b/src/gui/sharing/sharingmodel.h @@ -31,6 +31,7 @@ class SharingModel : public QAbstractListModel Switch, TextField, TextArea, + RecipientsField, }; Q_ENUM(FieldTypes) diff --git a/src/libsync/sharing/sharingmanager.cpp b/src/libsync/sharing/sharingmanager.cpp index de637b749554e..f6c02ba08882a 100644 --- a/src/libsync/sharing/sharingmanager.cpp +++ b/src/libsync/sharing/sharingmanager.cpp @@ -133,3 +133,16 @@ QMap> SharingManager::features() const { return _features; } + +OCC::JsonApiJob *SharingManager::createSearchJob(const QString &query, int64_t offset, int64_t limit, QObject *parent) +{ + // TODO: use promises? + auto job = new JsonApiJob(_account, "/ocs/v2.php/apps/sharing/api/v1/recipients"_L1, parent); + QUrlQuery params; + params.addQueryItem("query"_L1, query); + params.addQueryItem("offset"_L1, QString::number(offset)); + params.addQueryItem("limit"_L1, QString::number(limit)); + job->addQueryParams(params); + + return job; +} diff --git a/src/libsync/sharing/sharingmanager.h b/src/libsync/sharing/sharingmanager.h index 2987063ae6d21..3ba7d710d45d3 100644 --- a/src/libsync/sharing/sharingmanager.h +++ b/src/libsync/sharing/sharingmanager.h @@ -9,6 +9,7 @@ #include #include "accountfwd.h" +#include "networkjobs.h" #include "sharetype.h" #include "feature.h" @@ -38,6 +39,8 @@ class SharingManager : public QObject [[nodiscard]] QMap> recipientTypes() const; [[nodiscard]] QMap> features() const; + JsonApiJob *createSearchJob(const QString &query, int64_t offset, int64_t limit, QObject *parent); + Q_SIGNALS: void availableChanged(); From 1df41ed23e031b4b54936b9f0e11a2ba9bf9a340 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Tue, 28 Apr 2026 15:54:34 +0200 Subject: [PATCH 026/104] feat(sharing): keep track of changes in viewmodel Signed-off-by: Jyrki Gadinger --- src/gui/sharing/FieldDelegate.qml | 37 +++++++++++++++++++++------- src/gui/sharing/sharingmodel.cpp | 40 +++++++++++++++++++++++++++++++ src/gui/sharing/sharingmodel.h | 4 ++++ 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/gui/sharing/FieldDelegate.qml b/src/gui/sharing/FieldDelegate.qml index 18ba2bdabde5c..804b2e1c980bf 100644 --- a/src/gui/sharing/FieldDelegate.qml +++ b/src/gui/sharing/FieldDelegate.qml @@ -15,10 +15,10 @@ import Style Loader { id: instantiator - required property var modelData + required property var model required property var accountState - sourceComponent: switch (modelData.type) { + sourceComponent: switch (model.type) { case SharingModel.Switch: return switchComponent; case SharingModel.TextField: @@ -35,10 +35,17 @@ Loader { id: switchComponent RowLayout { Label { - text: modelData.label + text: instantiator.model.label Layout.fillWidth: true } Switch { + Component.onCompleted: checked = instantiator.model.value ?? false + onCheckedChanged: { + if (instantiator.model.value === checked) { + return; + } + instantiator.model.value = checked + } } } } @@ -47,11 +54,18 @@ Loader { id: textFieldComponent ColumnLayout { Label { - text: modelData.label + text: instantiator.model.label } TextField { Layout.fillWidth: true - placeholderText: modelData.placeholder + placeholderText: instantiator.model.placeholder + Component.onCompleted: text = instantiator.model.value ?? "" + onEditingFinished: { + if (instantiator.model.value === text) { + return; + } + instantiator.model.value = text + } } } } @@ -60,11 +74,18 @@ Loader { id: textAreaComponent ColumnLayout { Label { - text: modelData.label + text: instantiator.model.label } TextArea { Layout.fillWidth: true - placeholderText: modelData.placeholder + placeholderText: instantiator.model.placeholder + Component.onCompleted: text = instantiator.model.value ?? "" + onEditingFinished: { + if (instantiator.model.value === text) { + return; + } + instantiator.model.value = text + } } } } @@ -73,7 +94,7 @@ Loader { id: recipientsFieldComponent ColumnLayout { Label { - text: modelData.label + text: instantiator.model.label } SearchField { id: searchField diff --git a/src/gui/sharing/sharingmodel.cpp b/src/gui/sharing/sharingmodel.cpp index 4c0cdcb0fb2be..41b3481cbf878 100644 --- a/src/gui/sharing/sharingmodel.cpp +++ b/src/gui/sharing/sharingmodel.cpp @@ -44,6 +44,8 @@ QVariant SharingModel::data(const QModelIndex &index, int role) const return FieldTypes::RecipientsField; case PlaceholderRole: return "Name, team, email, or federated cloud ID"_L1; + case ValueRole: + return _fieldValues.value("recipients"_L1).toList(); // TODO default: return {}; } @@ -63,11 +65,48 @@ QVariant SharingModel::data(const QModelIndex &index, int role) const return static_cast(index.row() % 3); case PlaceholderRole: return u"Placeholder for row %1"_s.arg(QString::number(index.row())); + case ValueRole: + return _fieldValues.value(feature->type()); default: return {}; } } +bool SharingModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + if (!_accountState) { + qCritical() << "no accountState, but setData called with" << index << value << role; + return false; + } + + qCritical() << "setData called with" << index << value << role; + + if (role != ValueRole) { + return false; + } + + if (index.row() == 0) { + qCritical() << "changing" << "recipients"_L1 << "to" << value; + _fieldValues.insert("recipients"_L1, value); // TODO + } else { + // TODO: cache this after setting accountstate + const auto sharing = accountState()->account()->sharing(); + const auto features = sharing->features().values(); + const auto feature = features.at(index.row() - 1); // TODO: -1 to adjust the special feature for searching + qCritical() << "changing" << feature->type() << "to" << value; + _fieldValues.insert(feature->type(), value); + } + + Q_EMIT dataChanged(index, index, {ValueRole}); + return true; +} + +Qt::ItemFlags SharingModel::flags(const QModelIndex &index) const +{ + qCritical() << "flags for" << index << QAbstractListModel::flags(index) << Qt::ItemIsEditable << "returning" << (QAbstractListModel::flags(index) | Qt::ItemIsEditable); + return QAbstractListModel::flags(index) | Qt::ItemIsEditable; +} + QHash SharingModel::roleNames() const { return { @@ -75,6 +114,7 @@ QHash SharingModel::roleNames() const { PropertyRole, "property"_ba}, { TypeRole, "type"_ba}, { PlaceholderRole, "placeholder"_ba}, + { ValueRole, "value"_ba}, }; }; diff --git a/src/gui/sharing/sharingmodel.h b/src/gui/sharing/sharingmodel.h index 3d82f9466fdb0..e5ca5b7a721f2 100644 --- a/src/gui/sharing/sharingmodel.h +++ b/src/gui/sharing/sharingmodel.h @@ -25,6 +25,7 @@ class SharingModel : public QAbstractListModel PropertyRole, TypeRole, PlaceholderRole, + ValueRole, }; enum FieldTypes { @@ -39,6 +40,8 @@ class SharingModel : public QAbstractListModel int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; + Qt::ItemFlags flags(const QModelIndex &index) const override; QHash roleNames() const override; [[nodiscard]] AccountState *accountState() const; @@ -49,6 +52,7 @@ class SharingModel : public QAbstractListModel private: AccountState *_accountState = nullptr; + QVariantMap _fieldValues; }; } From 9d4e57c826395a176c231f51c1f0ee72c94a78a8 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Tue, 28 Apr 2026 15:55:37 +0200 Subject: [PATCH 027/104] refactor(sharing): adopt unified sharing API models Signed-off-by: Jyrki Gadinger --- src/gui/sharing/CMakeLists.txt | 21 +- src/gui/sharing/FieldDelegate.qml | 25 ++- src/gui/sharing/MainPage.qml | 27 ++- src/gui/sharing/RecipientSearchField.qml | 253 +++++++++++++++++++++++ src/gui/sharing/SettingsPage.qml | 17 +- src/gui/sharing/ShareDialog.qml | 21 +- src/gui/sharing/ocssharingjob.cpp | 95 +++++++++ src/gui/sharing/ocssharingjob.h | 42 ++++ src/gui/sharing/permission.cpp | 69 +++++++ src/gui/sharing/permission.h | 42 ++++ src/gui/sharing/property.cpp | 71 +++++++ src/gui/sharing/property.h | 49 +++++ src/gui/sharing/propertymodel.cpp | 118 +++++++++++ src/gui/sharing/propertymodel.h | 58 ++++++ src/gui/sharing/recipientsearchmodel.cpp | 36 ++-- src/gui/sharing/recipientsearchmodel.h | 16 +- src/gui/sharing/share.cpp | 130 ++++++++++++ src/gui/sharing/share.h | 68 ++++++ src/gui/sharing/sharingcontroller.cpp | 99 +++++++++ src/gui/sharing/sharingcontroller.h | 48 +++++ src/gui/sharing/sharingfiltermodel.cpp | 8 +- src/gui/sharing/sharingmodel.cpp | 107 ++++++---- src/gui/sharing/sharingmodel.h | 5 + src/gui/systray.cpp | 18 +- src/libsync/CMakeLists.txt | 6 - src/libsync/account.cpp | 11 - src/libsync/account.h | 9 - src/libsync/capabilities.cpp | 5 + src/libsync/capabilities.h | 1 + src/libsync/sharing/feature.cpp | 44 ---- src/libsync/sharing/feature.h | 35 ---- src/libsync/sharing/sharetype.cpp | 38 ---- src/libsync/sharing/sharetype.h | 34 --- src/libsync/sharing/sharingmanager.cpp | 148 ------------- src/libsync/sharing/sharingmanager.h | 61 ------ 35 files changed, 1342 insertions(+), 493 deletions(-) create mode 100644 src/gui/sharing/RecipientSearchField.qml create mode 100644 src/gui/sharing/ocssharingjob.cpp create mode 100644 src/gui/sharing/ocssharingjob.h create mode 100644 src/gui/sharing/permission.cpp create mode 100644 src/gui/sharing/permission.h create mode 100644 src/gui/sharing/property.cpp create mode 100644 src/gui/sharing/property.h create mode 100644 src/gui/sharing/propertymodel.cpp create mode 100644 src/gui/sharing/propertymodel.h create mode 100644 src/gui/sharing/share.cpp create mode 100644 src/gui/sharing/share.h create mode 100644 src/gui/sharing/sharingcontroller.cpp create mode 100644 src/gui/sharing/sharingcontroller.h delete mode 100644 src/libsync/sharing/feature.cpp delete mode 100644 src/libsync/sharing/feature.h delete mode 100644 src/libsync/sharing/sharetype.cpp delete mode 100644 src/libsync/sharing/sharetype.h delete mode 100644 src/libsync/sharing/sharingmanager.cpp delete mode 100644 src/libsync/sharing/sharingmanager.h diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index 48e1625ee4e9f..ba4bc0fa521c7 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -5,12 +5,24 @@ add_library(nextcloudGuiSharing STATIC) target_sources(nextcloudGuiSharing PRIVATE + ocssharingjob.h + ocssharingjob.cpp + permission.h + permission.cpp + property.h + property.cpp + propertymodel.h + propertymodel.cpp recipientsearchmodel.h recipientsearchmodel.cpp - sharingfiltermodel.h - sharingfiltermodel.cpp - sharingmodel.h - sharingmodel.cpp + share.h + share.cpp + # sharingfiltermodel.h + # sharingfiltermodel.cpp + sharingcontroller.h + sharingcontroller.cpp + # sharingmodel.h + # sharingmodel.cpp ) ecm_add_qml_module(nextcloudGuiSharing @@ -19,6 +31,7 @@ ecm_add_qml_module(nextcloudGuiSharing QML_FILES FieldDelegate.qml MainPage.qml + RecipientSearchField.qml SettingsPage.qml ShareDialog.qml ) diff --git a/src/gui/sharing/FieldDelegate.qml b/src/gui/sharing/FieldDelegate.qml index 804b2e1c980bf..5db32203a2f87 100644 --- a/src/gui/sharing/FieldDelegate.qml +++ b/src/gui/sharing/FieldDelegate.qml @@ -16,16 +16,16 @@ import Style Loader { id: instantiator required property var model - required property var accountState + required property var account sourceComponent: switch (model.type) { - case SharingModel.Switch: + case PropertyModel.Switch: return switchComponent; - case SharingModel.TextField: + case PropertyModel.TextField: return textFieldComponent; - case SharingModel.TextArea: + case PropertyModel.TextArea: return textAreaComponent; - case SharingModel.RecipientsField: + case PropertyModel.RecipientsField: return recipientsFieldComponent; default: return unknownItem; @@ -103,7 +103,7 @@ Loader { // no placeholderText on SearchField, really? suggestionModel: RecipientSearchModel { - accountState: instantiator.accountState + account: instantiator.account query: searchField.text } textRole: "query" @@ -125,6 +125,19 @@ Loader { required property string displayName required property string value } + + onActivated: (index) => { + console.log(`activated index ${index}`) + console.log(`activated index ${index} => ${suggestionModel.get(index).value}`) + instantiator.model.value.append(suggestionModel.get(index).value) + } + } + Repeater { + model: model.value ?? [] + Label { + required property list value + text: value + } } } } diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index 32bc289ca1bbb..e8f57260ecd8d 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -16,11 +16,9 @@ import Style Page { id: root - property var accountState - property QtObject sharingManager property string localPath: "" property string shortLocalPath: "" - required property SharingModel sharingModel + required property SharingController sharingController required property list recipientTypes title: qsTr("Share \"%1\"").arg(root.shortLocalPath) @@ -38,18 +36,29 @@ Page { contentHeight: availableHeight rightPadding: ScrollBar.vertical.policy == ScrollBar.AlwaysOn ? ScrollBar.vertical.width + Style.standardSpacing : 0 + RecipientSearchField { + id: searchField + Layout.fillWidth: true + account: root.sharingController.account + + onRecipientSelected: (recipientType, recipientValue) => { + root.sharingController.addRecipient(recipientType, recipientValue) + } + } + + ListView { id: propertyList clip: true - model: SharingFilterModel { - filterType: SharingFilterModel.General - sourceModel: root.sharingModel - recipientTypes: root.recipientTypes - } + // model: SharingFilterModel { + // filterType: SharingFilterModel.General + // sourceModel: root.sharingModel + // recipientTypes: root.recipientTypes + // } delegate: FieldDelegate { - accountState: root.accountState + account: root.account width: propertyList.contentItem.width } } diff --git a/src/gui/sharing/RecipientSearchField.qml b/src/gui/sharing/RecipientSearchField.qml new file mode 100644 index 0000000000000..c39218d12fe09 --- /dev/null +++ b/src/gui/sharing/RecipientSearchField.qml @@ -0,0 +1,253 @@ +/* + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Window +import QtQuick.Layouts +import QtQuick.Controls + +import com.nextcloud.desktopclient +import com.nextcloud.desktopclient as NC +import Style + +// Based on the old `ShareeSearchField` component from filedetails. +// While Qt 6.10+ has a `SearchField` type, it's still lacking some features +// such as a placeholder text. +TextField { + id: root + + signal recipientSelected(string recipientType, string recipientValue) + + required property var account + // property bool isShareeFetchOngoing: recipientModel.fetchOngoing + property RecipientSearchModel recipientModel: RecipientSearchModel { + account: root.account + query: root.text + } + + readonly property int horizontalPaddingOffset: Style.trayHorizontalMargin + readonly property double iconsScaleFactor: 0.6 + + function triggerSuggestionsVisibility() { + recipientListView.count > 0 ? suggestionsPopup.open() : suggestionsPopup.close(); + } + + placeholderText: enabled ? qsTr("Search for recipients") : qsTr("Sharing is not available for this folder") + verticalAlignment: Qt.AlignVCenter + implicitHeight: Math.max(Style.talkReplyTextFieldPreferredHeight, contentHeight) + + onActiveFocusChanged: triggerSuggestionsVisibility() + onTextChanged: triggerSuggestionsVisibility() + Keys.onPressed: { + if(suggestionsPopup.visible) { + switch(event.key) { + case Qt.Key_Escape: + suggestionsPopup.close(); + recipientListView.currentIndex = -1; + event.accepted = true; + break; + + case Qt.Key_Up: + recipientListView.decrementCurrentIndex(); + event.accepted = true; + break; + + case Qt.Key_Down: + recipientListView.incrementCurrentIndex(); + event.accepted = true; + break; + + case Qt.Key_Enter: + case Qt.Key_Return: + if(recipientListView.currentIndex > -1) { + recipientListView.itemAtIndex(recipientListView.currentIndex).selectItem(); + event.accepted = true; + break; + } + } + } else { + switch(event.key) { + case Qt.Key_Down: + triggerSuggestionsVisibility(); + event.accepted = true; + break; + } + } + } + + leftPadding: searchIcon.width + searchIcon.anchors.leftMargin + horizontalPaddingOffset + rightPadding: clearTextButton.width + clearTextButton.anchors.rightMargin + horizontalPaddingOffset + + Image { + id: searchIcon + anchors { + top: parent.top + left: parent.left + bottom: parent.bottom + margins: 4 + } + + width: height + + smooth: true + antialiasing: true + mipmap: true + fillMode: Image.PreserveAspectFit + horizontalAlignment: Image.AlignLeft + + source: "image://svgimage-custom-color/search.svg" + "/" + palette.placeholderText + sourceSize: Qt.size(parent.height * root.iconsScaleFactor, parent.height * root.iconsScaleFactor) + + visible: !root.recipientModel.fetchOngoing + } +/* + NCBusyIndicator { + id: busyIndicator + + anchors { + top: parent.top + left: parent.left + bottom: parent.bottom + } + + width: height + color: palette.placeholderText + visible: root.recipientModel.fetchOngoing + running: visible + } + */ + Image { + id: clearTextButton + + anchors { + top: parent.top + right: parent.right + bottom: parent.bottom + margins: 4 + } + + width: height + + smooth: true + antialiasing: true + mipmap: true + fillMode: Image.PreserveAspectFit + + source: "image://svgimage-custom-color/clear.svg" + "/" + palette.placeholderText + sourceSize: Qt.size(parent.height * root.iconsScaleFactor, parent.height * root.iconsScaleFactor) + + visible: root.text + + MouseArea { + id: clearTextButtonMouseArea + anchors.fill: parent + onClicked: root.clear() + } + } + + Popup { + id: suggestionsPopup + + width: root.width + y: root.height + + contentItem: ScrollView { + id: suggestionsScrollView + + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: recipientListView.contentHeight > recipientListView.height ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff + + // need to take the popup's padding in account for the max height + // remove bottomPadding twice to leave some space between the window border + implicitHeight: Math.min(Window.height - parent.y - parent.topPadding - parent.bottomPadding * 2, contentHeight) + + ListView { + id: recipientListView + + spacing: 0 + currentIndex: -1 + interactive: true + + highlight: Rectangle { + anchors.fill: recipientListView.currentItem + color: palette.highlight + } + highlightFollowsCurrentItem: true + highlightMoveDuration: 0 + highlightResizeDuration: 0 + highlightRangeMode: ListView.ApplyRange + preferredHighlightBegin: 0 + preferredHighlightEnd: suggestionsScrollView.height + + onCountChanged: root.triggerSuggestionsVisibility() + + model: root.recipientModel + delegate: ItemDelegate { + id: recipientDelegate + required property int index + + required property string type + required property string value + required property string displayName + required property string iconUrl + + width: recipientListView.contentItem.width + + text: displayName + + contentItem: RowLayout { + Image { + source: recipientDelegate.iconUrl + } + Label { + text: recipientDelegate.displayName + } + Label { + text: recipientDelegate.type + } + } + + // enabled: model.type !== NC.recipient.LookupServerSearchResults + // hoverEnabled: model.type !== NC.recipient.LookupServerSearchResults + + function selectSharee() { + console.log(`recipientSelected: ${JSON.stringify([recipientDelegate.type, recipientDelegate.value])})`) + root.recipientSelected(recipientDelegate.type, recipientDelegate.value); + suggestionsPopup.close(); + + root.clear(); + } + + function selectItem() { + // if (model.type === NC.recipient.LookupServerSearch) { + // recipientListView.currentIndex = -1 + // root.recipientModel.searchGlobally() + // } else { + selectSharee() + // } + } + + onHoveredChanged: if (hovered) { + // When we set the currentIndex the list view will scroll... + // unless we tamper with the preferred highlight points to stop this. + const savedPreferredHighlightBegin = recipientListView.preferredHighlightBegin; + const savedPreferredHighlightEnd = recipientListView.preferredHighlightEnd; + // Set overkill values to make sure no scroll happens when we hover with mouse + recipientListView.preferredHighlightBegin = -suggestionsScrollView.height; + recipientListView.preferredHighlightEnd = suggestionsScrollView.height * 2; + + recipientListView.currentIndex = index + + // Reset original values so keyboard navigation makes list view scroll + recipientListView.preferredHighlightBegin = savedPreferredHighlightBegin; + recipientListView.preferredHighlightEnd = savedPreferredHighlightEnd; + } + onClicked: selectItem() + } + } + } + } +} diff --git a/src/gui/sharing/SettingsPage.qml b/src/gui/sharing/SettingsPage.qml index 24291221c17ee..4cbcf85e60384 100644 --- a/src/gui/sharing/SettingsPage.qml +++ b/src/gui/sharing/SettingsPage.qml @@ -16,11 +16,9 @@ import Style Page { id: root - property var accountState - property QtObject sharingManager property string localPath: "" property string shortLocalPath: "" - required property SharingModel sharingModel + required property SharingController sharingController required property list recipientTypes title: qsTr("Sharing settings") @@ -41,15 +39,18 @@ Page { id: propertyList clip: true - model: SharingFilterModel { - filterType: SharingFilterModel.Settings - sourceModel: root.sharingModel - recipientTypes: root.recipientTypes + // model: SharingFilterModel { + // filterType: SharingFilterModel.Settings + // sourceModel: root.sharingModel + // recipientTypes: root.recipientTypes + // } + model: PropertyModel { + share: root.sharingController.share } delegate: FieldDelegate { - accountState: root.accountState + account: root.sharingController.account width: propertyList.contentItem.width } } diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index a0fb020eced07..92ac173dc0130 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -18,8 +18,7 @@ ApplicationWindow { id: root visible: true - property var accountState - property QtObject sharingManager + property var account property string localPath: "" property string shortLocalPath: root.localPath.split("/").reverse()[0] @@ -31,9 +30,13 @@ ApplicationWindow { id: shareType } - SharingModel { - id: sharingModel - accountState: root.accountState + SharingController { + id: sharingController + account: root.account + } + + Component.onCompleted: { + sharingController.createShare("8") // TODO: do not hardcode fileid } width: 400 @@ -125,10 +128,8 @@ ApplicationWindow { visible: stack.depth < 2 onClicked: stack.push(Qt.createComponent("com.nextcloud.desktopclient.sharing", "SettingsPage").createObject(root, { - accountState: root.accountState, - sharingManager: root.sharingManager, + sharingController: sharingController, shortLocalPath: root.shortLocalPath, - sharingModel: sharingModel, recipientTypes: Qt.binding(() => root.recipientTypes), })) } @@ -205,11 +206,9 @@ ApplicationWindow { MainPage { id: mainPage - accountState: root.accountState - sharingManager: root.sharingManager + sharingController: sharingController localPath: root.localPath shortLocalPath: root.shortLocalPath - sharingModel: sharingModel recipientTypes: root.recipientTypes } } diff --git a/src/gui/sharing/ocssharingjob.cpp b/src/gui/sharing/ocssharingjob.cpp new file mode 100644 index 0000000000000..6768fcd7d6363 --- /dev/null +++ b/src/gui/sharing/ocssharingjob.cpp @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "ocssharingjob.h" + +#include +#include + +#include "share.h" + +Q_LOGGING_CATEGORY(lcOcsSharingJob, "nextcloud.gui.sharing.ocssharingjob", QtInfoMsg) + +using namespace Qt::StringLiterals; + +using namespace OCC; +using namespace OCC::Gui::Sharing; + +namespace +{ + constexpr auto SHARING_V1_BASE = "/ocs/v2.php/apps/sharing/api/v1"_L1; + + constexpr auto SOURCE_TYPE_NODE = "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1; +} + +OcsSharingJob::OcsSharingJob(AccountPtr account, const QString &shareId) + : OcsJob{account} + , _shareId{shareId} +{ +} + +void OcsSharingJob::createShare() +{ + setPath(SHARING_V1_BASE % "/share"); + setVerb("POST"_ba); + addPassStatusCode(201); + + connect(this, &OcsSharingJob::jobFinished, this, [this](const QJsonDocument &json, int statusCode) -> void { + qCDebug(lcOcsSharingJob) << "share received with status" << statusCode << json; + + auto share = Share::fromJson(json, _account); + + Q_EMIT shareCreated(share); + }); + + start(); +} + +void OcsSharingJob::addSource(const QString &fileId) +{ + if (_shareId.isEmpty()) { + qCWarning(lcOcsSharingJob) << "addSource called without a shareId, not starting job"; + Q_EMIT jobFinished({}, 0); + return; + } + + setPath(SHARING_V1_BASE % "/share/%1/source"_L1.arg(_shareId)); + setVerb("POST"_ba); + + addParam("class"_L1, SOURCE_TYPE_NODE); + addParam("value"_L1, fileId); + + start(); +} + +void OcsSharingJob::addRecipient(const QString &recipientType, const QString &recipientValue) +{ + if (_shareId.isEmpty()) { + qCWarning(lcOcsSharingJob) << "addRecipient called without a shareId, not starting job"; + Q_EMIT jobFinished({}, 0); + return; + } + + setPath(SHARING_V1_BASE % "/share/%1/recipient"_L1.arg(_shareId)); + setVerb("POST"_ba); + + addParam("class"_L1, recipientType); + addParam("value"_L1, recipientValue); + + start(); +} + +void OcsSharingJob::searchRecipients(const QString &query, int64_t offset, int64_t limit) +{ + setPath(SHARING_V1_BASE % "/recipients"); + setVerb("GET"_ba); + + addParam("query"_L1, query); + addParam("offset"_L1, QString::number(offset)); + addParam("limit"_L1, QString::number(limit)); + + start(); +} + diff --git a/src/gui/sharing/ocssharingjob.h b/src/gui/sharing/ocssharingjob.h new file mode 100644 index 0000000000000..2b1973f67b3a0 --- /dev/null +++ b/src/gui/sharing/ocssharingjob.h @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include + +#include "ocsjob.h" + +#include "accountfwd.h" + +namespace OCC::Gui::Sharing { + +class Share; + +/** + * \brief The OcsSharingJob class handles requests to the new Unified Sharing API. + */ +class OcsSharingJob : public OcsJob +{ + Q_OBJECT + +public: + explicit OcsSharingJob(AccountPtr account, const QString &shareId = {}); + + void createShare(); + + void addSource(const QString &fileId); + void addRecipient(const QString &recipientType, const QString &recipientValue); + + void searchRecipients(const QString &query, int64_t offset, int64_t limit); + +Q_SIGNALS: + void shareCreated(QPointer share); + +private: + QString _shareId; +}; + +} diff --git a/src/gui/sharing/permission.cpp b/src/gui/sharing/permission.cpp new file mode 100644 index 0000000000000..c62a4fd670dbc --- /dev/null +++ b/src/gui/sharing/permission.cpp @@ -0,0 +1,69 @@ + +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "permission.h" + +#include +#include + +using namespace Qt::StringLiterals; + +using namespace OCC::Gui::Sharing; + +QPointer Permission::fromJson(const QJsonObject &json) +{ + const auto className = json.value("class"_L1).toString(); + const auto displayName = json.value("display_name"_L1).toString(); + const auto enabled = json.value("enabled"_L1).toBool(); + const auto hint = json.value("hint"_L1).toString(); + + auto permission = QPointer(new Permission { + className, + displayName, + enabled, + hint, + }); + return permission; +} + +Permission::Permission(const QString &className, const QString &displayName, bool enabled, const QString &hint, QObject *parent) + : QObject{parent} + , _className{className} + , _displayName{displayName} + , _enabled{enabled} + , _hint{hint} +{ +} + +QString Permission::className() const +{ + return _className; +} + +QString Permission::displayName() const +{ + return _displayName; +} + +bool Permission::enabled() const +{ + return _enabled; +} + +QString Permission::hint() const +{ + return _hint; +} + +void Permission::setEnabled(bool enabled) +{ + if (_enabled == enabled) { + return; + } + + _enabled = enabled; + Q_EMIT enabledChanged(); +} diff --git a/src/gui/sharing/permission.h b/src/gui/sharing/permission.h new file mode 100644 index 0000000000000..147876365707c --- /dev/null +++ b/src/gui/sharing/permission.h @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include + +namespace OCC::Gui::Sharing { + +class Permission : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString className READ className CONSTANT) + Q_PROPERTY(QString displayName READ displayName CONSTANT) + Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged) + Q_PROPERTY(QString hint READ hint CONSTANT) + +public: + [[nodiscard]] static QPointer fromJson(const QJsonObject &json); + + [[nodiscard]] QString className() const; + [[nodiscard]] QString displayName() const; + [[nodiscard]] bool enabled() const; + [[nodiscard]] QString hint() const; + + void setEnabled(bool enabled); + +Q_SIGNALS: + void enabledChanged(); + +private: + explicit Permission(const QString &className, const QString &displayName, bool enabled, const QString &hint, QObject *parent = nullptr); + + QString _className; + QString _displayName; + bool _enabled; + QString _hint; +}; + +} diff --git a/src/gui/sharing/property.cpp b/src/gui/sharing/property.cpp new file mode 100644 index 0000000000000..b4ddbca7f7d84 --- /dev/null +++ b/src/gui/sharing/property.cpp @@ -0,0 +1,71 @@ + +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "property.h" + +#include +#include + +using namespace Qt::StringLiterals; + +using namespace OCC::Gui::Sharing; + +QPointer Property::fromJson(const QJsonObject &json) +{ + auto property = QPointer(new Property); + property->_className = json.value("class"_L1).toString(); + property->_displayName = json.value("display_name"_L1).toString(); + property->_required = json.value("required"_L1).toBool(); + property->_hint = json.value("hint"_L1).toString(); + property->_type = json.value("type"_L1).toString(); + property->_value = json.value("value"_L1).toVariant(); + return property; +} + +Property::Property(QObject *parent) + : QObject{parent} +{ +} + +QString Property::className() const +{ + return _className; +} + +QString Property::displayName() const +{ + return _displayName; +} + +bool Property::required() const +{ + return _required; +} + +QString Property::hint() const +{ + return _hint; +} + +QString Property::type() const +{ + return _type; +} + +QVariant Property::value() const +{ + return _value; +} + +void Property::setValue(const QVariant &value) +{ + if (_value == value) { + return; + } + + _value = value; + Q_EMIT valueChanged(); +} diff --git a/src/gui/sharing/property.h b/src/gui/sharing/property.h new file mode 100644 index 0000000000000..aa1c3516e8469 --- /dev/null +++ b/src/gui/sharing/property.h @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include + +namespace OCC::Gui::Sharing { + +class Property : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString className READ className CONSTANT) + Q_PROPERTY(QString displayName READ displayName CONSTANT) + Q_PROPERTY(bool required READ required CONSTANT) + Q_PROPERTY(QString hint READ hint CONSTANT) + Q_PROPERTY(QString type READ type CONSTANT) + Q_PROPERTY(QVariant value READ value WRITE setValue NOTIFY valueChanged) + +public: + [[nodiscard]] static QPointer fromJson(const QJsonObject &json); + + [[nodiscard]] QString className() const; + [[nodiscard]] QString displayName() const; + [[nodiscard]] bool required() const; + [[nodiscard]] QString type() const; + [[nodiscard]] QString hint() const; + [[nodiscard]] QVariant value() const; + + void setValue(const QVariant &value); + +Q_SIGNALS: + void valueChanged(); + +private: + explicit Property(QObject *parent = nullptr); + + QString _className; + QString _displayName; + bool _required; + QString _hint; + QString _type; + QVariant _value; +}; + +} diff --git a/src/gui/sharing/propertymodel.cpp b/src/gui/sharing/propertymodel.cpp new file mode 100644 index 0000000000000..7b38c9b4cbc50 --- /dev/null +++ b/src/gui/sharing/propertymodel.cpp @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "propertymodel.h" + +#include + +#include "share.h" +#include "property.h" + +using namespace Qt::StringLiterals; +using namespace OCC; +using namespace OCC::Gui::Sharing; + +PropertyModel::PropertyModel(QObject *parent) + : QAbstractListModel{parent} +{} + +int PropertyModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + + if (!_share) { + return 0; + } + + return _share->properties().size(); +} + +QVariant PropertyModel::data(const QModelIndex &index, int role) const +{ + if (!_share) { + return {}; + } + + const auto properties = _share->properties(); + const auto property = properties.at(index.row()); + + switch (role) { + case LabelRole: + return property->displayName(); + case PropertyRole: + return property->className(); + case TypeRole: + if (property->className() == "string"_L1) { + return FieldTypes::TextField; + } + // TODO: date etc. + return FieldTypes::TextField; + case PlaceholderRole: + return property->hint(); + case ValueRole: + // return _fieldValues.value(feature->type()); + return property->value(); + default: + return {}; + } +} + +bool PropertyModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + return false; + + qCritical() << "setData called with" << index << value << role; + + if (role != ValueRole) { + return false; + } + + if (index.row() == 0) { + qCritical() << "changing" << "recipients"_L1 << "to" << value; + _fieldValues.insert("recipients"_L1, value); // TODO + } else { + // TODO: cache this after setting accountstate + // const auto sharing = account()->account()->sharing(); + // const auto features = sharing->features().values(); + // const auto feature = features.at(index.row() - 1); // TODO: -1 to adjust the special feature for searching + // qCritical() << "changing" << feature->type() << "to" << value; + // _fieldValues.insert(feature->type(), value); + } + + Q_EMIT dataChanged(index, index, {ValueRole}); + return true; +} + +Qt::ItemFlags PropertyModel::flags(const QModelIndex &index) const +{ + qCritical() << "flags for" << index << QAbstractListModel::flags(index) << Qt::ItemIsEditable << "returning" << (QAbstractListModel::flags(index) | Qt::ItemIsEditable); + return QAbstractListModel::flags(index) | Qt::ItemIsEditable; +} + +QHash PropertyModel::roleNames() const +{ + return { + { LabelRole, "label"_ba}, + { PropertyRole, "property"_ba}, + { TypeRole, "type"_ba}, + { PlaceholderRole, "placeholder"_ba}, + { ValueRole, "value"_ba}, + }; +}; + +Share *PropertyModel::share() const +{ + return _share; +} + +void PropertyModel::setShare(Share *share) +{ + if (_share == share) { + return; + } + + _share = share; + Q_EMIT shareChanged(); +} diff --git a/src/gui/sharing/propertymodel.h b/src/gui/sharing/propertymodel.h new file mode 100644 index 0000000000000..e153b6602387d --- /dev/null +++ b/src/gui/sharing/propertymodel.h @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include + +#include "share.h" + +namespace OCC::Gui::Sharing { + +class PropertyModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(Share *share READ share WRITE setShare NOTIFY shareChanged) + +public: + enum Roles { + LabelRole = Qt::UserRole, + PropertyRole, + TypeRole, + PlaceholderRole, + ValueRole, + }; + + enum FieldTypes { + Switch, + TextField, + TextArea, + RecipientsField, + }; + Q_ENUM(FieldTypes) + + explicit PropertyModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; + Qt::ItemFlags flags(const QModelIndex &index) const override; + QHash roleNames() const override; + + [[nodiscard]] Share* share() const; + void setShare(Share* share); + +Q_SIGNALS: + void shareChanged(); + +private: + Share *_share = nullptr; + QVariantMap _fieldValues; +}; + +} diff --git a/src/gui/sharing/recipientsearchmodel.cpp b/src/gui/sharing/recipientsearchmodel.cpp index 9decf89df13be..92f9d0125af21 100644 --- a/src/gui/sharing/recipientsearchmodel.cpp +++ b/src/gui/sharing/recipientsearchmodel.cpp @@ -5,7 +5,12 @@ #include "recipientsearchmodel.h" -#include "account.h" +#include +#include + +#include "ocssharingjob.h" + +Q_LOGGING_CATEGORY(lcSharingRecipientShareModel, "nextcloud.gui.sharing.recipientsearchmodel", QtInfoMsg) using namespace Qt::StringLiterals; using namespace OCC; @@ -26,11 +31,13 @@ QVariant RecipientSearchModel::data(const QModelIndex &index, int role) const switch (role) { case TypeRole: - return item.toObject().value("type"_L1).toString(); + return item.toObject().value("class"_L1).toString(); case ValueRole: return item.toObject().value("value"_L1).toString(); case DisplayNameRole: return item.toObject().value("display_name"_L1).toString(); + case IconRole: + return "image://tray-image-provider/%1"_L1.arg(item.toObject().value("icon"_L1).toObject().value("light").toString()); default: return {}; } @@ -42,23 +49,24 @@ QHash RecipientSearchModel::roleNames() const { TypeRole, "type"_ba}, { ValueRole, "value"_ba}, { DisplayNameRole, "displayName"_ba}, + { IconRole, "iconUrl"_ba}, }; }; -AccountState *RecipientSearchModel::accountState() const +AccountPtr RecipientSearchModel::account() const { - return _accountState; + return _account; } -void RecipientSearchModel::setAccountState(AccountState *accountState) +void RecipientSearchModel::setAccount(AccountPtr account) { - if (_accountState == accountState) { + if (_account == account) { return; } beginResetModel(); - _accountState = accountState; - Q_EMIT accountStateChanged(); + _account = account; + Q_EMIT accountChanged(); endResetModel(); } @@ -69,11 +77,15 @@ QString RecipientSearchModel::query() const void RecipientSearchModel::setQuery(const QString &query) { + if (!_account) { + return; + } + if (_query == query) { return; } - qCritical() << "query set to" << query; + qCDebug(lcSharingRecipientShareModel) << "query set to" << query; _query = query; Q_EMIT queryChanged(); @@ -85,11 +97,11 @@ void RecipientSearchModel::setQuery(const QString &query) } // TODO: start timer for search job - auto job = _accountState->account()->sharing()->createSearchJob(_query, 0, 10, this); - connect(job, &OCC::JsonApiJob::jsonReceived, this, [this](const QJsonDocument &json, int statusCode) -> void { + auto job = new OcsSharingJob(_account); + connect(job, &OcsSharingJob::jobFinished, this, [this](const QJsonDocument &json, int statusCode) -> void { beginResetModel(); _searchResults = json.object().value("ocs"_L1).toObject().value("data"_L1).toArray(); endResetModel(); }); - job->start(); + job->searchRecipients(query, 0, 10); } diff --git a/src/gui/sharing/recipientsearchmodel.h b/src/gui/sharing/recipientsearchmodel.h index ddea6d80e9440..c3a571ff1a2de 100644 --- a/src/gui/sharing/recipientsearchmodel.h +++ b/src/gui/sharing/recipientsearchmodel.h @@ -6,10 +6,11 @@ #pragma once #include -#include +#include #include -#include "accountstate.h" + +#include "accountfwd.h" namespace OCC::Gui::Sharing { @@ -18,7 +19,7 @@ class RecipientSearchModel : public QAbstractListModel Q_OBJECT QML_ELEMENT - Q_PROPERTY(AccountState *accountState READ accountState WRITE setAccountState NOTIFY accountStateChanged) + Q_PROPERTY(AccountPtr account READ account WRITE setAccount NOTIFY accountChanged) Q_PROPERTY(QString query READ query WRITE setQuery NOTIFY queryChanged) public: @@ -26,6 +27,7 @@ class RecipientSearchModel : public QAbstractListModel TypeRole = Qt::UserRole, ValueRole, DisplayNameRole, + IconRole, }; explicit RecipientSearchModel(QObject *parent = nullptr); @@ -34,18 +36,18 @@ class RecipientSearchModel : public QAbstractListModel QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; - [[nodiscard]] AccountState *accountState() const; - void setAccountState(AccountState *accountState); + [[nodiscard]] AccountPtr account() const; + void setAccount(AccountPtr account); [[nodiscard]] QString query() const; void setQuery(const QString &query); Q_SIGNALS: - void accountStateChanged(); + void accountChanged(); void queryChanged(); private: - AccountState *_accountState = nullptr; + AccountPtr _account = nullptr; QJsonArray _searchResults; QString _query; }; diff --git a/src/gui/sharing/share.cpp b/src/gui/sharing/share.cpp new file mode 100644 index 0000000000000..d1b67df31f367 --- /dev/null +++ b/src/gui/sharing/share.cpp @@ -0,0 +1,130 @@ + +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "share.h" + +#include +#include +#include +#include +#include + +#include "property.h" + +Q_LOGGING_CATEGORY(lcSharingShare, "nextcloud.gui.sharing.share", QtInfoMsg) + +using namespace Qt::StringLiterals; + +using namespace OCC::Gui::Sharing; + +QPointer Share::fromJson(const QJsonDocument &json, const AccountPtr &account) +{ + auto share = QPointer{new Share(account)}; + share->updateFromJson(json); + return share; +} + +void Share::updateFromJson(const QJsonDocument &json) +{ + qCDebug(lcSharingShare) << "updating share from json" << json; + const auto data = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject(); + setId(data.value("id"_L1).toString()); + setState(data.value("state"_L1).toString()); + setPermissions(data.value("permissions"_L1).toArray()); + setProperties(data.value("properties"_L1).toArray()); +} + +Share::Share(const AccountPtr &account) + :_account{account} +{} + +QString Share::id() const +{ + return _id; +} + +Share::ShareState Share::state() const +{ + return _state; +} + +const QList> &Share::permissions() const +{ + return _permissions; +} + +const QList> &Share::properties() const +{ + return _properties; +} + +void Share::setId(const QString &id) +{ + if (_id == id) { + return; + } + + _id = id; + Q_EMIT idChanged(); +} + +void Share::setState(const QString &state) +{ + ShareState newState = ShareState::Draft; + + if (state == "active"_L1) { + newState = ShareState::Active; + } else if (state == "deleted"_L1) { + newState = ShareState::Deleted; + } + + if (_state == newState) { + return; + } + + _state = newState; + Q_EMIT stateChanged(); +} + +void Share::setPermissions(const QJsonArray &permissions) +{ + _permissions.clear(); + + if (permissions.isEmpty()) { + Q_EMIT permissionsChanged(); + return; + } + + for (const auto &permissionValue : permissions) { + if (!permissionValue.isObject()) { + continue; + } + const auto permissionObject = permissionValue.toObject(); + _permissions.append(Permission::fromJson(permissionObject)); + } + + Q_EMIT permissionsChanged(); +} + +void Share::setProperties(const QJsonArray &properties) +{ + _properties.clear(); + + if (properties.isEmpty()) { + Q_EMIT propertiesChanged(); + return; + } + + for (const auto &propertyValue : properties) { + if (!propertyValue.isObject()) { + continue; + } + const auto propertyObject = propertyValue.toObject(); + _properties.append(Property::fromJson(propertyObject)); + } + + Q_EMIT propertiesChanged(); +} diff --git a/src/gui/sharing/share.h b/src/gui/sharing/share.h new file mode 100644 index 0000000000000..8b45768c631e8 --- /dev/null +++ b/src/gui/sharing/share.h @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include + +#include + +#include "permission.h" +#include "property.h" + +#include "accountfwd.h" + +namespace OCC::Gui::Sharing { + +class Share : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("created via SharingController") + + Q_PROPERTY(QString id READ id NOTIFY idChanged) + Q_PROPERTY(Share::ShareState state READ state NOTIFY stateChanged) + Q_PROPERTY(QList> permissions READ permissions NOTIFY permissionsChanged) + Q_PROPERTY(QList> properties READ properties NOTIFY propertiesChanged) + +public: + [[nodiscard]] static QPointer fromJson(const QJsonDocument &json, const AccountPtr &account); + + enum class ShareState { + Draft, + Active, + Deleted + }; + Q_ENUM(ShareState) + + void updateFromJson(const QJsonDocument &json); + + [[nodiscard]] QString id() const; + [[nodiscard]] ShareState state() const; + [[nodiscard]] const QList> &permissions() const; + [[nodiscard]] const QList> &properties() const; + +Q_SIGNALS: + void idChanged(); + void stateChanged(); + void permissionsChanged(); + void propertiesChanged(); + +private: + AccountPtr _account; + QString _id; + ShareState _state = ShareState::Draft; + QList> _permissions; + QList> _properties; + + explicit Share(const AccountPtr &account); + + void setId(const QString &id); + void setState(const QString &state); + void setPermissions(const QJsonArray &permissions); + void setProperties(const QJsonArray &properties); +}; + +} diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp new file mode 100644 index 0000000000000..342822bb7f562 --- /dev/null +++ b/src/gui/sharing/sharingcontroller.cpp @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "sharingcontroller.h" + +#include +#include + +#include "share.h" +#include "ocssharingjob.h" + +Q_LOGGING_CATEGORY(lcSharingController, "nextcloud.gui.sharing.sharingcontroller", QtInfoMsg) + +using namespace Qt::StringLiterals; + +using namespace OCC; +using namespace OCC::Gui::Sharing; + +SharingController::SharingController(QObject *parent) + : QObject{parent} +{ +} + +AccountPtr SharingController::account() const +{ + return _account; +} + +void SharingController::setAccount(AccountPtr account) +{ + if (_account == account) { + return; + } + + _account = account; + Q_EMIT accountChanged(); +} + +Share *SharingController::share() const +{ + return _share.get(); +} + +void SharingController::createShare(const QString &fileId) +{ + if (!_account) { + qCWarning(lcSharingController) << "attempted to create a new share without an account set"; + return; + } + + const auto job = new OcsSharingJob(_account); + connect(job, &OcsSharingJob::shareCreated, this, [this, fileId](QPointer share) -> void { + if (!share) { + qCWarning(lcSharingController) << "share created without a valid Share object"; + return; + } + + share->setParent(this); + _share = share; + Q_EMIT shareChanged(); + addSourceAfterCreation(fileId); + }); + job->createShare(); +} + +void SharingController::addRecipient(const QString &recipientType, const QString &recipientValue) +{ + if (!_account) { + qCWarning(lcSharingController) << "attempted to add a new recipient to a share without an account set"; + return; + } + + if (!_share) { + qCWarning(lcSharingController) << "attempted to add a new recipient without a share"; + return; + } + + const auto job = new OcsSharingJob(_account, _share->id()); + connect(job, &OcsSharingJob::jobFinished, this, [this](const QJsonDocument &json, int statusCode) -> void { + qCDebug(lcSharingController).nospace() << "recipient added" + << " id=" << _share->id(); + _share->updateFromJson(json); + }); + job->addRecipient(recipientType, recipientValue); +} + +void SharingController::addSourceAfterCreation(const QString &fileId) +{ + const auto job = new OcsSharingJob(_account, _share->id()); + connect(job, &OcsSharingJob::jobFinished, this, [this, &fileId](const QJsonDocument &json, int statusCode) -> void { + qCDebug(lcSharingController).nospace() << "share created" + << " id=" << _share->id() + << " fileId=" << fileId; + _share->updateFromJson(json); + }); + job->addSource(fileId); +} diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h new file mode 100644 index 0000000000000..1508498c98ce4 --- /dev/null +++ b/src/gui/sharing/sharingcontroller.h @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include +#include + +#include "accountfwd.h" + +namespace OCC::Gui::Sharing { + +class Share; + +class SharingController : public QObject +{ + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(AccountPtr account READ account WRITE setAccount NOTIFY accountChanged) + Q_PROPERTY(Share *share READ share NOTIFY shareChanged) + +public: + SharingController(QObject *parent = nullptr); + + [[nodiscard]] AccountPtr account() const; + void setAccount(AccountPtr account); + + [[nodiscard]] Share *share() const; + + Q_INVOKABLE void createShare(const QString &fileId); + Q_INVOKABLE void addRecipient(const QString &recipientType, const QString &recipientValue); + +Q_SIGNALS: + void accountChanged(); + void shareChanged(); + +private: + AccountPtr _account; + QPointer _share; + + void addSourceAfterCreation(const QString &fileId); +}; + +} diff --git a/src/gui/sharing/sharingfiltermodel.cpp b/src/gui/sharing/sharingfiltermodel.cpp index 1c89a9f635abf..adae37432e92f 100644 --- a/src/gui/sharing/sharingfiltermodel.cpp +++ b/src/gui/sharing/sharingfiltermodel.cpp @@ -68,17 +68,17 @@ bool SharingFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sour return true; } - const auto sharing = accountState->account()->sharing(); + // const auto sharing = accountState->account()->sharing(); const auto featureRole = model->data(index, SharingModel::LabelRole).toString(); // TODO: this will not be label if (featureRole == "Add people"_L1) { // TODO: same as above for now; >1 because link shares are only one recipient types return _filterType == General && _recipientTypes.size() > 1; } - if (!sharing->isFeatureAvailable(featureRole, {"OCA\\Files\\Sharing\\SourceType\\NodeShareSourceType"}, _recipientTypes)) { + // if (!sharing->isFeatureAvailable(featureRole, {"OCA\\Files\\Sharing\\SourceType\\NodeShareSourceType"}, _recipientTypes)) { // this feature is not available in general for these recipient types - return false; - } + // return false; + // } bool isAdvancedProperty = !featureRole.contains("NoteShareFeature"); diff --git a/src/gui/sharing/sharingmodel.cpp b/src/gui/sharing/sharingmodel.cpp index 41b3481cbf878..ad71dba34de45 100644 --- a/src/gui/sharing/sharingmodel.cpp +++ b/src/gui/sharing/sharingmodel.cpp @@ -5,6 +5,8 @@ #include "sharingmodel.h" +#include + #include "account.h" using namespace Qt::StringLiterals; @@ -17,56 +19,62 @@ SharingModel::SharingModel(QObject *parent) int SharingModel::rowCount(const QModelIndex &parent) const { - if (!_accountState) { + if (!(_accountState && _share)) { return 0; } - // TODO: cache this after setting accountstate - const auto sharing = accountState()->account()->sharing(); - const auto features = sharing->features().values(); - return features.size() + 1; // TODO: +1 for the special feature at the beginning for the recipients + return _share->properties().size(); } QVariant SharingModel::data(const QModelIndex &index, int role) const { - if (!_accountState) { + if (!(_accountState && _share)) { return {}; } - if (index.row() == 0) { - // special feature for the recipients search - switch (role) { - case LabelRole: - return "Add people"_L1; - case PropertyRole: - return "recipients"_L1; - case TypeRole: - return FieldTypes::RecipientsField; - case PlaceholderRole: - return "Name, team, email, or federated cloud ID"_L1; - case ValueRole: - return _fieldValues.value("recipients"_L1).toList(); // TODO - default: - return {}; - } - } - - // TODO: cache this after setting accountstate - const auto sharing = accountState()->account()->sharing(); - const auto features = sharing->features().values(); - const auto feature = features.at(index.row() - 1); // TODO: -1 to adjust the special feature for searching + // // if (index.row() == 0) { + // // // special feature for the recipients search + // // switch (role) { + // // case LabelRole: + // // return "Add people"_L1; + // // case PropertyRole: + // // return "recipients"_L1; + // // case TypeRole: + // // return FieldTypes::RecipientsField; + // // case PlaceholderRole: + // // return "Name, team, email, or federated cloud ID"_L1; + // // case ValueRole: + // // return _fieldValues.value("recipients"_L1).toList(); // TODO + // // default: + // // return {}; + // // } + // // } + // // + // // TODO: cache this after setting accountstate + // const auto sharing = accountState()->account()->sharing(); + // // const auto features = sharing->features().values(); + // // const auto feature = features.at(index.row() - 1); // TODO: -1 to adjust the special feature for searching + // + + const auto properties = _share->properties(); + const auto property = properties.at(index.row()); switch (role) { case LabelRole: - return feature->type(); + return property->displayName(); case PropertyRole: - return u"prop%1"_s.arg(QString::number(index.row())); + return property->className(); case TypeRole: - return static_cast(index.row() % 3); + if (property->className() == "string"_L1) { + return FieldTypes::TextField; + } + // TODO: date etc. + return FieldTypes::TextField; case PlaceholderRole: - return u"Placeholder for row %1"_s.arg(QString::number(index.row())); + return property->hint(); case ValueRole: - return _fieldValues.value(feature->type()); + // return _fieldValues.value(feature->type()); + return property->value(); default: return {}; } @@ -74,6 +82,7 @@ QVariant SharingModel::data(const QModelIndex &index, int role) const bool SharingModel::setData(const QModelIndex &index, const QVariant &value, int role) { + return false; if (!_accountState) { qCritical() << "no accountState, but setData called with" << index << value << role; return false; @@ -91,10 +100,10 @@ bool SharingModel::setData(const QModelIndex &index, const QVariant &value, int } else { // TODO: cache this after setting accountstate const auto sharing = accountState()->account()->sharing(); - const auto features = sharing->features().values(); - const auto feature = features.at(index.row() - 1); // TODO: -1 to adjust the special feature for searching - qCritical() << "changing" << feature->type() << "to" << value; - _fieldValues.insert(feature->type(), value); + // const auto features = sharing->features().values(); + // const auto feature = features.at(index.row() - 1); // TODO: -1 to adjust the special feature for searching + // qCritical() << "changing" << feature->type() << "to" << value; + // _fieldValues.insert(feature->type(), value); } Q_EMIT dataChanged(index, index, {ValueRole}); @@ -129,8 +138,28 @@ void SharingModel::setAccountState(AccountState *accountState) return; } - beginResetModel(); _accountState = accountState; Q_EMIT accountStateChanged(); - endResetModel(); + + auto job = _accountState->account()->sharing()->createShareJob(this); + connect(job, &OCC::JsonApiJob::jsonReceived, this, [this](const QJsonDocument &json, int statusCode) -> void { + beginResetModel(); + qCritical() << "request finished with code" << statusCode << "data" << json; + _share = OCC::Sharing::Share::fromJson(json, _accountState->account()); + qCritical() << "share id:" << _share->id(); + endResetModel(); + + connect(_share.get(), &OCC::Sharing::Share::propertiesChanged, this, [this]() -> void { + beginResetModel(); + endResetModel(); + }); + + _share->addSource("8"_L1); + }); + job->start(); +} + +void SharingModel::addRecipient(const QString &type, const QString &value) +{ + _share->addRecipient(type, value); } diff --git a/src/gui/sharing/sharingmodel.h b/src/gui/sharing/sharingmodel.h index e5ca5b7a721f2..7e8a7f6ec59ab 100644 --- a/src/gui/sharing/sharingmodel.h +++ b/src/gui/sharing/sharingmodel.h @@ -10,6 +10,8 @@ #include "accountstate.h" +#include "sharing/share.h" + namespace OCC::Gui::Sharing { class SharingModel : public QAbstractListModel @@ -47,11 +49,14 @@ class SharingModel : public QAbstractListModel [[nodiscard]] AccountState *accountState() const; void setAccountState(AccountState *accountState); + Q_INVOKABLE void addRecipient(const QString &type, const QString &value); + Q_SIGNALS: void accountStateChanged(); private: AccountState *_accountState = nullptr; + QSharedPointer _share = nullptr; QVariantMap _fieldValues; }; diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index 00db09f44e1ef..a8f07ab67fb11 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -862,16 +862,15 @@ void Systray::createFileDetailsDialog(const QString &localPath) return; } - const QVariantMap initialProperties{ - {"accountState", QVariant::fromValue(folder->accountState())}, - {"localPath", localPath}, - {"sharingManager", QVariant::fromValue(folder->accountState()->account()->sharing())}, - }; - - if (folder->accountState()->account()->sharing()->isAvailable()) { + if (folder->accountState()->account()->capabilities().unifiedSharingAvailable()) { // we have a server with the new unified sharing system, let's show the new fancy one // TODO: reduce code duplication + const QVariantMap initialProperties{ + {"account", QVariant::fromValue(folder->accountState()->account())}, + {"localPath", localPath}, + }; + QQmlComponent fileDetailsDialog(trayEngine(), "com.nextcloud.desktopclient.sharing"_L1, "ShareDialog"_L1); if (fileDetailsDialog.isError()) { @@ -896,6 +895,11 @@ void Systray::createFileDetailsDialog(const QString &localPath) return; } + const QVariantMap initialProperties{ + {"accountState", QVariant::fromValue(folder->accountState())}, + {"localPath", localPath}, + }; + QQmlComponent fileDetailsDialog(trayEngine(), QStringLiteral("qrc:/qml/src/gui/filedetails/FileDetailsWindow.qml")); if (!fileDetailsDialog.isError()) { diff --git a/src/libsync/CMakeLists.txt b/src/libsync/CMakeLists.txt index 3f5dcabb922d4..be060e8ce275e 100644 --- a/src/libsync/CMakeLists.txt +++ b/src/libsync/CMakeLists.txt @@ -110,12 +110,6 @@ set(libsync_SRCS propagateuploadencrypted.cpp propagatedownloadencrypted.h propagatedownloadencrypted.cpp - sharing/feature.h - sharing/feature.cpp - sharing/sharetype.h - sharing/sharetype.cpp - sharing/sharingmanager.h - sharing/sharingmanager.cpp syncengine.h syncengine.cpp syncfileitem.h diff --git a/src/libsync/account.cpp b/src/libsync/account.cpp index 6256d548fd002..a9ff3c9adda4c 100644 --- a/src/libsync/account.cpp +++ b/src/libsync/account.cpp @@ -53,8 +53,6 @@ #include "creds/abstractcredentials.h" -#include "sharing/sharingmanager.h" - using namespace QKeychain; namespace { @@ -100,7 +98,6 @@ AccountPtr Account::create() AccountPtr acc = AccountPtr(new Account); acc->setSharedThis(acc); acc->_e2e.setAccount(acc); - acc->_sharingManager = new Sharing::SharingManager{acc, acc.get()}; return acc; } @@ -781,9 +778,6 @@ void Account::setCapabilities(const QVariantMap &caps) trySetupPushNotifications(); trySetupClientStatusReporting(); - if (_sharingManager) { - _sharingManager->updateFromCapabilities(caps); - } } void Account::setupUserStatusConnector() @@ -1595,9 +1589,4 @@ void Account::updateServerHasIntegration() _serverHasIntegration = capabilities().serverHasClientIntegration(); } -Sharing::SharingManager *Account::sharing() const -{ - return _sharingManager; -} - } // namespace OCC diff --git a/src/libsync/account.h b/src/libsync/account.h index 22c9da5532277..7d890ba3f5f63 100644 --- a/src/libsync/account.h +++ b/src/libsync/account.h @@ -13,7 +13,6 @@ #include "clientstatusreporting.h" #include "common/utility.h" #include "common/vfs.h" -#include "sharing/sharingmanager.h" #include "syncfileitem.h" #include "updatechannel.h" @@ -57,10 +56,6 @@ class PushNotifications; class UserStatusConnector; class SyncJournalDb; -namespace Sharing { - class SharingManager; -} - /** * @brief Reimplement this to handle SSL errors from libsync * @ingroup libsync @@ -445,8 +440,6 @@ class OWNCLOUDSYNC_EXPORT Account : public QObject [[nodiscard]] bool serverHasIntegration() const; - [[nodiscard]] Sharing::SharingManager *sharing() const; - public slots: /// Used when forgetting credentials void clearQNAMCache(); @@ -610,8 +603,6 @@ private slots: UpdateChannel _enterpriseUpdateChannel = UpdateChannel::Invalid; QByteArray _encryptionCertificateFingerprint; - Sharing::SharingManager *_sharingManager = nullptr; - #ifdef BUILD_FILE_PROVIDER_MODULE QString _fileProviderDomainIdentifier; QByteArray _lastRootETag; // Runtime-only, not persisted diff --git a/src/libsync/capabilities.cpp b/src/libsync/capabilities.cpp index c15d393e22821..ab94b094bcbb1 100644 --- a/src/libsync/capabilities.cpp +++ b/src/libsync/capabilities.cpp @@ -103,6 +103,11 @@ int Capabilities::shareDefaultPermissions() const return {}; } +bool Capabilities::unifiedSharingAvailable() const +{ + return _capabilities.contains("sharing"_L1); +} + bool Capabilities::clientSideEncryptionAvailable() const { auto it = _capabilities.constFind(QStringLiteral("end-to-end-encryption")); diff --git a/src/libsync/capabilities.h b/src/libsync/capabilities.h index 828fd58ef36f6..8226006a3b239 100644 --- a/src/libsync/capabilities.h +++ b/src/libsync/capabilities.h @@ -50,6 +50,7 @@ class OWNCLOUDSYNC_EXPORT Capabilities [[nodiscard]] int shareRemoteExpireDateDays() const; [[nodiscard]] bool shareResharing() const; [[nodiscard]] int shareDefaultPermissions() const; + [[nodiscard]] bool unifiedSharingAvailable() const; [[nodiscard]] bool chunkingNg() const; [[nodiscard]] qint64 maxChunkSize() const; [[nodiscard]] int maxConcurrentChunkUploads() const; diff --git a/src/libsync/sharing/feature.cpp b/src/libsync/sharing/feature.cpp deleted file mode 100644 index 031c5b64e3b4c..0000000000000 --- a/src/libsync/sharing/feature.cpp +++ /dev/null @@ -1,44 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - - -#include "feature.h" - -#include - -using namespace Qt::StringLiterals; - -using namespace OCC::Sharing; - -QSharedPointer Feature::fromCapability(const QVariantMap &object) -{ - const auto type = object.value("type"_L1).toString(); - const auto compatibleSourceTypes = object.value("compatible_source_types"_L1).toStringList(); - const auto compatibleRecipientTypes = object.value("compatible_recipient_types"_L1).toStringList(); - - return QSharedPointer{new Feature{type, compatibleSourceTypes, compatibleRecipientTypes}}; -} - - -Feature::Feature(const QString &type, const QStringList &compatibleSourceTypes, const QStringList &compatibleRecipientTypes) - : _type{type} - , _compatibleSourceTypes{compatibleSourceTypes} - , _compatibleRecipientTypes{compatibleRecipientTypes} -{} - -QString Feature::type() const -{ - return _type; -} - -QStringList Feature::compatibleSourceTypes() const -{ - return _compatibleSourceTypes; -} - -QStringList Feature::compatibleRecipientTypes() const -{ - return _compatibleRecipientTypes; -} diff --git a/src/libsync/sharing/feature.h b/src/libsync/sharing/feature.h deleted file mode 100644 index 6f1c53992f909..0000000000000 --- a/src/libsync/sharing/feature.h +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#pragma once - -#include -#include -#include - -namespace OCC::Sharing { - -class Feature : public QObject -{ - Q_OBJECT - Q_PROPERTY(QString type READ type) - -public: - [[nodiscard]] static QSharedPointer fromCapability(const QVariantMap &object); - - [[nodiscard]] QString type() const; - [[nodiscard]] QStringList compatibleSourceTypes() const; - [[nodiscard]] QStringList compatibleRecipientTypes() const; - -private: - QString _type; - QStringList _compatibleSourceTypes; - QStringList _compatibleRecipientTypes; - - explicit Feature(const QString &type, const QStringList &compatibleSourceTypes, const QStringList &compatibleRecipientTypes); -}; - -} - diff --git a/src/libsync/sharing/sharetype.cpp b/src/libsync/sharing/sharetype.cpp deleted file mode 100644 index 3c15c62c66dda..0000000000000 --- a/src/libsync/sharing/sharetype.cpp +++ /dev/null @@ -1,38 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - - -#include "sharetype.h" - -#include - -using namespace Qt::StringLiterals; - -using namespace OCC::Sharing; - -QSharedPointer ShareType::fromCapability(const QVariantMap &object) -{ - const auto type = object.value("type"_L1).toString(); - const auto displayName = object.value("displayName"_L1).toString(); - - auto shareType = new ShareType{type, displayName}; - - return QSharedPointer{shareType}; -} - -ShareType::ShareType(const QString &type, const QString &displayName) - : _type{type} - , _displayName{displayName} -{} - -QString ShareType::type() const -{ - return _type; -} - -QString ShareType::displayName() const -{ - return _displayName; -} diff --git a/src/libsync/sharing/sharetype.h b/src/libsync/sharing/sharetype.h deleted file mode 100644 index 03c8671345640..0000000000000 --- a/src/libsync/sharing/sharetype.h +++ /dev/null @@ -1,34 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#pragma once - -#include -#include -#include - -namespace OCC::Sharing { - -class ShareType : public QObject -{ - Q_OBJECT - Q_PROPERTY(QString type READ type) - Q_PROPERTY(QString displayName READ displayName) - -public: - [[nodiscard]] static QSharedPointer fromCapability(const QVariantMap &object); - - [[nodiscard]] QString type() const; - [[nodiscard]] QString displayName() const; - -private: - QString _type; - QString _displayName; - - explicit ShareType(const QString &type, const QString &displayName); -}; - -} - diff --git a/src/libsync/sharing/sharingmanager.cpp b/src/libsync/sharing/sharingmanager.cpp deleted file mode 100644 index f6c02ba08882a..0000000000000 --- a/src/libsync/sharing/sharingmanager.cpp +++ /dev/null @@ -1,148 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#include "sharingmanager.h" - -#include -#include - -using namespace Qt::StringLiterals; - -using namespace OCC::Sharing; - -namespace -{ -template -void parseTypes(const QVariantMap &capability, const QString &key, QMap> &typeMap) { - const auto propertyList = capability.value(key).toList(); - - typeMap.clear(); - for (const auto &typeObject : propertyList) { - const auto object = typeObject.toMap(); - auto typeInstance = T::fromCapability(object); - if (!typeInstance) { - continue; - } - - typeMap.insert(typeInstance->type(), typeInstance); - } -}; -} - -SharingManager::SharingManager(AccountPtr account, QObject *parent) - : QObject{parent} - , _account{account} -{ - -} - -void SharingManager::updateFromCapabilities(const QVariantMap &capabilities) -{ - if (const auto property = capabilities.value("sharing"_L1); !property.isValid()) { - // no sharing capability present, we're done here - setAvailable(false); - return; - } - setAvailable(true); - - const auto capability = capabilities.value("sharing"_L1).toMap(); - - if (const auto property = capability.value("api_versions"_L1); property.isValid() && property.canConvert()) { - _apiVersions = property.value(); - } - - parseTypes(capability, "source_types"_L1, _sourceTypes); - parseTypes(capability, "recipient_types"_L1, _recipientTypes); - parseTypes(capability, "features"_L1, _features); -} - -QList> SharingManager::availableFeatures(const QStringList &sourceTypes, const QStringList &recipientTypes) const -{ - QList> features; - - const auto sourceTypesSet = QSet(sourceTypes.cbegin(), sourceTypes.cend()); - const auto recipientTypesSet = QSet(recipientTypes.cbegin(), recipientTypes.cend()); - - for (const auto &feature : std::as_const(_features)) { - if (const auto compatibleSourceTypes = feature->compatibleSourceTypes(); - !sourceTypesSet.intersects(QSet(compatibleSourceTypes.cbegin(), compatibleSourceTypes.cend()))) { - continue; - } - if (const auto compatibleRecipientTypes = feature->compatibleRecipientTypes(); - !recipientTypesSet.intersects(QSet(compatibleRecipientTypes.cbegin(), compatibleRecipientTypes.cend()))) { - continue; - } - - features.append(feature); - } - - return features; -} - -bool SharingManager::isFeatureAvailable(const QString &feature, const QStringList &sourceTypes, const QStringList &recipientTypes) const -{ - if (!_features.contains(feature)) { - return false; - } - const auto featureObject = _features.value(feature); - - const auto sourceTypesSet = QSet(sourceTypes.cbegin(), sourceTypes.cend()); - const auto recipientTypesSet = QSet(recipientTypes.cbegin(), recipientTypes.cend()); - - if (const auto compatibleSourceTypes = featureObject->compatibleSourceTypes(); - !sourceTypesSet.intersects(QSet(compatibleSourceTypes.cbegin(), compatibleSourceTypes.cend()))) { - return false; - } - - if (const auto compatibleRecipientTypes = featureObject->compatibleRecipientTypes(); - !recipientTypesSet.intersects(QSet(compatibleRecipientTypes.cbegin(), compatibleRecipientTypes.cend()))) { - return false; - } - - return true; -} - -bool SharingManager::isAvailable() const -{ - return _available; -} - -void SharingManager::setAvailable(bool available) -{ - if (_available == available) { - return; - } - - _available = available; - Q_EMIT availableChanged(); -} - -QMap> SharingManager::sourceTypes() const -{ - return _sourceTypes; -} - -QMap> SharingManager::recipientTypes() const -{ - return _recipientTypes; -} - -QMap> SharingManager::features() const -{ - return _features; -} - -OCC::JsonApiJob *SharingManager::createSearchJob(const QString &query, int64_t offset, int64_t limit, QObject *parent) -{ - // TODO: use promises? - auto job = new JsonApiJob(_account, "/ocs/v2.php/apps/sharing/api/v1/recipients"_L1, parent); - QUrlQuery params; - params.addQueryItem("query"_L1, query); - params.addQueryItem("offset"_L1, QString::number(offset)); - params.addQueryItem("limit"_L1, QString::number(limit)); - job->addQueryParams(params); - - return job; -} diff --git a/src/libsync/sharing/sharingmanager.h b/src/libsync/sharing/sharingmanager.h deleted file mode 100644 index 3ba7d710d45d3..0000000000000 --- a/src/libsync/sharing/sharingmanager.h +++ /dev/null @@ -1,61 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#pragma once - -#include -#include - -#include "accountfwd.h" -#include "networkjobs.h" - -#include "sharetype.h" -#include "feature.h" - -namespace OCC::Sharing { - -class SharingManager : public QObject -{ - Q_OBJECT - - Q_PROPERTY(bool available READ isAvailable NOTIFY availableChanged) - -public: - explicit SharingManager(AccountPtr account, QObject *parent = nullptr); - - void updateFromCapabilities(const QVariantMap &capabilities); - - [[nodiscard]] Q_INVOKABLE QList> availableFeatures(const QStringList &sourceTypes, const QStringList &recipientTypes) const; - [[nodiscard]] Q_INVOKABLE bool isFeatureAvailable(const QString &feature, const QStringList &sourceTypes, const QStringList &recipientTypes) const; - - /** - * \brief Whether the new sharing system is available (announced via capabilities). - */ - [[nodiscard]] bool isAvailable() const; - - [[nodiscard]] QMap> sourceTypes() const; - [[nodiscard]] QMap> recipientTypes() const; - [[nodiscard]] QMap> features() const; - - JsonApiJob *createSearchJob(const QString &query, int64_t offset, int64_t limit, QObject *parent); - -Q_SIGNALS: - void availableChanged(); - -private: - AccountPtr _account; - - bool _available = false; - - QStringList _apiVersions; - QMap> _sourceTypes; - QMap> _recipientTypes; - QMap> _features; - - void setAvailable(bool available); -}; - -} - From a716cfdfa20564e954b476cf2b97f28c255e3aad Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Wed, 17 Jun 2026 18:48:05 +0200 Subject: [PATCH 028/104] feat(sharing): display permissions Signed-off-by: Jyrki Gadinger --- src/gui/sharing/CMakeLists.txt | 2 + src/gui/sharing/MainPage.qml | 48 ++++++++++-- src/gui/sharing/ocssharingjob.cpp | 16 ++++ src/gui/sharing/ocssharingjob.h | 2 + src/gui/sharing/permissionmodel.cpp | 106 ++++++++++++++++++++++++++ src/gui/sharing/permissionmodel.h | 48 ++++++++++++ src/gui/sharing/sharingcontroller.cpp | 21 +++++ src/gui/sharing/sharingcontroller.h | 2 + 8 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 src/gui/sharing/permissionmodel.cpp create mode 100644 src/gui/sharing/permissionmodel.h diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index ba4bc0fa521c7..298b87ade2302 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -9,6 +9,8 @@ target_sources(nextcloudGuiSharing ocssharingjob.cpp permission.h permission.cpp + permissionmodel.h + permissionmodel.cpp property.h property.cpp propertymodel.h diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index e8f57260ecd8d..244eb22b3b178 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -36,16 +36,50 @@ Page { contentHeight: availableHeight rightPadding: ScrollBar.vertical.policy == ScrollBar.AlwaysOn ? ScrollBar.vertical.width + Style.standardSpacing : 0 - RecipientSearchField { - id: searchField - Layout.fillWidth: true - account: root.sharingController.account + ColumnLayout { + RecipientSearchField { + id: searchField + Layout.fillWidth: true + account: root.sharingController.account + + onRecipientSelected: (recipientType, recipientValue) => { + root.sharingController.addRecipient(recipientType, recipientValue) + } + } - onRecipientSelected: (recipientType, recipientValue) => { - root.sharingController.addRecipient(recipientType, recipientValue) + Repeater { + id: permissionsList + Layout.fillWidth: true + + model: PermissionModel { + share: root.sharingController.share + } + + delegate: ItemDelegate { + // Layout.fillWidth: true + + required property var model + RowLayout { + Layout.fillWidth: true + Label { + text: model.label + Layout.fillWidth: true + } + Switch { + Component.onCompleted: checked = model.enabled ?? false + onCheckedChanged: { + if (model.enabled === checked) { + return; + } + // model.enabled = checked + root.sharingController.setPermission(model.className, checked) + } + } + } + } } - } + } ListView { id: propertyList diff --git a/src/gui/sharing/ocssharingjob.cpp b/src/gui/sharing/ocssharingjob.cpp index 6768fcd7d6363..180695890e096 100644 --- a/src/gui/sharing/ocssharingjob.cpp +++ b/src/gui/sharing/ocssharingjob.cpp @@ -93,3 +93,19 @@ void OcsSharingJob::searchRecipients(const QString &query, int64_t offset, int64 start(); } +void OcsSharingJob::setPermission(const QString &permissionClass, bool enabled) +{ + if (_shareId.isEmpty()) { + qCWarning(lcOcsSharingJob) << "setPermission called without a shareId, not starting job"; + Q_EMIT jobFinished({}, 0); + return; + } + + setPath(SHARING_V1_BASE % "/share/%1/enabled"_L1.arg(_shareId)); + setVerb("PUT"_ba); + + addParam("class"_L1, permissionClass); + addParam("enabled"_L1, enabled ? "true"_L1 : "false"_L1); + + start(); +} diff --git a/src/gui/sharing/ocssharingjob.h b/src/gui/sharing/ocssharingjob.h index 2b1973f67b3a0..06904753e6d2d 100644 --- a/src/gui/sharing/ocssharingjob.h +++ b/src/gui/sharing/ocssharingjob.h @@ -32,6 +32,8 @@ class OcsSharingJob : public OcsJob void searchRecipients(const QString &query, int64_t offset, int64_t limit); + void setPermission(const QString &permissionClass, bool enabled); + Q_SIGNALS: void shareCreated(QPointer share); diff --git a/src/gui/sharing/permissionmodel.cpp b/src/gui/sharing/permissionmodel.cpp new file mode 100644 index 0000000000000..e3baf3de3fcee --- /dev/null +++ b/src/gui/sharing/permissionmodel.cpp @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "permissionmodel.h" + +#include +#include + +#include "share.h" +#include "permission.h" + +using namespace Qt::StringLiterals; +using namespace OCC; +using namespace OCC::Gui::Sharing; + +PermissionModel::PermissionModel(QObject *parent) + : QAbstractListModel{parent} +{} + +int PermissionModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + + if (!_share) { + return 0; + } + + qCritical() << "permissions size:" << _share->permissions().size(); + return _share->permissions().size(); +} + +QVariant PermissionModel::data(const QModelIndex &index, int role) const +{ + if (!_share) { + return {}; + } + + const auto permissions = _share->permissions(); + const auto permission = permissions.at(index.row()); + + switch (role) { + case LabelRole: + return permission->displayName(); + case ClassNameRole: + return permission->className(); + case PlaceholderRole: + return permission->hint(); + case EnabledRole: + return permission->enabled(); + default: + return {}; + } +} + +bool PermissionModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + qCritical() << "setData called with" << index << value << role; + + if (role != EnabledRole) { + return false; + } + + const auto newValue = value.toBool(); + + Q_EMIT dataChanged(index, index, {EnabledRole}); + return true; +} + +Qt::ItemFlags PermissionModel::flags(const QModelIndex &index) const +{ + qCritical() << "flags for" << index << QAbstractListModel::flags(index) << Qt::ItemIsEditable << "returning" << (QAbstractListModel::flags(index) | Qt::ItemIsEditable); + return QAbstractListModel::flags(index) | Qt::ItemIsEditable; +} + +QHash PermissionModel::roleNames() const +{ + return { + { LabelRole, "label"_ba}, + { ClassNameRole, "className"_ba}, + { PlaceholderRole, "hint"_ba}, + { EnabledRole, "enabled"_ba}, + }; +}; + +Share *PermissionModel::share() const +{ + return _share; +} + +void PermissionModel::setShare(Share *share) +{ + if (_share == share) { + return; + } + + beginResetModel(); + _share = share; + connect(_share, &Share::permissionsChanged, this, [this]() -> void { + beginResetModel(); + endResetModel(); + }); + Q_EMIT shareChanged(); + endResetModel(); +} diff --git a/src/gui/sharing/permissionmodel.h b/src/gui/sharing/permissionmodel.h new file mode 100644 index 0000000000000..e715dc70e2742 --- /dev/null +++ b/src/gui/sharing/permissionmodel.h @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include + +#include "share.h" + +namespace OCC::Gui::Sharing { + +class PermissionModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(Share *share READ share WRITE setShare NOTIFY shareChanged) + +public: + enum Roles { + LabelRole = Qt::UserRole, + ClassNameRole, + PlaceholderRole, + EnabledRole, + }; + + explicit PermissionModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; + Qt::ItemFlags flags(const QModelIndex &index) const override; + QHash roleNames() const override; + + [[nodiscard]] Share* share() const; + void setShare(Share* share); + +Q_SIGNALS: + void shareChanged(); + +private: + Share *_share = nullptr; +}; + +} diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index 342822bb7f562..353d85d96ac9c 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -86,6 +86,27 @@ void SharingController::addRecipient(const QString &recipientType, const QString job->addRecipient(recipientType, recipientValue); } +void SharingController::setPermission(const QString &permissionClass, bool enabled) +{ + if (!_account) { + qCWarning(lcSharingController) << "attempted to set permission without an account set"; + return; + } + + if (!_share) { + qCWarning(lcSharingController) << "attempted to set permission without a share"; + return; + } + + const auto job = new OcsSharingJob(_account, _share->id()); + connect(job, &OcsSharingJob::jobFinished, this, [this](const QJsonDocument &json, int statusCode) -> void { + qCDebug(lcSharingController).nospace() << "permissions updated" + << " id=" << _share->id(); + _share->updateFromJson(json); + }); + job->setPermission(permissionClass, enabled); +} + void SharingController::addSourceAfterCreation(const QString &fileId) { const auto job = new OcsSharingJob(_account, _share->id()); diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index 1508498c98ce4..8fe1c0bbb15f1 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -34,6 +34,8 @@ class SharingController : public QObject Q_INVOKABLE void createShare(const QString &fileId); Q_INVOKABLE void addRecipient(const QString &recipientType, const QString &recipientValue); + Q_INVOKABLE void setPermission(const QString &permissionClass, bool enabled); + Q_SIGNALS: void accountChanged(); void shareChanged(); From 871a81510df8fc5ce6650e4d8f79371c840adb17 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Wed, 17 Jun 2026 18:49:46 +0200 Subject: [PATCH 029/104] feat(sharing): add recipients Signed-off-by: Jyrki Gadinger --- src/gui/sharing/CMakeLists.txt | 4 - src/gui/sharing/ShareDialog.qml | 10 ++ src/gui/sharing/ocssharingjob.cpp | 31 +++++ src/gui/sharing/ocssharingjob.h | 2 + src/gui/sharing/sharingcontroller.cpp | 43 ++++++- src/gui/sharing/sharingcontroller.h | 2 + src/gui/sharing/sharingfiltermodel.cpp | 93 -------------- src/gui/sharing/sharingfiltermodel.h | 48 ------- src/gui/sharing/sharingmodel.cpp | 165 ------------------------- src/gui/sharing/sharingmodel.h | 63 ---------- 10 files changed, 87 insertions(+), 374 deletions(-) delete mode 100644 src/gui/sharing/sharingfiltermodel.cpp delete mode 100644 src/gui/sharing/sharingfiltermodel.h delete mode 100644 src/gui/sharing/sharingmodel.cpp delete mode 100644 src/gui/sharing/sharingmodel.h diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index 298b87ade2302..a6b7a884982ef 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -19,12 +19,8 @@ target_sources(nextcloudGuiSharing recipientsearchmodel.cpp share.h share.cpp - # sharingfiltermodel.h - # sharingfiltermodel.cpp sharingcontroller.h sharingcontroller.cpp - # sharingmodel.h - # sharingmodel.cpp ) ecm_add_qml_module(nextcloudGuiSharing diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 92ac173dc0130..8278a4f10b10a 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -191,6 +191,16 @@ ApplicationWindow { checkable: true ButtonGroup.group: shareType + enabled: false // TODO: figure out why link shares don't work + + onCheckedChanged: { + const parameters = ["OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType", qsTr("Link share")] + if (checked) { + sharingController.addRecipient(...parameters) + return + } + sharingController.removeRecipient(...parameters) + } } } diff --git a/src/gui/sharing/ocssharingjob.cpp b/src/gui/sharing/ocssharingjob.cpp index 180695890e096..c1babb71e5f2f 100644 --- a/src/gui/sharing/ocssharingjob.cpp +++ b/src/gui/sharing/ocssharingjob.cpp @@ -47,6 +47,20 @@ void OcsSharingJob::createShare() start(); } +void OcsSharingJob::destroyShare() +{ + if (_shareId.isEmpty()) { + qCWarning(lcOcsSharingJob) << "destroyShare called without a shareId, not starting job"; + Q_EMIT jobFinished({}, 0); + return; + } + + setPath(SHARING_V1_BASE % "/share/%1"_L1.arg(_shareId)); + setVerb("DELETE"_ba); + addPassStatusCode(204); + start(); +} + void OcsSharingJob::addSource(const QString &fileId) { if (_shareId.isEmpty()) { @@ -81,6 +95,23 @@ void OcsSharingJob::addRecipient(const QString &recipientType, const QString &re start(); } +void OcsSharingJob::removeRecipient(const QString &recipientType, const QString &recipientValue) +{ + if (_shareId.isEmpty()) { + qCWarning(lcOcsSharingJob) << "removeRecipient called without a shareId, not starting job"; + Q_EMIT jobFinished({}, 0); + return; + } + + setPath(SHARING_V1_BASE % "/share/%1/recipient"_L1.arg(_shareId)); + setVerb("DELETE"_ba); + + addParam("class"_L1, recipientType); + addParam("value"_L1, recipientValue); + + start(); +} + void OcsSharingJob::searchRecipients(const QString &query, int64_t offset, int64_t limit) { setPath(SHARING_V1_BASE % "/recipients"); diff --git a/src/gui/sharing/ocssharingjob.h b/src/gui/sharing/ocssharingjob.h index 06904753e6d2d..a115c4f1f5b38 100644 --- a/src/gui/sharing/ocssharingjob.h +++ b/src/gui/sharing/ocssharingjob.h @@ -26,9 +26,11 @@ class OcsSharingJob : public OcsJob explicit OcsSharingJob(AccountPtr account, const QString &shareId = {}); void createShare(); + void destroyShare(); void addSource(const QString &fileId); void addRecipient(const QString &recipientType, const QString &recipientValue); + void removeRecipient(const QString &recipientType, const QString &recipientValue); void searchRecipients(const QString &query, int64_t offset, int64_t limit); diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index 353d85d96ac9c..a0c7cb15d5e55 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -65,6 +65,26 @@ void SharingController::createShare(const QString &fileId) job->createShare(); } +void SharingController::destroyShare() +{ + if (!_account) { + qCWarning(lcSharingController) << "attempted to create a new share without an account set"; + return; + } + + if (!_share) { + qCWarning(lcSharingController) << "attempted to destroty a share without a share"; + return; + } + + const auto job = new OcsSharingJob(_account); + connect(job, &OcsSharingJob::jobFinished, this, [this](const QJsonDocument &json, int statusCode) -> void { + _share = nullptr; + Q_EMIT shareChanged(); // TODO: shareDeleted maybe? + }); + job->createShare(); +} + void SharingController::addRecipient(const QString &recipientType, const QString &recipientValue) { if (!_account) { @@ -86,6 +106,27 @@ void SharingController::addRecipient(const QString &recipientType, const QString job->addRecipient(recipientType, recipientValue); } +void SharingController::removeRecipient(const QString &recipientType, const QString &recipientValue) +{ + if (!_account) { + qCWarning(lcSharingController) << "attempted to remove a new recipient to a share without an account set"; + return; + } + + if (!_share) { + qCWarning(lcSharingController) << "attempted to remove a new recipient without a share"; + return; + } + + const auto job = new OcsSharingJob(_account, _share->id()); + connect(job, &OcsSharingJob::jobFinished, this, [this](const QJsonDocument &json, int statusCode) -> void { + qCDebug(lcSharingController).nospace() << "recipient removed" + << " id=" << _share->id(); + _share->updateFromJson(json); + }); + job->addRecipient(recipientType, recipientValue); +} + void SharingController::setPermission(const QString &permissionClass, bool enabled) { if (!_account) { @@ -110,7 +151,7 @@ void SharingController::setPermission(const QString &permissionClass, bool enabl void SharingController::addSourceAfterCreation(const QString &fileId) { const auto job = new OcsSharingJob(_account, _share->id()); - connect(job, &OcsSharingJob::jobFinished, this, [this, &fileId](const QJsonDocument &json, int statusCode) -> void { + connect(job, &OcsSharingJob::jobFinished, this, [this, fileId](const QJsonDocument &json, int statusCode) -> void { qCDebug(lcSharingController).nospace() << "share created" << " id=" << _share->id() << " fileId=" << fileId; diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index 8fe1c0bbb15f1..02cd85e84ed11 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -32,7 +32,9 @@ class SharingController : public QObject [[nodiscard]] Share *share() const; Q_INVOKABLE void createShare(const QString &fileId); + Q_INVOKABLE void destroyShare(); Q_INVOKABLE void addRecipient(const QString &recipientType, const QString &recipientValue); + Q_INVOKABLE void removeRecipient(const QString &recipientType, const QString &recipientValue); Q_INVOKABLE void setPermission(const QString &permissionClass, bool enabled); diff --git a/src/gui/sharing/sharingfiltermodel.cpp b/src/gui/sharing/sharingfiltermodel.cpp deleted file mode 100644 index adae37432e92f..0000000000000 --- a/src/gui/sharing/sharingfiltermodel.cpp +++ /dev/null @@ -1,93 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#include "sharingfiltermodel.h" -#include "sharingmodel.h" - -#include "account.h" - -using namespace Qt::StringLiterals; -using namespace OCC::Gui::Sharing; - -SharingFilterModel::SharingFilterModel(QObject *parent) - : QSortFilterProxyModel{parent} -{} - - -SharingFilterModel::FilterType SharingFilterModel::filterType() const -{ - return _filterType; -} - -void SharingFilterModel::setFilterType(SharingFilterModel::FilterType filterType) -{ - if (_filterType == filterType) { - return; - } - - beginFilterChange(); - _filterType = filterType; - endFilterChange(); - Q_EMIT filterTypeChanged(); -} - -QStringList SharingFilterModel::recipientTypes() const -{ - return _recipientTypes; -} - -void SharingFilterModel::setRecipientTypes(const QStringList &recipientTypes) -{ - if (_recipientTypes == recipientTypes) { - return; - } - - beginFilterChange(); - _recipientTypes = recipientTypes; - endFilterChange(); - Q_EMIT recipientTypesChanged(); -} - -bool SharingFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const -{ - const auto model = qobject_cast(sourceModel()); - if (!model) { - // not a SharingModel, no need to filter - return true; - } - - const auto accountState = model->accountState(); - if (!accountState) { - return true; - } - - const auto index = model->index(sourceRow, 0, sourceParent); - if (!index.isValid()) { - return true; - } - - // const auto sharing = accountState->account()->sharing(); - const auto featureRole = model->data(index, SharingModel::LabelRole).toString(); // TODO: this will not be label - if (featureRole == "Add people"_L1) { - // TODO: same as above for now; >1 because link shares are only one recipient types - return _filterType == General && _recipientTypes.size() > 1; - } - - // if (!sharing->isFeatureAvailable(featureRole, {"OCA\\Files\\Sharing\\SourceType\\NodeShareSourceType"}, _recipientTypes)) { - // this feature is not available in general for these recipient types - // return false; - // } - - bool isAdvancedProperty = !featureRole.contains("NoteShareFeature"); - - switch (_filterType) { - case General: - return !isAdvancedProperty; - case Settings: - return isAdvancedProperty; - } - - return true; -} diff --git a/src/gui/sharing/sharingfiltermodel.h b/src/gui/sharing/sharingfiltermodel.h deleted file mode 100644 index afb73ce94a56c..0000000000000 --- a/src/gui/sharing/sharingfiltermodel.h +++ /dev/null @@ -1,48 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#pragma once - -#include -#include - -namespace OCC::Gui::Sharing { - -class SharingFilterModel : public QSortFilterProxyModel -{ - Q_OBJECT - QML_ELEMENT - - Q_PROPERTY(FilterType filterType READ filterType WRITE setFilterType NOTIFY filterTypeChanged) - Q_PROPERTY(QStringList recipientTypes READ recipientTypes WRITE setRecipientTypes NOTIFY recipientTypesChanged) - -public: - enum FilterType { - General, - Settings, - }; - Q_ENUM(FilterType) - - explicit SharingFilterModel(QObject *parent = nullptr); - - [[nodiscard]] FilterType filterType() const; - void setFilterType(FilterType filterType); - - [[nodiscard]] QStringList recipientTypes() const; - void setRecipientTypes(const QStringList &recipientTypes); - -Q_SIGNALS: - void filterTypeChanged(); - void recipientTypesChanged(); - -protected: - [[nodiscard]] bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; - -private: - FilterType _filterType = FilterType::General; - QStringList _recipientTypes = {}; -}; - -} diff --git a/src/gui/sharing/sharingmodel.cpp b/src/gui/sharing/sharingmodel.cpp deleted file mode 100644 index ad71dba34de45..0000000000000 --- a/src/gui/sharing/sharingmodel.cpp +++ /dev/null @@ -1,165 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#include "sharingmodel.h" - -#include - -#include "account.h" - -using namespace Qt::StringLiterals; -using namespace OCC; -using namespace OCC::Gui::Sharing; - -SharingModel::SharingModel(QObject *parent) - : QAbstractListModel{parent} -{} - -int SharingModel::rowCount(const QModelIndex &parent) const -{ - if (!(_accountState && _share)) { - return 0; - } - - return _share->properties().size(); -} - -QVariant SharingModel::data(const QModelIndex &index, int role) const -{ - if (!(_accountState && _share)) { - return {}; - } - - // // if (index.row() == 0) { - // // // special feature for the recipients search - // // switch (role) { - // // case LabelRole: - // // return "Add people"_L1; - // // case PropertyRole: - // // return "recipients"_L1; - // // case TypeRole: - // // return FieldTypes::RecipientsField; - // // case PlaceholderRole: - // // return "Name, team, email, or federated cloud ID"_L1; - // // case ValueRole: - // // return _fieldValues.value("recipients"_L1).toList(); // TODO - // // default: - // // return {}; - // // } - // // } - // // - // // TODO: cache this after setting accountstate - // const auto sharing = accountState()->account()->sharing(); - // // const auto features = sharing->features().values(); - // // const auto feature = features.at(index.row() - 1); // TODO: -1 to adjust the special feature for searching - // - - const auto properties = _share->properties(); - const auto property = properties.at(index.row()); - - switch (role) { - case LabelRole: - return property->displayName(); - case PropertyRole: - return property->className(); - case TypeRole: - if (property->className() == "string"_L1) { - return FieldTypes::TextField; - } - // TODO: date etc. - return FieldTypes::TextField; - case PlaceholderRole: - return property->hint(); - case ValueRole: - // return _fieldValues.value(feature->type()); - return property->value(); - default: - return {}; - } -} - -bool SharingModel::setData(const QModelIndex &index, const QVariant &value, int role) -{ - return false; - if (!_accountState) { - qCritical() << "no accountState, but setData called with" << index << value << role; - return false; - } - - qCritical() << "setData called with" << index << value << role; - - if (role != ValueRole) { - return false; - } - - if (index.row() == 0) { - qCritical() << "changing" << "recipients"_L1 << "to" << value; - _fieldValues.insert("recipients"_L1, value); // TODO - } else { - // TODO: cache this after setting accountstate - const auto sharing = accountState()->account()->sharing(); - // const auto features = sharing->features().values(); - // const auto feature = features.at(index.row() - 1); // TODO: -1 to adjust the special feature for searching - // qCritical() << "changing" << feature->type() << "to" << value; - // _fieldValues.insert(feature->type(), value); - } - - Q_EMIT dataChanged(index, index, {ValueRole}); - return true; -} - -Qt::ItemFlags SharingModel::flags(const QModelIndex &index) const -{ - qCritical() << "flags for" << index << QAbstractListModel::flags(index) << Qt::ItemIsEditable << "returning" << (QAbstractListModel::flags(index) | Qt::ItemIsEditable); - return QAbstractListModel::flags(index) | Qt::ItemIsEditable; -} - -QHash SharingModel::roleNames() const -{ - return { - { LabelRole, "label"_ba}, - { PropertyRole, "property"_ba}, - { TypeRole, "type"_ba}, - { PlaceholderRole, "placeholder"_ba}, - { ValueRole, "value"_ba}, - }; -}; - -AccountState *SharingModel::accountState() const -{ - return _accountState; -} - -void SharingModel::setAccountState(AccountState *accountState) -{ - if (_accountState == accountState) { - return; - } - - _accountState = accountState; - Q_EMIT accountStateChanged(); - - auto job = _accountState->account()->sharing()->createShareJob(this); - connect(job, &OCC::JsonApiJob::jsonReceived, this, [this](const QJsonDocument &json, int statusCode) -> void { - beginResetModel(); - qCritical() << "request finished with code" << statusCode << "data" << json; - _share = OCC::Sharing::Share::fromJson(json, _accountState->account()); - qCritical() << "share id:" << _share->id(); - endResetModel(); - - connect(_share.get(), &OCC::Sharing::Share::propertiesChanged, this, [this]() -> void { - beginResetModel(); - endResetModel(); - }); - - _share->addSource("8"_L1); - }); - job->start(); -} - -void SharingModel::addRecipient(const QString &type, const QString &value) -{ - _share->addRecipient(type, value); -} diff --git a/src/gui/sharing/sharingmodel.h b/src/gui/sharing/sharingmodel.h deleted file mode 100644 index 7e8a7f6ec59ab..0000000000000 --- a/src/gui/sharing/sharingmodel.h +++ /dev/null @@ -1,63 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#pragma once - -#include -#include - -#include "accountstate.h" - -#include "sharing/share.h" - -namespace OCC::Gui::Sharing { - -class SharingModel : public QAbstractListModel -{ - Q_OBJECT - QML_ELEMENT - - Q_PROPERTY(AccountState *accountState READ accountState WRITE setAccountState NOTIFY accountStateChanged) - -public: - enum Roles { - LabelRole = Qt::UserRole, - PropertyRole, - TypeRole, - PlaceholderRole, - ValueRole, - }; - - enum FieldTypes { - Switch, - TextField, - TextArea, - RecipientsField, - }; - Q_ENUM(FieldTypes) - - explicit SharingModel(QObject *parent = nullptr); - - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; - Qt::ItemFlags flags(const QModelIndex &index) const override; - QHash roleNames() const override; - - [[nodiscard]] AccountState *accountState() const; - void setAccountState(AccountState *accountState); - - Q_INVOKABLE void addRecipient(const QString &type, const QString &value); - -Q_SIGNALS: - void accountStateChanged(); - -private: - AccountState *_accountState = nullptr; - QSharedPointer _share = nullptr; - QVariantMap _fieldValues; -}; - -} From d31f81c13d02047656caf7ba772860fb29be11ae Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Tue, 7 Jul 2026 14:29:12 +0200 Subject: [PATCH 030/104] fix(sharing): show detailed permission switches depending on preset selection Signed-off-by: Jyrki Gadinger --- src/gui/sharing/MainPage.qml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index 244eb22b3b178..09c3c97d6e939 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -47,6 +47,17 @@ Page { } } + ComboBox { + id: permissionPresetSelector + model: [ + { preset: "view", text: qsTr("Can view") }, + { preset: "edit", text: qsTr("Can edit") }, + { preset: null, text: qsTr("Can…") }, + ] + textRole: "text" + valueRole: "preset" + } + Repeater { id: permissionsList Layout.fillWidth: true @@ -57,6 +68,7 @@ Page { delegate: ItemDelegate { // Layout.fillWidth: true + visible: !permissionPresetSelector.currentValue required property var model RowLayout { From 37c522f5478beab2046b021a3c5f6a1fbb4c7d2d Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Tue, 7 Jul 2026 14:29:18 +0200 Subject: [PATCH 031/104] fix(sharing): update permission resource Signed-off-by: Jyrki Gadinger --- src/gui/sharing/ocssharingjob.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/sharing/ocssharingjob.cpp b/src/gui/sharing/ocssharingjob.cpp index c1babb71e5f2f..872d24a5cb492 100644 --- a/src/gui/sharing/ocssharingjob.cpp +++ b/src/gui/sharing/ocssharingjob.cpp @@ -132,7 +132,7 @@ void OcsSharingJob::setPermission(const QString &permissionClass, bool enabled) return; } - setPath(SHARING_V1_BASE % "/share/%1/enabled"_L1.arg(_shareId)); + setPath(SHARING_V1_BASE % "/share/%1/permission"_L1.arg(_shareId)); setVerb("PUT"_ba); addParam("class"_L1, permissionClass); From e5efd99365e76ecb0406f0e184785edb5cc7deca Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Wed, 8 Jul 2026 13:23:38 +0200 Subject: [PATCH 032/104] fix(sharing): create link recipients and update permissions Signed-off-by: Jyrki Gadinger --- src/gui/ocsjob.cpp | 2 +- src/gui/sharing/MainPage.qml | 5 +++-- src/gui/sharing/ShareDialog.qml | 14 +------------- src/gui/sharing/permissionmodel.cpp | 4 +--- src/gui/sharing/propertymodel.cpp | 4 +--- src/gui/sharing/recipientsearchmodel.cpp | 4 ++++ 6 files changed, 11 insertions(+), 22 deletions(-) diff --git a/src/gui/ocsjob.cpp b/src/gui/ocsjob.cpp index 9f4d918ce3a5b..20de002cbb3af 100644 --- a/src/gui/ocsjob.cpp +++ b/src/gui/ocsjob.cpp @@ -77,7 +77,7 @@ void OcsJob::start() auto *buffer = new QBuffer; QUrlQuery queryItems; - if (_verb == "GET") { + if (_verb == "GET" || _verb == "DELETE") { queryItems = percentEncodeQueryItems(_params); } else if (_verb == "POST" || _verb == "PUT") { // Url encode the _postParams and put them in a buffer. diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index 09c3c97d6e939..c29519d69be7b 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -56,6 +56,8 @@ Page { ] textRole: "text" valueRole: "preset" + + // value: root.sharingController.share.permissionPreset } Repeater { @@ -78,12 +80,11 @@ Page { Layout.fillWidth: true } Switch { - Component.onCompleted: checked = model.enabled ?? false + checked: model.enabled onCheckedChanged: { if (model.enabled === checked) { return; } - // model.enabled = checked root.sharingController.setPermission(model.className, checked) } } diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 8278a4f10b10a..5a3a69ac2288b 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -167,12 +167,6 @@ ApplicationWindow { id: viewInvitedPeople text: qsTr("Invited people") - readonly property list recipientTypes: [ - // TODO: this won't be hardcoded in the future - "OC\\Core\\Sharing\\RecipientType\\UserShareRecipientType", - "OC\\Core\\Sharing\\RecipientType\\GroupShareRecipientType", - ] - checkable: true checked: true ButtonGroup.group: shareType @@ -184,17 +178,11 @@ ApplicationWindow { id: viewAnyone text: qsTr("Anyone") - readonly property list recipientTypes: [ - // TODO: this won't be hardcoded in the future - "OC\\Core\\Sharing\\RecipientType\\TokenShareRecipientType", - ] - checkable: true ButtonGroup.group: shareType - enabled: false // TODO: figure out why link shares don't work onCheckedChanged: { - const parameters = ["OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType", qsTr("Link share")] + const parameters = ["OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType", "somevaluethatislongerthan32characters12345678"] if (checked) { sharingController.addRecipient(...parameters) return diff --git a/src/gui/sharing/permissionmodel.cpp b/src/gui/sharing/permissionmodel.cpp index e3baf3de3fcee..fddd6af1119f6 100644 --- a/src/gui/sharing/permissionmodel.cpp +++ b/src/gui/sharing/permissionmodel.cpp @@ -21,9 +21,7 @@ PermissionModel::PermissionModel(QObject *parent) int PermissionModel::rowCount(const QModelIndex &parent) const { - Q_UNUSED(parent) - - if (!_share) { + if (parent.isValid() || !_share) { return 0; } diff --git a/src/gui/sharing/propertymodel.cpp b/src/gui/sharing/propertymodel.cpp index 7b38c9b4cbc50..b4d2934bb514e 100644 --- a/src/gui/sharing/propertymodel.cpp +++ b/src/gui/sharing/propertymodel.cpp @@ -20,9 +20,7 @@ PropertyModel::PropertyModel(QObject *parent) int PropertyModel::rowCount(const QModelIndex &parent) const { - Q_UNUSED(parent) - - if (!_share) { + if (parent.isValid() || !_share) { return 0; } diff --git a/src/gui/sharing/recipientsearchmodel.cpp b/src/gui/sharing/recipientsearchmodel.cpp index 92f9d0125af21..ef4319d62c7ab 100644 --- a/src/gui/sharing/recipientsearchmodel.cpp +++ b/src/gui/sharing/recipientsearchmodel.cpp @@ -22,6 +22,10 @@ RecipientSearchModel::RecipientSearchModel(QObject *parent) int RecipientSearchModel::rowCount(const QModelIndex &parent) const { + if (parent.isValid()) { + return 0; + } + return _searchResults.size(); } From 403c459231d2c9c7b51db520001bad03f6daddc7 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Wed, 8 Jul 2026 14:30:09 +0200 Subject: [PATCH 033/104] feat(sharing): model share recipients Signed-off-by: Jyrki Gadinger --- src/gui/sharing/CMakeLists.txt | 6 +++ src/gui/sharing/MainPage.qml | 5 +- src/gui/sharing/ShareDialog.qml | 4 +- src/gui/sharing/abstractsharemodel.cpp | 33 ++++++++++++ src/gui/sharing/abstractsharemodel.h | 33 ++++++++++++ src/gui/sharing/permissionmodel.cpp | 15 ++---- src/gui/sharing/permissionmodel.h | 18 ++----- src/gui/sharing/propertymodel.cpp | 16 +++--- src/gui/sharing/propertymodel.h | 15 ++---- src/gui/sharing/recipient.cpp | 42 +++++++++++++++ src/gui/sharing/recipient.h | 34 ++++++++++++ src/gui/sharing/recipientmodel.cpp | 72 ++++++++++++++++++++++++++ src/gui/sharing/recipientmodel.h | 35 +++++++++++++ src/gui/sharing/share.cpp | 42 +++++++++++++++ src/gui/sharing/share.h | 11 ++++ 15 files changed, 330 insertions(+), 51 deletions(-) create mode 100644 src/gui/sharing/abstractsharemodel.cpp create mode 100644 src/gui/sharing/abstractsharemodel.h create mode 100644 src/gui/sharing/recipient.cpp create mode 100644 src/gui/sharing/recipient.h create mode 100644 src/gui/sharing/recipientmodel.cpp create mode 100644 src/gui/sharing/recipientmodel.h diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index a6b7a884982ef..e00b71a60150c 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -5,6 +5,8 @@ add_library(nextcloudGuiSharing STATIC) target_sources(nextcloudGuiSharing PRIVATE + abstractsharemodel.h + abstractsharemodel.cpp ocssharingjob.h ocssharingjob.cpp permission.h @@ -15,6 +17,10 @@ target_sources(nextcloudGuiSharing property.cpp propertymodel.h propertymodel.cpp + recipient.h + recipient.cpp + recipientmodel.h + recipientmodel.cpp recipientsearchmodel.h recipientsearchmodel.cpp share.h diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index c29519d69be7b..bcbc6df45a57c 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -19,7 +19,7 @@ Page { property string localPath: "" property string shortLocalPath: "" required property SharingController sharingController - required property list recipientTypes + property bool isLinkShare: false title: qsTr("Share \"%1\"").arg(root.shortLocalPath) @@ -40,6 +40,9 @@ Page { RecipientSearchField { id: searchField Layout.fillWidth: true + + visible: !root.isLinkShare + account: root.sharingController.account onRecipientSelected: (recipientType, recipientValue) => { diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 5a3a69ac2288b..2c3f1fa782482 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -24,8 +24,6 @@ ApplicationWindow { readonly property int windowRadius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius - property list recipientTypes: shareType.checkedButton?.recipientTypes || [] - ButtonGroup { id: shareType } @@ -207,6 +205,6 @@ ApplicationWindow { sharingController: sharingController localPath: root.localPath shortLocalPath: root.shortLocalPath - recipientTypes: root.recipientTypes + isLinkShare: viewAnyone.checked } } diff --git a/src/gui/sharing/abstractsharemodel.cpp b/src/gui/sharing/abstractsharemodel.cpp new file mode 100644 index 0000000000000..d05e879777fbc --- /dev/null +++ b/src/gui/sharing/abstractsharemodel.cpp @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "abstractsharemodel.h" + +#include "share.h" + +using namespace Qt::StringLiterals; +using namespace OCC; +using namespace OCC::Gui::Sharing; + +AbstractShareModel::AbstractShareModel(QObject *parent) + : QAbstractListModel{parent} +{} + +Share *AbstractShareModel::share() const +{ + return _share; +} + +void AbstractShareModel::setShare(Share *share) +{ + if (_share == share) { + return; + } + + beginResetModel(); + _share = share; + Q_EMIT shareChanged(); + endResetModel(); +} diff --git a/src/gui/sharing/abstractsharemodel.h b/src/gui/sharing/abstractsharemodel.h new file mode 100644 index 0000000000000..604785c91ceb8 --- /dev/null +++ b/src/gui/sharing/abstractsharemodel.h @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include + +#include "share.h" + +namespace OCC::Gui::Sharing { + +class AbstractShareModel : public QAbstractListModel +{ + Q_OBJECT + + Q_PROPERTY(Share *share READ share WRITE setShare NOTIFY shareChanged) + +public: + explicit AbstractShareModel(QObject *parent = nullptr); + + [[nodiscard]] Share* share() const; + virtual void setShare(Share* share); + +Q_SIGNALS: + void shareChanged(); + +protected: + Share *_share = nullptr; +}; + +} diff --git a/src/gui/sharing/permissionmodel.cpp b/src/gui/sharing/permissionmodel.cpp index fddd6af1119f6..9ccbac842f520 100644 --- a/src/gui/sharing/permissionmodel.cpp +++ b/src/gui/sharing/permissionmodel.cpp @@ -6,7 +6,6 @@ #include "permissionmodel.h" #include -#include #include "share.h" #include "permission.h" @@ -16,7 +15,7 @@ using namespace OCC; using namespace OCC::Gui::Sharing; PermissionModel::PermissionModel(QObject *parent) - : QAbstractListModel{parent} + : AbstractShareModel{parent} {} int PermissionModel::rowCount(const QModelIndex &parent) const @@ -82,23 +81,15 @@ QHash PermissionModel::roleNames() const }; }; -Share *PermissionModel::share() const -{ - return _share; -} - void PermissionModel::setShare(Share *share) { - if (_share == share) { + AbstractShareModel::setShare(share); + if (!_share) { return; } - beginResetModel(); - _share = share; connect(_share, &Share::permissionsChanged, this, [this]() -> void { beginResetModel(); endResetModel(); }); - Q_EMIT shareChanged(); - endResetModel(); } diff --git a/src/gui/sharing/permissionmodel.h b/src/gui/sharing/permissionmodel.h index e715dc70e2742..0bfb5bbfd07ff 100644 --- a/src/gui/sharing/permissionmodel.h +++ b/src/gui/sharing/permissionmodel.h @@ -5,20 +5,17 @@ #pragma once -#include -#include +#include "abstractsharemodel.h" -#include "share.h" +#include namespace OCC::Gui::Sharing { -class PermissionModel : public QAbstractListModel +class PermissionModel : public AbstractShareModel { Q_OBJECT QML_ELEMENT - Q_PROPERTY(Share *share READ share WRITE setShare NOTIFY shareChanged) - public: enum Roles { LabelRole = Qt::UserRole, @@ -35,14 +32,7 @@ class PermissionModel : public QAbstractListModel Qt::ItemFlags flags(const QModelIndex &index) const override; QHash roleNames() const override; - [[nodiscard]] Share* share() const; - void setShare(Share* share); - -Q_SIGNALS: - void shareChanged(); - -private: - Share *_share = nullptr; + void setShare(Share* share) override; }; } diff --git a/src/gui/sharing/propertymodel.cpp b/src/gui/sharing/propertymodel.cpp index b4d2934bb514e..f4628d095040f 100644 --- a/src/gui/sharing/propertymodel.cpp +++ b/src/gui/sharing/propertymodel.cpp @@ -15,7 +15,7 @@ using namespace OCC; using namespace OCC::Gui::Sharing; PropertyModel::PropertyModel(QObject *parent) - : QAbstractListModel{parent} + : AbstractShareModel{parent} {} int PropertyModel::rowCount(const QModelIndex &parent) const @@ -100,17 +100,15 @@ QHash PropertyModel::roleNames() const }; }; -Share *PropertyModel::share() const -{ - return _share; -} - void PropertyModel::setShare(Share *share) { - if (_share == share) { + AbstractShareModel::setShare(share); + if (!_share) { return; } - _share = share; - Q_EMIT shareChanged(); + connect(_share, &Share::propertiesChanged, this, [this]() -> void { + beginResetModel(); + endResetModel(); + }); } diff --git a/src/gui/sharing/propertymodel.h b/src/gui/sharing/propertymodel.h index e153b6602387d..e3796987686db 100644 --- a/src/gui/sharing/propertymodel.h +++ b/src/gui/sharing/propertymodel.h @@ -5,20 +5,16 @@ #pragma once -#include +#include "abstractsharemodel.h" #include -#include "share.h" - namespace OCC::Gui::Sharing { -class PropertyModel : public QAbstractListModel +class PropertyModel : public AbstractShareModel { Q_OBJECT QML_ELEMENT - Q_PROPERTY(Share *share READ share WRITE setShare NOTIFY shareChanged) - public: enum Roles { LabelRole = Qt::UserRole, @@ -44,14 +40,9 @@ class PropertyModel : public QAbstractListModel Qt::ItemFlags flags(const QModelIndex &index) const override; QHash roleNames() const override; - [[nodiscard]] Share* share() const; - void setShare(Share* share); - -Q_SIGNALS: - void shareChanged(); + void setShare(Share* share) override; private: - Share *_share = nullptr; QVariantMap _fieldValues; }; diff --git a/src/gui/sharing/recipient.cpp b/src/gui/sharing/recipient.cpp new file mode 100644 index 0000000000000..f1ae17881f452 --- /dev/null +++ b/src/gui/sharing/recipient.cpp @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "recipient.h" + +#include +#include + +using namespace Qt::StringLiterals; + +using namespace OCC::Gui::Sharing; + +QPointer Recipient::fromJson(const QJsonObject &json) +{ + auto recipient = QPointer(new Recipient); + recipient->_className = json.value("class"_L1).toString(); + recipient->_displayName = json.value("display_name"_L1).toString(); + recipient->_value = json.value("value"_L1).toString(); + return recipient; +} + +Recipient::Recipient(QObject *parent) + : QObject{parent} +{ +} + +QString Recipient::className() const +{ + return _className; +} + +QString Recipient::displayName() const +{ + return _displayName; +} + +QString Recipient::value() const +{ + return _value; +} diff --git a/src/gui/sharing/recipient.h b/src/gui/sharing/recipient.h new file mode 100644 index 0000000000000..7eefae5c3cbd9 --- /dev/null +++ b/src/gui/sharing/recipient.h @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include + +namespace OCC::Gui::Sharing { + +class Recipient : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString className READ className CONSTANT) + Q_PROPERTY(QString displayName READ displayName CONSTANT) + Q_PROPERTY(QString value READ value CONSTANT) + +public: + [[nodiscard]] static QPointer fromJson(const QJsonObject &json); + + [[nodiscard]] QString className() const; + [[nodiscard]] QString displayName() const; + [[nodiscard]] QString value() const; + +private: + explicit Recipient(QObject *parent = nullptr); + + QString _className; + QString _displayName; + QString _value; +}; + +} diff --git a/src/gui/sharing/recipientmodel.cpp b/src/gui/sharing/recipientmodel.cpp new file mode 100644 index 0000000000000..66c04a750d3b4 --- /dev/null +++ b/src/gui/sharing/recipientmodel.cpp @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "recipientmodel.h" + +#include + +#include "share.h" +#include "recipient.h" + +using namespace Qt::StringLiterals; +using namespace OCC; +using namespace OCC::Gui::Sharing; + +RecipientModel::RecipientModel(QObject *parent) + : AbstractShareModel{parent} +{} + +int RecipientModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid() || !_share) { + return 0; + } + + qCritical() << "recipients size:" << _share->recipients().size(); + return _share->recipients().size(); +} + +QVariant RecipientModel::data(const QModelIndex &index, int role) const +{ + if (!_share) { + return {}; + } + + const auto recipients = _share->recipients(); + const auto recipient = recipients.at(index.row()); + + switch (role) { + case LabelRole: + return recipient->displayName(); + case ClassNameRole: + return recipient->className(); + case ValueRole: + return recipient->value(); + default: + return {}; + } +} + +QHash RecipientModel::roleNames() const +{ + return { + { LabelRole, "label"_ba}, + { ClassNameRole, "className"_ba}, + { ValueRole, "value"_ba}, + }; +}; + +void RecipientModel::setShare(Share *share) +{ + AbstractShareModel::setShare(share); + if (!_share) { + return; + } + + connect(_share, &Share::recipientsChanged, this, [this]() -> void { + beginResetModel(); + endResetModel(); + }); +} diff --git a/src/gui/sharing/recipientmodel.h b/src/gui/sharing/recipientmodel.h new file mode 100644 index 0000000000000..df73554cd2b2d --- /dev/null +++ b/src/gui/sharing/recipientmodel.h @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "abstractsharemodel.h" + +#include + +namespace OCC::Gui::Sharing { + +class RecipientModel : public AbstractShareModel +{ + Q_OBJECT + QML_ELEMENT + +public: + enum Roles { + LabelRole = Qt::UserRole, + ClassNameRole, + ValueRole, + }; + + explicit RecipientModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void setShare(Share* share) override; +}; + +} diff --git a/src/gui/sharing/share.cpp b/src/gui/sharing/share.cpp index d1b67df31f367..ce23b8da64112 100644 --- a/src/gui/sharing/share.cpp +++ b/src/gui/sharing/share.cpp @@ -33,8 +33,10 @@ void Share::updateFromJson(const QJsonDocument &json) const auto data = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject(); setId(data.value("id"_L1).toString()); setState(data.value("state"_L1).toString()); + setPermissionPreset(data.value("permission_preset"_L1).toString()); setPermissions(data.value("permissions"_L1).toArray()); setProperties(data.value("properties"_L1).toArray()); + setRecipients(data.value("recipients"_L1).toArray()); } Share::Share(const AccountPtr &account) @@ -51,6 +53,11 @@ Share::ShareState Share::state() const return _state; } +QString Share::permissionPreset() const +{ + return _permissionPreset; +} + const QList> &Share::permissions() const { return _permissions; @@ -61,6 +68,11 @@ const QList> &Share::properties() const return _properties; } +const QList> &Share::recipients() const +{ + return _recipients; +} + void Share::setId(const QString &id) { if (_id == id) { @@ -89,6 +101,16 @@ void Share::setState(const QString &state) Q_EMIT stateChanged(); } +void Share::setPermissionPreset(const QString &permissionPreset) +{ + if (_permissionPreset == permissionPreset) { + return; + } + + _permissionPreset = permissionPreset; + Q_EMIT permissionPresetChanged(); +} + void Share::setPermissions(const QJsonArray &permissions) { _permissions.clear(); @@ -128,3 +150,23 @@ void Share::setProperties(const QJsonArray &properties) Q_EMIT propertiesChanged(); } + +void Share::setRecipients(const QJsonArray &recipients) +{ + _recipients.clear(); + + if (recipients.isEmpty()) { + Q_EMIT recipientsChanged(); + return; + } + + for (const auto &recipientValue : recipients) { + if (!recipientValue.isObject()) { + continue; + } + const auto recipientObject = recipientValue.toObject(); + _recipients.append(Recipient::fromJson(recipientObject)); + } + + Q_EMIT recipientsChanged(); +} diff --git a/src/gui/sharing/share.h b/src/gui/sharing/share.h index 8b45768c631e8..3a7013ddd85e3 100644 --- a/src/gui/sharing/share.h +++ b/src/gui/sharing/share.h @@ -11,6 +11,7 @@ #include "permission.h" #include "property.h" +#include "recipient.h" #include "accountfwd.h" @@ -24,8 +25,10 @@ class Share : public QObject Q_PROPERTY(QString id READ id NOTIFY idChanged) Q_PROPERTY(Share::ShareState state READ state NOTIFY stateChanged) + Q_PROPERTY(QString permissionPreset READ permissionPreset NOTIFY permissionPresetChanged) Q_PROPERTY(QList> permissions READ permissions NOTIFY permissionsChanged) Q_PROPERTY(QList> properties READ properties NOTIFY propertiesChanged) + Q_PROPERTY(QList> recipients READ recipients NOTIFY recipientsChanged) public: [[nodiscard]] static QPointer fromJson(const QJsonDocument &json, const AccountPtr &account); @@ -41,28 +44,36 @@ class Share : public QObject [[nodiscard]] QString id() const; [[nodiscard]] ShareState state() const; + [[nodiscard]] QString permissionPreset() const; [[nodiscard]] const QList> &permissions() const; [[nodiscard]] const QList> &properties() const; + [[nodiscard]] const QList> &recipients() const; Q_SIGNALS: void idChanged(); void stateChanged(); + void permissionPresetChanged(); void permissionsChanged(); void propertiesChanged(); + void recipientsChanged(); private: AccountPtr _account; QString _id; ShareState _state = ShareState::Draft; + QString _permissionPreset; QList> _permissions; QList> _properties; + QList> _recipients; explicit Share(const AccountPtr &account); void setId(const QString &id); void setState(const QString &state); + void setPermissionPreset(const QString &permissionPreset); void setPermissions(const QJsonArray &permissions); void setProperties(const QJsonArray &properties); + void setRecipients(const QJsonArray &recipients); }; } From e219b10e98bbf51089b686e4ffdd9a28b8da9a80 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Wed, 8 Jul 2026 14:51:16 +0200 Subject: [PATCH 034/104] refactor(sharing): remove obsolete field delegate code Signed-off-by: Jyrki Gadinger --- src/gui/sharing/FieldDelegate.qml | 55 ------------------------------- src/gui/sharing/MainPage.qml | 11 ++++--- src/gui/sharing/propertymodel.h | 1 - 3 files changed, 7 insertions(+), 60 deletions(-) diff --git a/src/gui/sharing/FieldDelegate.qml b/src/gui/sharing/FieldDelegate.qml index 5db32203a2f87..16bd55e4246c6 100644 --- a/src/gui/sharing/FieldDelegate.qml +++ b/src/gui/sharing/FieldDelegate.qml @@ -16,7 +16,6 @@ import Style Loader { id: instantiator required property var model - required property var account sourceComponent: switch (model.type) { case PropertyModel.Switch: @@ -25,8 +24,6 @@ Loader { return textFieldComponent; case PropertyModel.TextArea: return textAreaComponent; - case PropertyModel.RecipientsField: - return recipientsFieldComponent; default: return unknownItem; } @@ -90,58 +87,6 @@ Loader { } } - Component { - id: recipientsFieldComponent - ColumnLayout { - Label { - text: instantiator.model.label - } - SearchField { - id: searchField - // TODO: only available with Qt 6.10 - Layout.fillWidth: true - // no placeholderText on SearchField, really? - - suggestionModel: RecipientSearchModel { - account: instantiator.account - query: searchField.text - } - textRole: "query" - delegate: ItemDelegate { - id: recipientDelegate - text: displayName - - contentItem: Row { - Label { - // TODO: use plaintext-only fields - text: recipientDelegate.value - } - Label { - text: recipientDelegate.text - // TODO: use plaintext-only fields - } - } - - required property string displayName - required property string value - } - - onActivated: (index) => { - console.log(`activated index ${index}`) - console.log(`activated index ${index} => ${suggestionModel.get(index).value}`) - instantiator.model.value.append(suggestionModel.get(index).value) - } - } - Repeater { - model: model.value ?? [] - Label { - required property list value - text: value - } - } - } - } - Component { id: unknownItem RowLayout { diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index bcbc6df45a57c..f9ff17a66e666 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -106,9 +106,12 @@ Page { // sourceModel: root.sharingModel // recipientTypes: root.recipientTypes // } + model: PropertyModel { + // TODO: only show properties with prio=1 + share: root.sharingController.share + } delegate: FieldDelegate { - account: root.account width: propertyList.contentItem.width } } @@ -118,14 +121,14 @@ Page { Button { Layout.fillWidth: true - text: qsTr("Copy link") + text: root.isLinkShare ? qsTr("Copy public link") : qsTr("Copy private link") } Button { Layout.fillWidth: true text: qsTr("Send") - visible: root.recipientTypes.length > 1 - enabled: root.recipientTypes.length > 1 + visible: !root.isLinkShare + enabled: !root.isLinkShare } } } diff --git a/src/gui/sharing/propertymodel.h b/src/gui/sharing/propertymodel.h index e3796987686db..232730e096f36 100644 --- a/src/gui/sharing/propertymodel.h +++ b/src/gui/sharing/propertymodel.h @@ -28,7 +28,6 @@ class PropertyModel : public AbstractShareModel Switch, TextField, TextArea, - RecipientsField, }; Q_ENUM(FieldTypes) From 53156a483d62f38ba76f2a80bd32670692aea2ee Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Wed, 8 Jul 2026 15:13:13 +0200 Subject: [PATCH 035/104] feat(sharing): add permission preset selection Signed-off-by: Jyrki Gadinger --- src/gui/sharing/MainPage.qml | 7 +++++++ src/gui/sharing/ocssharingjob.cpp | 16 ++++++++++++++++ src/gui/sharing/ocssharingjob.h | 1 + src/gui/sharing/sharingcontroller.cpp | 26 ++++++++++++++++++++++++++ src/gui/sharing/sharingcontroller.h | 1 + 5 files changed, 51 insertions(+) diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index f9ff17a66e666..cbfd129cbddae 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -61,6 +61,13 @@ Page { valueRole: "preset" // value: root.sharingController.share.permissionPreset + onCurrentValueChanged: { + if (!currentValue) { + return; + } + + root.sharingController.setPermissionPreset(currentValue) + } } Repeater { diff --git a/src/gui/sharing/ocssharingjob.cpp b/src/gui/sharing/ocssharingjob.cpp index 872d24a5cb492..0aef74f2a1d22 100644 --- a/src/gui/sharing/ocssharingjob.cpp +++ b/src/gui/sharing/ocssharingjob.cpp @@ -140,3 +140,19 @@ void OcsSharingJob::setPermission(const QString &permissionClass, bool enabled) start(); } + +void OcsSharingJob::setPermissionPreset(const QString &permissionPreset) +{ + if (_shareId.isEmpty()) { + qCWarning(lcOcsSharingJob) << "setPermissionPreset called without a shareId, not starting job"; + Q_EMIT jobFinished({}, 0); + return; + } + + setPath(SHARING_V1_BASE % "/share/%1/permission/preset"_L1.arg(_shareId)); + setVerb("PUT"_ba); + + addParam("permissionPreset"_L1, permissionPreset); + + start(); +} diff --git a/src/gui/sharing/ocssharingjob.h b/src/gui/sharing/ocssharingjob.h index a115c4f1f5b38..72df75a3fa40b 100644 --- a/src/gui/sharing/ocssharingjob.h +++ b/src/gui/sharing/ocssharingjob.h @@ -35,6 +35,7 @@ class OcsSharingJob : public OcsJob void searchRecipients(const QString &query, int64_t offset, int64_t limit); void setPermission(const QString &permissionClass, bool enabled); + void setPermissionPreset(const QString &permissionPreset); Q_SIGNALS: void shareCreated(QPointer share); diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index a0c7cb15d5e55..9f4da7edcc52b 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -148,6 +148,32 @@ void SharingController::setPermission(const QString &permissionClass, bool enabl job->setPermission(permissionClass, enabled); } +void SharingController::setPermissionPreset(const QString &permissionPreset) +{ + if (!_account) { + qCWarning(lcSharingController) << "attempted to set permission preset without an account set"; + return; + } + + if (!_share) { + qCWarning(lcSharingController) << "attempted to set permission preset without a share"; + return; + } + + if (permissionPreset.isEmpty()) { + qCDebug(lcSharingController) << "ignoring attempt to set a null/empty permission preset"; + return; + } + + const auto job = new OcsSharingJob(_account, _share->id()); + connect(job, &OcsSharingJob::jobFinished, this, [this](const QJsonDocument &json, int statusCode) -> void { + qCDebug(lcSharingController).nospace() << "permissions updated" + << " id=" << _share->id(); + _share->updateFromJson(json); + }); + job->setPermissionPreset(permissionPreset); +} + void SharingController::addSourceAfterCreation(const QString &fileId) { const auto job = new OcsSharingJob(_account, _share->id()); diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index 02cd85e84ed11..5bb7d2daa76c3 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -37,6 +37,7 @@ class SharingController : public QObject Q_INVOKABLE void removeRecipient(const QString &recipientType, const QString &recipientValue); Q_INVOKABLE void setPermission(const QString &permissionClass, bool enabled); + Q_INVOKABLE void setPermissionPreset(const QString &permissionPreset); Q_SIGNALS: void accountChanged(); From aee4058b3e51b3c5c640465828dba845b41c3108 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Fri, 10 Jul 2026 09:59:28 +0200 Subject: [PATCH 036/104] refactor(socketapi): use early returns instead of elsif in processShareRequest Signed-off-by: Jyrki Gadinger --- src/gui/socketapi/socketapi.cpp | 57 ++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/src/gui/socketapi/socketapi.cpp b/src/gui/socketapi/socketapi.cpp index 63887e03f4055..3a67999ff2a6f 100644 --- a/src/gui/socketapi/socketapi.cpp +++ b/src/gui/socketapi/socketapi.cpp @@ -599,42 +599,49 @@ void SocketApi::processShareRequest(const QString &localFile, SocketListener *li const QString message = QLatin1String("SHARE:NOP:") + QDir::toNativeSeparators(localFile); // files that are not within a sync folder are not synced. listener->sendMessage(message); - } else if (!shareFolder->accountState()->isConnected()) { + return; + } + + if (!shareFolder->accountState()->isConnected()) { const QString message = QLatin1String("SHARE:NOTCONNECTED:") + QDir::toNativeSeparators(localFile); // if the folder isn't connected, don't open the share dialog listener->sendMessage(message); - } else if (!theme->linkSharing() && (!theme->userGroupSharing() || shareFolder->accountState()->account()->serverVersionInt() < Account::makeServerVersion(8, 2, 0))) { + return; + } + + if (!theme->linkSharing() && (!theme->userGroupSharing() || shareFolder->accountState()->account()->serverVersionInt() < Account::makeServerVersion(8, 2, 0))) { const QString message = QLatin1String("SHARE:NOP:") + QDir::toNativeSeparators(localFile); listener->sendMessage(message); - } else { - // If the file doesn't have a journal record, it might not be uploaded yet - if (!fileData.journalRecord().isValid()) { - const QString message = QLatin1String("SHARE:NOTSYNCED:") + QDir::toNativeSeparators(localFile); - listener->sendMessage(message); - return; - } + return; + } - if (!fileData.journalRecord().e2eMangledName().isEmpty()) { - // we can not share an encrypted file or a subfolder under encrypted root foolder - const QString message = QLatin1String("SHARE:NOP:") + QDir::toNativeSeparators(localFile); - listener->sendMessage(message); - return; - } + // If the file doesn't have a journal record, it might not be uploaded yet + if (!fileData.journalRecord().isValid()) { + const QString message = QLatin1String("SHARE:NOTSYNCED:") + QDir::toNativeSeparators(localFile); + listener->sendMessage(message); + return; + } - auto &remotePath = fileData.serverRelativePath; + if (!fileData.journalRecord().e2eMangledName().isEmpty()) { + // we can not share an encrypted file or a subfolder under encrypted root foolder + const QString message = QLatin1String("SHARE:NOP:") + QDir::toNativeSeparators(localFile); + listener->sendMessage(message); + return; + } - // Can't share root folder - if (remotePath == "/") { - const QString message = QLatin1String("SHARE:CANNOTSHAREROOT:") + QDir::toNativeSeparators(localFile); - listener->sendMessage(message); - return; - } + auto &remotePath = fileData.serverRelativePath; - const QString message = QLatin1String("SHARE:OK:") + QDir::toNativeSeparators(localFile); + // Can't share root folder + if (remotePath == "/") { + const QString message = QLatin1String("SHARE:CANNOTSHAREROOT:") + QDir::toNativeSeparators(localFile); listener->sendMessage(message); - - emit shareCommandReceived(fileData.localPath); + return; } + + const QString message = QLatin1String("SHARE:OK:") + QDir::toNativeSeparators(localFile); + listener->sendMessage(message); + + emit shareCommandReceived(fileData.localPath); } void SocketApi::processLeaveShareRequest(const QString &localFile, SocketListener *listener) From 89f91d51ece6d450956909700bdbff0c209d54f7 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Fri, 10 Jul 2026 12:08:11 +0200 Subject: [PATCH 037/104] feat(sharing): pass file ID to sharing dialog Signed-off-by: Jyrki Gadinger --- src/gui/owncloudgui.cpp | 4 ++-- src/gui/owncloudgui.h | 2 +- src/gui/sharing/ShareDialog.qml | 3 ++- src/gui/socketapi/socketapi.cpp | 4 +++- src/gui/socketapi/socketapi.h | 2 +- src/gui/systray.cpp | 9 +++++---- src/gui/systray.h | 4 ++-- 7 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index fe13b18a3d7f3..4c736d835416d 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -780,9 +780,9 @@ void ownCloudGui::raiseDialog(QWidget *raiseWidget) } -void ownCloudGui::slotShowShareDialog(const QString &localPath) const +void ownCloudGui::slotShowShareDialog(const QString &localPath, const QString &fileId) const { - _tray->createShareDialog(localPath); + _tray->createShareDialog(localPath, fileId); } void ownCloudGui::slotShowGovernanceLabelsDialog(AccountPtr account, diff --git a/src/gui/owncloudgui.h b/src/gui/owncloudgui.h index 46429bdf41588..2d56cc011aaf8 100644 --- a/src/gui/owncloudgui.h +++ b/src/gui/owncloudgui.h @@ -96,7 +96,7 @@ public slots: * localPath is the absolute local path to it (so not relative * to the folder). */ - void slotShowShareDialog(const QString &localPath) const; + void slotShowShareDialog(const QString &localPath, const QString &fileId) const; void slotShowGovernanceLabelsDialog(AccountPtr account, const QString &localPath, const QString &fileId) const; diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 2c3f1fa782482..a1e23307ec0f1 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -21,6 +21,7 @@ ApplicationWindow { property var account property string localPath: "" property string shortLocalPath: root.localPath.split("/").reverse()[0] + property string fileId: "" readonly property int windowRadius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius @@ -34,7 +35,7 @@ ApplicationWindow { } Component.onCompleted: { - sharingController.createShare("8") // TODO: do not hardcode fileid + sharingController.createShare(root.fileId) } width: 400 diff --git a/src/gui/socketapi/socketapi.cpp b/src/gui/socketapi/socketapi.cpp index 3a67999ff2a6f..129862d126f52 100644 --- a/src/gui/socketapi/socketapi.cpp +++ b/src/gui/socketapi/socketapi.cpp @@ -641,7 +641,9 @@ void SocketApi::processShareRequest(const QString &localFile, SocketListener *li const QString message = QLatin1String("SHARE:OK:") + QDir::toNativeSeparators(localFile); listener->sendMessage(message); - emit shareCommandReceived(fileData.localPath); + const QString fileId = fileData.journalRecord().numericFileId(); + + emit shareCommandReceived(fileData.localPath, fileId); } void SocketApi::processLeaveShareRequest(const QString &localFile, SocketListener *listener) diff --git a/src/gui/socketapi/socketapi.h b/src/gui/socketapi/socketapi.h index b0a08ecaa328b..bc00fc0d11453 100644 --- a/src/gui/socketapi/socketapi.h +++ b/src/gui/socketapi/socketapi.h @@ -70,7 +70,7 @@ public slots: void broadcastStatusPushMessage(const QString &systemPath, OCC::SyncFileStatus fileStatus); signals: - void shareCommandReceived(const QString &localPath); + void shareCommandReceived(const QString &localPath, const QString &fileId); void fileActivityCommandReceived(const QString &localPath); void fileActionsCommandReceived(const QString &localPath); void governanceLabelsCommandReceived(OCC::AccountPtr account, const QString &filePath, const QString &fileId); diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index a8f07ab67fb11..d3a27905e42a5 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -842,14 +842,14 @@ bool Systray::raiseFileDetailDialogs(const QString &localPath) return !_fileDetailDialogs.empty(); } -void Systray::createFileDetailsDialog(const QString &localPath) +void Systray::createFileDetailsDialog(const QString &localPath, const QString &fileId) { if (raiseFileDetailDialogs(localPath)) { qCDebug(lcSystray) << "Reopening an existing file details dialog for " << localPath; return; } - qCDebug(lcSystray) << "Opening new file details dialog for " << localPath; + qCDebug(lcSystray).nospace() << "Opening new file details dialog localPath=" << localPath << " fileId=" << fileId; if (!_trayEngine) { qCWarning(lcSystray) << "Could not open file details dialog for" << localPath << "as no tray engine was available"; @@ -869,6 +869,7 @@ void Systray::createFileDetailsDialog(const QString &localPath) const QVariantMap initialProperties{ {"account", QVariant::fromValue(folder->accountState()->account())}, {"localPath", localPath}, + {"fileId", fileId}, }; QQmlComponent fileDetailsDialog(trayEngine(), "com.nextcloud.desktopclient.sharing"_L1, "ShareDialog"_L1); @@ -922,9 +923,9 @@ void Systray::createFileDetailsDialog(const QString &localPath) } } -void Systray::createShareDialog(const QString &localPath) +void Systray::createShareDialog(const QString &localPath, const QString &fileId) { - createFileDetailsDialog(localPath); + createFileDetailsDialog(localPath, fileId); Q_EMIT showFileDetailsPage(localPath, FileDetailsPage::Sharing); } diff --git a/src/gui/systray.h b/src/gui/systray.h index 29ae435439979..6b3a19f701622 100644 --- a/src/gui/systray.h +++ b/src/gui/systray.h @@ -161,7 +161,7 @@ public slots: void setSyncIsPaused(const bool syncIsPaused); void setIsOpen(const bool isOpen); - void createShareDialog(const QString &localPath); + void createShareDialog(const QString &localPath, const QString &fileId = {}); void createFileActivityDialog(const QString &localPath); void showFileActionsDialog(const QString &localPath); @@ -189,7 +189,7 @@ private slots: Systray(); void setupContextMenu(); - void createFileDetailsDialog(const QString &localPath); + void createFileDetailsDialog(const QString &localPath, const QString &fileId = {}); void createFileActionsDialog(const QString &localPath); #ifdef BUILD_FILE_PROVIDER_MODULE From 8b053bf547f4b355ad58196ad771c22308206283 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Thu, 16 Jul 2026 15:41:09 +0200 Subject: [PATCH 038/104] refactor(sharing): simplify settings page properties Signed-off-by: Jyrki Gadinger --- src/gui/sharing/SettingsPage.qml | 5 ----- src/gui/sharing/ShareDialog.qml | 2 -- 2 files changed, 7 deletions(-) diff --git a/src/gui/sharing/SettingsPage.qml b/src/gui/sharing/SettingsPage.qml index 4cbcf85e60384..1d118e7acd307 100644 --- a/src/gui/sharing/SettingsPage.qml +++ b/src/gui/sharing/SettingsPage.qml @@ -16,10 +16,7 @@ import Style Page { id: root - property string localPath: "" - property string shortLocalPath: "" required property SharingController sharingController - required property list recipientTypes title: qsTr("Sharing settings") @@ -48,9 +45,7 @@ Page { share: root.sharingController.share } - delegate: FieldDelegate { - account: root.sharingController.account width: propertyList.contentItem.width } } diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index a1e23307ec0f1..e364e868edf8e 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -128,8 +128,6 @@ ApplicationWindow { onClicked: stack.push(Qt.createComponent("com.nextcloud.desktopclient.sharing", "SettingsPage").createObject(root, { sharingController: sharingController, - shortLocalPath: root.shortLocalPath, - recipientTypes: Qt.binding(() => root.recipientTypes), })) } From 077aafeaa6e7ce10bdae3520d9ce11471d89f7cd Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Thu, 16 Jul 2026 15:41:57 +0200 Subject: [PATCH 039/104] refactor(sharing): reuse wizard-styled components Signed-off-by: Jyrki Gadinger --- src/gui/sharing/MainPage.qml | 9 +++- src/gui/sharing/ShareDialog.qml | 89 +++++++++++---------------------- 2 files changed, 36 insertions(+), 62 deletions(-) diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index cbfd129cbddae..12622053e9e30 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -12,6 +12,9 @@ import QtQuick.Controls import com.nextcloud.desktopclient import Style +// import "qrc:/qml/src/gui" +// import "qrc:/qml/src/gui/tray" +import "qrc:/qml/src/gui/wizard/qml" Page { id: root @@ -37,6 +40,8 @@ Page { rightPadding: ScrollBar.vertical.policy == ScrollBar.AlwaysOn ? ScrollBar.vertical.width + Style.standardSpacing : 0 ColumnLayout { + width: parent.width + RecipientSearchField { id: searchField Layout.fillWidth: true @@ -50,8 +55,10 @@ Page { } } - ComboBox { + WizardComboBox { id: permissionPresetSelector + Layout.fillWidth: true + model: [ { preset: "view", text: qsTr("Can view") }, { preset: "edit", text: qsTr("Can edit") }, diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index e364e868edf8e..624cf09374d99 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -12,9 +12,11 @@ import QtQuick.Controls import com.nextcloud.desktopclient import Style +import "qrc:/qml/src/gui" import "qrc:/qml/src/gui/tray" +import "qrc:/qml/src/gui/wizard/qml" -ApplicationWindow { +WizardStyledWindow { id: root visible: true @@ -23,7 +25,11 @@ ApplicationWindow { property string shortLocalPath: root.localPath.split("/").reverse()[0] property string fileId: "" - readonly property int windowRadius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius + title: mainPage.title + width: 400 + height: 500 + minimumWidth: Style.wizardStandaloneWindowMinimumWidth + minimumHeight: Style.wizardStandaloneWindowMinimumHeight ButtonGroup { id: shareType @@ -34,24 +40,24 @@ ApplicationWindow { account: root.account } + Shortcut { + sequences: [StandardKey.Cancel] + onActivated: root.close() + } + Component.onCompleted: { sharingController.createShare(root.fileId) } - width: 400 - height: 500 - minimumWidth: 300 - minimumHeight: 300 - - title: mainPage.title - ColumnLayout { - spacing: Style.standardSpacing anchors.fill: parent - anchors.margins: Style.standardSpacing + anchors.leftMargin: Style.wizardWindowMargin + anchors.rightMargin: Style.wizardWindowMargin + anchors.topMargin: Style.wizardWindowTopMargin + anchors.bottomMargin: Style.wizardWindowMargin + spacing: Style.wizardSectionSpacing RowLayout { - // TODO: extract this to a shared component id: windowHeader Layout.fillWidth: true @@ -68,7 +74,6 @@ ApplicationWindow { Layout.topMargin: Style.extraSmallSpacing background: Rectangle { color: "transparent" - radius: root.windowRadius border.width: parent.hovered ? Style.trayWindowBorderWidth : 0 border.color: palette.dark anchors.fill: parent @@ -84,8 +89,8 @@ ApplicationWindow { id: headerSettings text: stack.currentItem.title elide: Text.ElideRight - font.bold: true - font.pixelSize: Style.pixelSize + font.pixelSize: Style.wizardHeaderTitleFontPixelSize + font.weight: Font.DemiBold color: palette.text Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft @@ -94,9 +99,8 @@ ApplicationWindow { id: headerSettingsLocalPath text: root.shortLocalPath elide: Text.ElideRight - font.bold: false - font.pixelSize: Style.pixelSize - color: palette.text + color: Style.wizardSecondaryText + font.pixelSize: Style.wizardHeaderAccountServerFontPixelSize Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft @@ -104,25 +108,9 @@ ApplicationWindow { } } - Button { + WizardButton { id: settingsButton - flat: true - padding: Style.extraSmallSpacing - spacing: 0 - icon.source: "image://svgimage-custom-color/settings.svg/" + palette.windowText - icon.width: Style.extraSmallIconSize - icon.height: Style.extraSmallIconSize - Layout.alignment: Qt.AlignTop | Qt.AlignRight - Layout.rightMargin: Style.extraSmallSpacing - Layout.topMargin: Style.extraSmallSpacing - background: Rectangle { - color: "transparent" - radius: root.windowRadius - border.width: parent.hovered ? Style.trayWindowBorderWidth : 0 - border.color: palette.dark - anchors.fill: parent - Layout.margins: Style.extraSmallSpacing - } + iconSource: "image://svgimage-custom-color/settings.svg/" + palette.windowText visible: stack.depth < 2 @@ -130,35 +118,12 @@ ApplicationWindow { sharingController: sharingController, })) } - - Button { - id: closeButton - flat: true - padding: Style.extraSmallSpacing - spacing: 0 - icon.source: "image://svgimage-custom-color/close.svg/" + palette.windowText - icon.width: Style.extraSmallIconSize - icon.height: Style.extraSmallIconSize - Layout.alignment: Qt.AlignTop | Qt.AlignRight - Layout.rightMargin: Style.extraSmallSpacing - Layout.topMargin: Style.extraSmallSpacing - background: Rectangle { - color: "transparent" - radius: root.windowRadius - border.width: parent.hovered ? Style.trayWindowBorderWidth : 0 - border.color: palette.dark - anchors.fill: parent - Layout.margins: Style.extraSmallSpacing - } - - onClicked: root.close() - } } RowLayout { Layout.fillWidth: true - Button { + WizardButton { Layout.fillWidth: true id: viewInvitedPeople @@ -166,16 +131,18 @@ ApplicationWindow { checkable: true checked: true + primary: checked // TODO: extend WizardButton for checked state ButtonGroup.group: shareType } - Button { + WizardButton { Layout.fillWidth: true id: viewAnyone text: qsTr("Anyone") checkable: true + primary: checked // TODO: extend WizardButton for checked state ButtonGroup.group: shareType onCheckedChanged: { From c27029cf908faa5d060d27f4e0ecaa5b00ba51f0 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 17:04:18 +0800 Subject: [PATCH 040/104] feat(sharing): add unified sharing api class Signed-off-by: Claudio Cambra --- src/gui/sharing/CMakeLists.txt | 2 + src/gui/sharing/unifiedsharingapi.cpp | 85 +++++++++++++++++++++++ src/gui/sharing/unifiedsharingapi.h | 40 +++++++++++ src/gui/sharing/unifiedsharingrequest.cpp | 40 +++++++++++ src/gui/sharing/unifiedsharingrequest.h | 39 +++++++++++ 5 files changed, 206 insertions(+) create mode 100644 src/gui/sharing/unifiedsharingapi.cpp create mode 100644 src/gui/sharing/unifiedsharingapi.h create mode 100644 src/gui/sharing/unifiedsharingrequest.cpp create mode 100644 src/gui/sharing/unifiedsharingrequest.h diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index e00b71a60150c..df52024f328ce 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -27,6 +27,8 @@ target_sources(nextcloudGuiSharing share.cpp sharingcontroller.h sharingcontroller.cpp + unifiedsharingapi.h + unifiedsharingapi.cpp ) ecm_add_qml_module(nextcloudGuiSharing diff --git a/src/gui/sharing/unifiedsharingapi.cpp b/src/gui/sharing/unifiedsharingapi.cpp new file mode 100644 index 0000000000000..b5d8f20cf2921 --- /dev/null +++ b/src/gui/sharing/unifiedsharingapi.cpp @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "unifiedsharingapi.h" + +#include "unifiedsharingrequest.h" + +using namespace Qt::StringLiterals; + +namespace OCC::Gui::Sharing +{ + +namespace +{ +constexpr auto sharingV1Base = "/ocs/v2.php/apps/sharing/api/v1"_L1; +constexpr auto nodeSourceType = "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1; +} + +UnifiedSharingApi::UnifiedSharingApi(AccountPtr account, QObject *parent) + : QObject{parent} + , _account{std::move(account)} +{ +} + +UnifiedSharingRequest *UnifiedSharingApi::createShare() +{ + return new UnifiedSharingRequest{_account, sharingV1Base % "/share"_L1, "POST"_ba, {}, {201}}; +} + +UnifiedSharingRequest *UnifiedSharingApi::destroyShare(const QString &shareId) +{ + return new UnifiedSharingRequest{_account, sharingV1Base % "/share/%1"_L1.arg(shareId), "DELETE"_ba, {}, {204}}; +} + +UnifiedSharingRequest *UnifiedSharingApi::addSource(const QString &shareId, const QString &fileId) +{ + return new UnifiedSharingRequest{_account, + sharingV1Base % "/share/%1/source"_L1.arg(shareId), + "POST"_ba, + {{"class"_L1, nodeSourceType}, {"value"_L1, fileId}}}; +} + +UnifiedSharingRequest *UnifiedSharingApi::addRecipient(const QString &shareId, const QString &recipientType, const QString &recipientValue) +{ + return new UnifiedSharingRequest{_account, + sharingV1Base % "/share/%1/recipient"_L1.arg(shareId), + "POST"_ba, + {{"class"_L1, recipientType}, {"value"_L1, recipientValue}}}; +} + +UnifiedSharingRequest *UnifiedSharingApi::removeRecipient(const QString &shareId, const QString &recipientType, const QString &recipientValue) +{ + return new UnifiedSharingRequest{_account, + sharingV1Base % "/share/%1/recipient"_L1.arg(shareId), + "DELETE"_ba, + {{"class"_L1, recipientType}, {"value"_L1, recipientValue}}}; +} + +UnifiedSharingRequest *UnifiedSharingApi::searchRecipients(const QString &query, qint64 offset, qint64 limit) +{ + return new UnifiedSharingRequest{_account, + sharingV1Base % "/recipients"_L1, + "GET"_ba, + {{"query"_L1, query}, {"offset"_L1, QString::number(offset)}, {"limit"_L1, QString::number(limit)}}}; +} + +UnifiedSharingRequest *UnifiedSharingApi::setPermission(const QString &shareId, const QString &permissionClass, bool enabled) +{ + return new UnifiedSharingRequest{_account, + sharingV1Base % "/share/%1/permission"_L1.arg(shareId), + "PUT"_ba, + {{"class"_L1, permissionClass}, {"enabled"_L1, enabled ? "true"_L1 : "false"_L1}}}; +} + +UnifiedSharingRequest *UnifiedSharingApi::setPermissionPreset(const QString &shareId, const QString &permissionPreset) +{ + return new UnifiedSharingRequest{_account, + sharingV1Base % "/share/%1/permission/preset"_L1.arg(shareId), + "PUT"_ba, + {{"permissionPreset"_L1, permissionPreset}}}; +} + +} diff --git a/src/gui/sharing/unifiedsharingapi.h b/src/gui/sharing/unifiedsharingapi.h new file mode 100644 index 0000000000000..08709d296141a --- /dev/null +++ b/src/gui/sharing/unifiedsharingapi.h @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include + +#include "accountfwd.h" + +namespace OCC::Gui::Sharing +{ + +class UnifiedSharingRequest; + +/** + * @brief Creates one-shot requests for the Unified Sharing API. + */ +class UnifiedSharingApi : public QObject +{ + Q_OBJECT + +public: + explicit UnifiedSharingApi(AccountPtr account, QObject *parent = nullptr); + + [[nodiscard]] UnifiedSharingRequest *createShare(); + [[nodiscard]] UnifiedSharingRequest *destroyShare(const QString &shareId); + [[nodiscard]] UnifiedSharingRequest *addSource(const QString &shareId, const QString &fileId); + [[nodiscard]] UnifiedSharingRequest *addRecipient(const QString &shareId, const QString &recipientType, const QString &recipientValue); + [[nodiscard]] UnifiedSharingRequest *removeRecipient(const QString &shareId, const QString &recipientType, const QString &recipientValue); + [[nodiscard]] UnifiedSharingRequest *searchRecipients(const QString &query, qint64 offset, qint64 limit); + [[nodiscard]] UnifiedSharingRequest *setPermission(const QString &shareId, const QString &permissionClass, bool enabled); + [[nodiscard]] UnifiedSharingRequest *setPermissionPreset(const QString &shareId, const QString &permissionPreset); + +private: + AccountPtr _account; +}; + +} diff --git a/src/gui/sharing/unifiedsharingrequest.cpp b/src/gui/sharing/unifiedsharingrequest.cpp new file mode 100644 index 0000000000000..0f6c7c043afca --- /dev/null +++ b/src/gui/sharing/unifiedsharingrequest.cpp @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "unifiedsharingrequest.h" + +#include + +Q_LOGGING_CATEGORY(lcUnifiedSharingRequest, "nextcloud.gui.sharing.unifiedsharingrequest", QtInfoMsg) + +using namespace OCC; +using namespace OCC::Gui::Sharing; + +UnifiedSharingRequest::UnifiedSharingRequest(AccountPtr account, + const QString &path, + const QByteArray &verb, + const QList> ¶meters, + const QList &passStatusCodes) + : OcsJob{account} +{ + setPath(path); + setVerb(verb); + for (const auto &[name, value] : parameters) { + addParam(name, value); + } + for (const auto statusCode : passStatusCodes) { + addPassStatusCode(statusCode); + } +} + +void UnifiedSharingRequest::start() +{ + if (_started) { + qCWarning(lcUnifiedSharingRequest) << "Attempted to start a Unified Sharing request more than once."; + return; + } + _started = true; + OcsJob::start(); +} diff --git a/src/gui/sharing/unifiedsharingrequest.h b/src/gui/sharing/unifiedsharingrequest.h new file mode 100644 index 0000000000000..6b2fc3b0f2527 --- /dev/null +++ b/src/gui/sharing/unifiedsharingrequest.h @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include +#include + +#include "ocsjob.h" + +#include "accountfwd.h" + +namespace OCC::Gui::Sharing +{ + +/** + * @brief Configures and starts one request to the Unified Sharing API. + */ +class UnifiedSharingRequest : public OcsJob +{ + Q_OBJECT + +public: + explicit UnifiedSharingRequest(AccountPtr account, + const QString &path, + const QByteArray &verb, + const QList> ¶meters = {}, + const QList &passStatusCodes = {}); + + void start() override; + +private: + bool _started = false; +}; + +} From ccf24caaab2f3e997285e96e375e6343bc5ab04d Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 17:41:05 +0800 Subject: [PATCH 041/104] feat(sharing): Split unified share request into specific types with job-specific return values Signed-off-by: Claudio Cambra --- src/gui/sharing/CMakeLists.txt | 12 +++- src/gui/sharing/createsharejob.cpp | 23 ++++++++ src/gui/sharing/createsharejob.h | 31 ++++++++++ src/gui/sharing/destroysharejob.cpp | 18 ++++++ src/gui/sharing/destroysharejob.h | 25 ++++++++ src/gui/sharing/searchrecipientsjob.cpp | 27 +++++++++ src/gui/sharing/searchrecipientsjob.h | 32 ++++++++++ src/gui/sharing/unifiedsharingapi.cpp | 77 +++++++++++++------------ src/gui/sharing/unifiedsharingapi.h | 25 ++++---- src/gui/sharing/updatesharejob.cpp | 28 +++++++++ src/gui/sharing/updatesharejob.h | 35 +++++++++++ 11 files changed, 285 insertions(+), 48 deletions(-) create mode 100644 src/gui/sharing/createsharejob.cpp create mode 100644 src/gui/sharing/createsharejob.h create mode 100644 src/gui/sharing/destroysharejob.cpp create mode 100644 src/gui/sharing/destroysharejob.h create mode 100644 src/gui/sharing/searchrecipientsjob.cpp create mode 100644 src/gui/sharing/searchrecipientsjob.h create mode 100644 src/gui/sharing/updatesharejob.cpp create mode 100644 src/gui/sharing/updatesharejob.h diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index df52024f328ce..fe04a454fced9 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -7,8 +7,16 @@ target_sources(nextcloudGuiSharing PRIVATE abstractsharemodel.h abstractsharemodel.cpp - ocssharingjob.h - ocssharingjob.cpp + unifiedsharingrequest.h + unifiedsharingrequest.cpp + createsharejob.h + createsharejob.cpp + destroysharejob.h + destroysharejob.cpp + searchrecipientsjob.h + searchrecipientsjob.cpp + updatesharejob.h + updatesharejob.cpp permission.h permission.cpp permissionmodel.h diff --git a/src/gui/sharing/createsharejob.cpp b/src/gui/sharing/createsharejob.cpp new file mode 100644 index 0000000000000..41f04b4174115 --- /dev/null +++ b/src/gui/sharing/createsharejob.cpp @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "createsharejob.h" + +#include "share.h" + +using namespace Qt::StringLiterals; + +namespace OCC::Gui::Sharing +{ + +CreateShareJob::CreateShareJob(AccountPtr account) + : UnifiedSharingRequest{account, "/ocs/v2.php/apps/sharing/api/v1/share"_L1, "POST"_ba, {}, {201}} +{ + connect(this, &OcsJob::jobFinished, this, [this, account = std::move(account)](const QJsonDocument &json, int) { + Q_EMIT shareCreated(Share::fromJson(json, account)); + }); +} + +} diff --git a/src/gui/sharing/createsharejob.h b/src/gui/sharing/createsharejob.h new file mode 100644 index 0000000000000..a3ac58bf2798c --- /dev/null +++ b/src/gui/sharing/createsharejob.h @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "unifiedsharingrequest.h" + +namespace OCC::Gui::Sharing +{ + +class Share; +class UnifiedSharingApi; + +/** + * @brief Creates a share and returns its parsed representation. + */ +class CreateShareJob : public UnifiedSharingRequest +{ + Q_OBJECT + +Q_SIGNALS: + void shareCreated(QPointer share); + +private: + friend class UnifiedSharingApi; + explicit CreateShareJob(AccountPtr account); +}; + +} diff --git a/src/gui/sharing/destroysharejob.cpp b/src/gui/sharing/destroysharejob.cpp new file mode 100644 index 0000000000000..797e5dfdae885 --- /dev/null +++ b/src/gui/sharing/destroysharejob.cpp @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "destroysharejob.h" + +using namespace Qt::StringLiterals; + +namespace OCC::Gui::Sharing +{ + +DestroyShareJob::DestroyShareJob(AccountPtr account, const QString &shareId) + : UnifiedSharingRequest{std::move(account), "/ocs/v2.php/apps/sharing/api/v1/share/%1"_L1.arg(shareId), "DELETE"_ba, {}, {204}} +{ +} + +} diff --git a/src/gui/sharing/destroysharejob.h b/src/gui/sharing/destroysharejob.h new file mode 100644 index 0000000000000..ef453b4bb484e --- /dev/null +++ b/src/gui/sharing/destroysharejob.h @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "unifiedsharingrequest.h" + +namespace OCC::Gui::Sharing +{ + +class UnifiedSharingApi; + +/** + * @brief Destroys a share. + */ +class DestroyShareJob : public UnifiedSharingRequest +{ +private: + friend class UnifiedSharingApi; + explicit DestroyShareJob(AccountPtr account, const QString &shareId); +}; + +} diff --git a/src/gui/sharing/searchrecipientsjob.cpp b/src/gui/sharing/searchrecipientsjob.cpp new file mode 100644 index 0000000000000..f9b8afab31c81 --- /dev/null +++ b/src/gui/sharing/searchrecipientsjob.cpp @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "searchrecipientsjob.h" + +#include +#include + +using namespace Qt::StringLiterals; + +namespace OCC::Gui::Sharing +{ + +SearchRecipientsJob::SearchRecipientsJob(AccountPtr account, const QString &query, qint64 offset, qint64 limit) + : UnifiedSharingRequest{std::move(account), + "/ocs/v2.php/apps/sharing/api/v1/recipients"_L1, + "GET"_ba, + {{"query"_L1, query}, {"offset"_L1, QString::number(offset)}, {"limit"_L1, QString::number(limit)}}} +{ + connect(this, &OcsJob::jobFinished, this, [this](const QJsonDocument &json, int) { + Q_EMIT recipientsFound(json.object().value("ocs"_L1).toObject().value("data"_L1).toArray()); + }); +} + +} diff --git a/src/gui/sharing/searchrecipientsjob.h b/src/gui/sharing/searchrecipientsjob.h new file mode 100644 index 0000000000000..972eef7836c06 --- /dev/null +++ b/src/gui/sharing/searchrecipientsjob.h @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "unifiedsharingrequest.h" + +#include + +namespace OCC::Gui::Sharing +{ + +class UnifiedSharingApi; + +/** + * @brief Searches for recipients and returns the result entries. + */ +class SearchRecipientsJob : public UnifiedSharingRequest +{ + Q_OBJECT + +Q_SIGNALS: + void recipientsFound(const QJsonArray &recipients); + +private: + friend class UnifiedSharingApi; + explicit SearchRecipientsJob(AccountPtr account, const QString &query, qint64 offset, qint64 limit); +}; + +} diff --git a/src/gui/sharing/unifiedsharingapi.cpp b/src/gui/sharing/unifiedsharingapi.cpp index b5d8f20cf2921..533d861bd2b13 100644 --- a/src/gui/sharing/unifiedsharingapi.cpp +++ b/src/gui/sharing/unifiedsharingapi.cpp @@ -5,7 +5,11 @@ #include "unifiedsharingapi.h" -#include "unifiedsharingrequest.h" +#include "createsharejob.h" +#include "destroysharejob.h" +#include "searchrecipientsjob.h" +#include "share.h" +#include "updatesharejob.h" using namespace Qt::StringLiterals; @@ -14,7 +18,6 @@ namespace OCC::Gui::Sharing namespace { -constexpr auto sharingV1Base = "/ocs/v2.php/apps/sharing/api/v1"_L1; constexpr auto nodeSourceType = "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1; } @@ -24,62 +27,64 @@ UnifiedSharingApi::UnifiedSharingApi(AccountPtr account, QObject *parent) { } -UnifiedSharingRequest *UnifiedSharingApi::createShare() +CreateShareJob *UnifiedSharingApi::createShare() const { - return new UnifiedSharingRequest{_account, sharingV1Base % "/share"_L1, "POST"_ba, {}, {201}}; + return new CreateShareJob{_account}; } -UnifiedSharingRequest *UnifiedSharingApi::destroyShare(const QString &shareId) +DestroyShareJob *UnifiedSharingApi::destroyShare(const QString &shareId) const { - return new UnifiedSharingRequest{_account, sharingV1Base % "/share/%1"_L1.arg(shareId), "DELETE"_ba, {}, {204}}; + return new DestroyShareJob{_account, shareId}; } -UnifiedSharingRequest *UnifiedSharingApi::addSource(const QString &shareId, const QString &fileId) +UpdateShareJob *UnifiedSharingApi::addSource(QPointer share, const QString &fileId) const { - return new UnifiedSharingRequest{_account, - sharingV1Base % "/share/%1/source"_L1.arg(shareId), - "POST"_ba, - {{"class"_L1, nodeSourceType}, {"value"_L1, fileId}}}; + return new UpdateShareJob{_account, + share, + "/ocs/v2.php/apps/sharing/api/v1/share/%1/source"_L1.arg(share->id()), + "POST"_ba, + {{"class"_L1, nodeSourceType}, {"value"_L1, fileId}}}; } -UnifiedSharingRequest *UnifiedSharingApi::addRecipient(const QString &shareId, const QString &recipientType, const QString &recipientValue) +UpdateShareJob *UnifiedSharingApi::addRecipient(QPointer share, const QString &recipientType, const QString &recipientValue) const { - return new UnifiedSharingRequest{_account, - sharingV1Base % "/share/%1/recipient"_L1.arg(shareId), - "POST"_ba, - {{"class"_L1, recipientType}, {"value"_L1, recipientValue}}}; + return new UpdateShareJob{_account, + share, + "/ocs/v2.php/apps/sharing/api/v1/share/%1/recipient"_L1.arg(share->id()), + "POST"_ba, + {{"class"_L1, recipientType}, {"value"_L1, recipientValue}}}; } -UnifiedSharingRequest *UnifiedSharingApi::removeRecipient(const QString &shareId, const QString &recipientType, const QString &recipientValue) +UpdateShareJob *UnifiedSharingApi::removeRecipient(QPointer share, const QString &recipientType, const QString &recipientValue) const { - return new UnifiedSharingRequest{_account, - sharingV1Base % "/share/%1/recipient"_L1.arg(shareId), - "DELETE"_ba, - {{"class"_L1, recipientType}, {"value"_L1, recipientValue}}}; + return new UpdateShareJob{_account, + share, + "/ocs/v2.php/apps/sharing/api/v1/share/%1/recipient"_L1.arg(share->id()), + "DELETE"_ba, + {{"class"_L1, recipientType}, {"value"_L1, recipientValue}}}; } -UnifiedSharingRequest *UnifiedSharingApi::searchRecipients(const QString &query, qint64 offset, qint64 limit) +SearchRecipientsJob *UnifiedSharingApi::searchRecipients(const QString &query, qint64 offset, qint64 limit) const { - return new UnifiedSharingRequest{_account, - sharingV1Base % "/recipients"_L1, - "GET"_ba, - {{"query"_L1, query}, {"offset"_L1, QString::number(offset)}, {"limit"_L1, QString::number(limit)}}}; + return new SearchRecipientsJob{_account, query, offset, limit}; } -UnifiedSharingRequest *UnifiedSharingApi::setPermission(const QString &shareId, const QString &permissionClass, bool enabled) +UpdateShareJob *UnifiedSharingApi::setPermission(QPointer share, const QString &permissionClass, bool enabled) const { - return new UnifiedSharingRequest{_account, - sharingV1Base % "/share/%1/permission"_L1.arg(shareId), - "PUT"_ba, - {{"class"_L1, permissionClass}, {"enabled"_L1, enabled ? "true"_L1 : "false"_L1}}}; + return new UpdateShareJob{_account, + share, + "/ocs/v2.php/apps/sharing/api/v1/share/%1/permission"_L1.arg(share->id()), + "PUT"_ba, + {{"class"_L1, permissionClass}, {"enabled"_L1, enabled ? "true"_L1 : "false"_L1}}}; } -UnifiedSharingRequest *UnifiedSharingApi::setPermissionPreset(const QString &shareId, const QString &permissionPreset) +UpdateShareJob *UnifiedSharingApi::setPermissionPreset(QPointer share, const QString &permissionPreset) const { - return new UnifiedSharingRequest{_account, - sharingV1Base % "/share/%1/permission/preset"_L1.arg(shareId), - "PUT"_ba, - {{"permissionPreset"_L1, permissionPreset}}}; + return new UpdateShareJob{_account, + share, + "/ocs/v2.php/apps/sharing/api/v1/share/%1/permission/preset"_L1.arg(share->id()), + "PUT"_ba, + {{"permissionPreset"_L1, permissionPreset}}}; } } diff --git a/src/gui/sharing/unifiedsharingapi.h b/src/gui/sharing/unifiedsharingapi.h index 08709d296141a..125d06d977fa3 100644 --- a/src/gui/sharing/unifiedsharingapi.h +++ b/src/gui/sharing/unifiedsharingapi.h @@ -6,16 +6,21 @@ #pragma once #include +#include #include "accountfwd.h" namespace OCC::Gui::Sharing { -class UnifiedSharingRequest; +class CreateShareJob; +class DestroyShareJob; +class SearchRecipientsJob; +class Share; +class UpdateShareJob; /** - * @brief Creates one-shot requests for the Unified Sharing API. + * @brief Creates typed jobs for the Unified Sharing API. */ class UnifiedSharingApi : public QObject { @@ -24,14 +29,14 @@ class UnifiedSharingApi : public QObject public: explicit UnifiedSharingApi(AccountPtr account, QObject *parent = nullptr); - [[nodiscard]] UnifiedSharingRequest *createShare(); - [[nodiscard]] UnifiedSharingRequest *destroyShare(const QString &shareId); - [[nodiscard]] UnifiedSharingRequest *addSource(const QString &shareId, const QString &fileId); - [[nodiscard]] UnifiedSharingRequest *addRecipient(const QString &shareId, const QString &recipientType, const QString &recipientValue); - [[nodiscard]] UnifiedSharingRequest *removeRecipient(const QString &shareId, const QString &recipientType, const QString &recipientValue); - [[nodiscard]] UnifiedSharingRequest *searchRecipients(const QString &query, qint64 offset, qint64 limit); - [[nodiscard]] UnifiedSharingRequest *setPermission(const QString &shareId, const QString &permissionClass, bool enabled); - [[nodiscard]] UnifiedSharingRequest *setPermissionPreset(const QString &shareId, const QString &permissionPreset); + [[nodiscard]] CreateShareJob *createShare() const; + [[nodiscard]] DestroyShareJob *destroyShare(const QString &shareId) const; + [[nodiscard]] UpdateShareJob *addSource(QPointer share, const QString &fileId) const; + [[nodiscard]] UpdateShareJob *addRecipient(QPointer share, const QString &recipientType, const QString &recipientValue) const; + [[nodiscard]] UpdateShareJob *removeRecipient(QPointer share, const QString &recipientType, const QString &recipientValue) const; + [[nodiscard]] SearchRecipientsJob *searchRecipients(const QString &query, qint64 offset, qint64 limit) const; + [[nodiscard]] UpdateShareJob *setPermission(QPointer share, const QString &permissionClass, bool enabled) const; + [[nodiscard]] UpdateShareJob *setPermissionPreset(QPointer share, const QString &permissionPreset) const; private: AccountPtr _account; diff --git a/src/gui/sharing/updatesharejob.cpp b/src/gui/sharing/updatesharejob.cpp new file mode 100644 index 0000000000000..a55e61f7da5c3 --- /dev/null +++ b/src/gui/sharing/updatesharejob.cpp @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "updatesharejob.h" + +#include "share.h" + +namespace OCC::Gui::Sharing +{ + +UpdateShareJob::UpdateShareJob(AccountPtr account, + QPointer share, + const QString &path, + const QByteArray &verb, + const QList> ¶meters) + : UnifiedSharingRequest{std::move(account), path, verb, parameters} +{ + connect(this, &OcsJob::jobFinished, this, [this, share](const QJsonDocument &json, int) { + if (share) { + share->updateFromJson(json); + } + Q_EMIT shareUpdated(share); + }); +} + +} diff --git a/src/gui/sharing/updatesharejob.h b/src/gui/sharing/updatesharejob.h new file mode 100644 index 0000000000000..87363089623af --- /dev/null +++ b/src/gui/sharing/updatesharejob.h @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "unifiedsharingrequest.h" + +namespace OCC::Gui::Sharing +{ + +class Share; +class UnifiedSharingApi; + +/** + * @brief Applies a server response to an existing share. + */ +class UpdateShareJob : public UnifiedSharingRequest +{ + Q_OBJECT + +Q_SIGNALS: + void shareUpdated(QPointer share); + +private: + friend class UnifiedSharingApi; + explicit UpdateShareJob(AccountPtr account, + QPointer share, + const QString &path, + const QByteArray &verb, + const QList> ¶meters); +}; + +} From fb0d61005ac7b04134f1c436d633adefa0287fde Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 18:10:10 +0800 Subject: [PATCH 042/104] feat(sharing): Remove ocssharingjob Signed-off-by: Claudio Cambra --- src/gui/sharing/ocssharingjob.cpp | 158 ----------------------- src/gui/sharing/ocssharingjob.h | 47 ------- src/gui/sharing/recipientsearchmodel.cpp | 21 +-- src/gui/sharing/sharingcontroller.cpp | 65 +++++----- src/gui/sharing/sharingcontroller.h | 7 +- 5 files changed, 48 insertions(+), 250 deletions(-) delete mode 100644 src/gui/sharing/ocssharingjob.cpp delete mode 100644 src/gui/sharing/ocssharingjob.h diff --git a/src/gui/sharing/ocssharingjob.cpp b/src/gui/sharing/ocssharingjob.cpp deleted file mode 100644 index 0aef74f2a1d22..0000000000000 --- a/src/gui/sharing/ocssharingjob.cpp +++ /dev/null @@ -1,158 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#include "ocssharingjob.h" - -#include -#include - -#include "share.h" - -Q_LOGGING_CATEGORY(lcOcsSharingJob, "nextcloud.gui.sharing.ocssharingjob", QtInfoMsg) - -using namespace Qt::StringLiterals; - -using namespace OCC; -using namespace OCC::Gui::Sharing; - -namespace -{ - constexpr auto SHARING_V1_BASE = "/ocs/v2.php/apps/sharing/api/v1"_L1; - - constexpr auto SOURCE_TYPE_NODE = "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1; -} - -OcsSharingJob::OcsSharingJob(AccountPtr account, const QString &shareId) - : OcsJob{account} - , _shareId{shareId} -{ -} - -void OcsSharingJob::createShare() -{ - setPath(SHARING_V1_BASE % "/share"); - setVerb("POST"_ba); - addPassStatusCode(201); - - connect(this, &OcsSharingJob::jobFinished, this, [this](const QJsonDocument &json, int statusCode) -> void { - qCDebug(lcOcsSharingJob) << "share received with status" << statusCode << json; - - auto share = Share::fromJson(json, _account); - - Q_EMIT shareCreated(share); - }); - - start(); -} - -void OcsSharingJob::destroyShare() -{ - if (_shareId.isEmpty()) { - qCWarning(lcOcsSharingJob) << "destroyShare called without a shareId, not starting job"; - Q_EMIT jobFinished({}, 0); - return; - } - - setPath(SHARING_V1_BASE % "/share/%1"_L1.arg(_shareId)); - setVerb("DELETE"_ba); - addPassStatusCode(204); - start(); -} - -void OcsSharingJob::addSource(const QString &fileId) -{ - if (_shareId.isEmpty()) { - qCWarning(lcOcsSharingJob) << "addSource called without a shareId, not starting job"; - Q_EMIT jobFinished({}, 0); - return; - } - - setPath(SHARING_V1_BASE % "/share/%1/source"_L1.arg(_shareId)); - setVerb("POST"_ba); - - addParam("class"_L1, SOURCE_TYPE_NODE); - addParam("value"_L1, fileId); - - start(); -} - -void OcsSharingJob::addRecipient(const QString &recipientType, const QString &recipientValue) -{ - if (_shareId.isEmpty()) { - qCWarning(lcOcsSharingJob) << "addRecipient called without a shareId, not starting job"; - Q_EMIT jobFinished({}, 0); - return; - } - - setPath(SHARING_V1_BASE % "/share/%1/recipient"_L1.arg(_shareId)); - setVerb("POST"_ba); - - addParam("class"_L1, recipientType); - addParam("value"_L1, recipientValue); - - start(); -} - -void OcsSharingJob::removeRecipient(const QString &recipientType, const QString &recipientValue) -{ - if (_shareId.isEmpty()) { - qCWarning(lcOcsSharingJob) << "removeRecipient called without a shareId, not starting job"; - Q_EMIT jobFinished({}, 0); - return; - } - - setPath(SHARING_V1_BASE % "/share/%1/recipient"_L1.arg(_shareId)); - setVerb("DELETE"_ba); - - addParam("class"_L1, recipientType); - addParam("value"_L1, recipientValue); - - start(); -} - -void OcsSharingJob::searchRecipients(const QString &query, int64_t offset, int64_t limit) -{ - setPath(SHARING_V1_BASE % "/recipients"); - setVerb("GET"_ba); - - addParam("query"_L1, query); - addParam("offset"_L1, QString::number(offset)); - addParam("limit"_L1, QString::number(limit)); - - start(); -} - -void OcsSharingJob::setPermission(const QString &permissionClass, bool enabled) -{ - if (_shareId.isEmpty()) { - qCWarning(lcOcsSharingJob) << "setPermission called without a shareId, not starting job"; - Q_EMIT jobFinished({}, 0); - return; - } - - setPath(SHARING_V1_BASE % "/share/%1/permission"_L1.arg(_shareId)); - setVerb("PUT"_ba); - - addParam("class"_L1, permissionClass); - addParam("enabled"_L1, enabled ? "true"_L1 : "false"_L1); - - start(); -} - -void OcsSharingJob::setPermissionPreset(const QString &permissionPreset) -{ - if (_shareId.isEmpty()) { - qCWarning(lcOcsSharingJob) << "setPermissionPreset called without a shareId, not starting job"; - Q_EMIT jobFinished({}, 0); - return; - } - - setPath(SHARING_V1_BASE % "/share/%1/permission/preset"_L1.arg(_shareId)); - setVerb("PUT"_ba); - - addParam("permissionPreset"_L1, permissionPreset); - - start(); -} diff --git a/src/gui/sharing/ocssharingjob.h b/src/gui/sharing/ocssharingjob.h deleted file mode 100644 index 72df75a3fa40b..0000000000000 --- a/src/gui/sharing/ocssharingjob.h +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#pragma once - -#include - -#include "ocsjob.h" - -#include "accountfwd.h" - -namespace OCC::Gui::Sharing { - -class Share; - -/** - * \brief The OcsSharingJob class handles requests to the new Unified Sharing API. - */ -class OcsSharingJob : public OcsJob -{ - Q_OBJECT - -public: - explicit OcsSharingJob(AccountPtr account, const QString &shareId = {}); - - void createShare(); - void destroyShare(); - - void addSource(const QString &fileId); - void addRecipient(const QString &recipientType, const QString &recipientValue); - void removeRecipient(const QString &recipientType, const QString &recipientValue); - - void searchRecipients(const QString &query, int64_t offset, int64_t limit); - - void setPermission(const QString &permissionClass, bool enabled); - void setPermissionPreset(const QString &permissionPreset); - -Q_SIGNALS: - void shareCreated(QPointer share); - -private: - QString _shareId; -}; - -} diff --git a/src/gui/sharing/recipientsearchmodel.cpp b/src/gui/sharing/recipientsearchmodel.cpp index ef4319d62c7ab..c67200e327a1b 100644 --- a/src/gui/sharing/recipientsearchmodel.cpp +++ b/src/gui/sharing/recipientsearchmodel.cpp @@ -5,10 +5,11 @@ #include "recipientsearchmodel.h" -#include #include +#include -#include "ocssharingjob.h" +#include "searchrecipientsjob.h" +#include "unifiedsharingapi.h" Q_LOGGING_CATEGORY(lcSharingRecipientShareModel, "nextcloud.gui.sharing.recipientsearchmodel", QtInfoMsg) @@ -50,10 +51,10 @@ QVariant RecipientSearchModel::data(const QModelIndex &index, int role) const QHash RecipientSearchModel::roleNames() const { return { - { TypeRole, "type"_ba}, - { ValueRole, "value"_ba}, - { DisplayNameRole, "displayName"_ba}, - { IconRole, "iconUrl"_ba}, + {TypeRole, "type"_ba}, + {ValueRole, "value"_ba}, + {DisplayNameRole, "displayName"_ba}, + {IconRole, "iconUrl"_ba}, }; }; @@ -101,11 +102,11 @@ void RecipientSearchModel::setQuery(const QString &query) } // TODO: start timer for search job - auto job = new OcsSharingJob(_account); - connect(job, &OcsSharingJob::jobFinished, this, [this](const QJsonDocument &json, int statusCode) -> void { + const auto job = UnifiedSharingApi{_account}.searchRecipients(query, 0, 10); + connect(job, &SearchRecipientsJob::recipientsFound, this, [this](const QJsonArray &recipients) { beginResetModel(); - _searchResults = json.object().value("ocs"_L1).toObject().value("data"_L1).toArray(); + _searchResults = recipients; endResetModel(); }); - job->searchRecipients(query, 0, 10); + job->start(); } diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index 9f4da7edcc52b..4492c9cfa8344 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -8,8 +8,11 @@ #include #include +#include "createsharejob.h" +#include "destroysharejob.h" #include "share.h" -#include "ocssharingjob.h" +#include "unifiedsharingapi.h" +#include "updatesharejob.h" Q_LOGGING_CATEGORY(lcSharingController, "nextcloud.gui.sharing.sharingcontroller", QtInfoMsg) @@ -35,6 +38,8 @@ void SharingController::setAccount(AccountPtr account) } _account = account; + delete _api; + _api = _account ? new UnifiedSharingApi{_account, this} : nullptr; Q_EMIT accountChanged(); } @@ -50,8 +55,8 @@ void SharingController::createShare(const QString &fileId) return; } - const auto job = new OcsSharingJob(_account); - connect(job, &OcsSharingJob::shareCreated, this, [this, fileId](QPointer share) -> void { + const auto job = _api->createShare(); + connect(job, &CreateShareJob::shareCreated, this, [this, fileId](QPointer share) -> void { if (!share) { qCWarning(lcSharingController) << "share created without a valid Share object"; return; @@ -62,7 +67,7 @@ void SharingController::createShare(const QString &fileId) Q_EMIT shareChanged(); addSourceAfterCreation(fileId); }); - job->createShare(); + job->start(); } void SharingController::destroyShare() @@ -77,12 +82,12 @@ void SharingController::destroyShare() return; } - const auto job = new OcsSharingJob(_account); - connect(job, &OcsSharingJob::jobFinished, this, [this](const QJsonDocument &json, int statusCode) -> void { + const auto job = _api->destroyShare(_share->id()); + connect(job, &DestroyShareJob::jobFinished, this, [this](const QJsonDocument &, int) { _share = nullptr; Q_EMIT shareChanged(); // TODO: shareDeleted maybe? }); - job->createShare(); + job->start(); } void SharingController::addRecipient(const QString &recipientType, const QString &recipientValue) @@ -97,13 +102,12 @@ void SharingController::addRecipient(const QString &recipientType, const QString return; } - const auto job = new OcsSharingJob(_account, _share->id()); - connect(job, &OcsSharingJob::jobFinished, this, [this](const QJsonDocument &json, int statusCode) -> void { + const auto job = _api->addRecipient(_share, recipientType, recipientValue); + connect(job, &UpdateShareJob::shareUpdated, this, [this](QPointer) { qCDebug(lcSharingController).nospace() << "recipient added" - << " id=" << _share->id(); - _share->updateFromJson(json); + << " id=" << _share->id(); }); - job->addRecipient(recipientType, recipientValue); + job->start(); } void SharingController::removeRecipient(const QString &recipientType, const QString &recipientValue) @@ -118,13 +122,12 @@ void SharingController::removeRecipient(const QString &recipientType, const QStr return; } - const auto job = new OcsSharingJob(_account, _share->id()); - connect(job, &OcsSharingJob::jobFinished, this, [this](const QJsonDocument &json, int statusCode) -> void { + const auto job = _api->removeRecipient(_share, recipientType, recipientValue); + connect(job, &UpdateShareJob::shareUpdated, this, [this](QPointer) { qCDebug(lcSharingController).nospace() << "recipient removed" - << " id=" << _share->id(); - _share->updateFromJson(json); + << " id=" << _share->id(); }); - job->addRecipient(recipientType, recipientValue); + job->start(); } void SharingController::setPermission(const QString &permissionClass, bool enabled) @@ -139,13 +142,12 @@ void SharingController::setPermission(const QString &permissionClass, bool enabl return; } - const auto job = new OcsSharingJob(_account, _share->id()); - connect(job, &OcsSharingJob::jobFinished, this, [this](const QJsonDocument &json, int statusCode) -> void { + const auto job = _api->setPermission(_share, permissionClass, enabled); + connect(job, &UpdateShareJob::shareUpdated, this, [this](QPointer) { qCDebug(lcSharingController).nospace() << "permissions updated" - << " id=" << _share->id(); - _share->updateFromJson(json); + << " id=" << _share->id(); }); - job->setPermission(permissionClass, enabled); + job->start(); } void SharingController::setPermissionPreset(const QString &permissionPreset) @@ -165,23 +167,20 @@ void SharingController::setPermissionPreset(const QString &permissionPreset) return; } - const auto job = new OcsSharingJob(_account, _share->id()); - connect(job, &OcsSharingJob::jobFinished, this, [this](const QJsonDocument &json, int statusCode) -> void { + const auto job = _api->setPermissionPreset(_share, permissionPreset); + connect(job, &UpdateShareJob::shareUpdated, this, [this](QPointer) { qCDebug(lcSharingController).nospace() << "permissions updated" - << " id=" << _share->id(); - _share->updateFromJson(json); + << " id=" << _share->id(); }); - job->setPermissionPreset(permissionPreset); + job->start(); } void SharingController::addSourceAfterCreation(const QString &fileId) { - const auto job = new OcsSharingJob(_account, _share->id()); - connect(job, &OcsSharingJob::jobFinished, this, [this, fileId](const QJsonDocument &json, int statusCode) -> void { + const auto job = _api->addSource(_share, fileId); + connect(job, &UpdateShareJob::shareUpdated, this, [this, fileId](QPointer) { qCDebug(lcSharingController).nospace() << "share created" - << " id=" << _share->id() - << " fileId=" << fileId; - _share->updateFromJson(json); + << " id=" << _share->id() << " fileId=" << fileId; }); - job->addSource(fileId); + job->start(); } diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index 5bb7d2daa76c3..a8d6e5c5e17de 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -6,14 +6,16 @@ #pragma once #include -#include #include +#include #include "accountfwd.h" -namespace OCC::Gui::Sharing { +namespace OCC::Gui::Sharing +{ class Share; +class UnifiedSharingApi; class SharingController : public QObject { @@ -46,6 +48,7 @@ class SharingController : public QObject private: AccountPtr _account; QPointer _share; + UnifiedSharingApi *_api = nullptr; void addSourceAfterCreation(const QString &fileId); }; From afc5be2cb427e9736f93f78fa7a736bb81e6cfe5 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 18:17:41 +0800 Subject: [PATCH 043/104] feat(sharing): Add timeout to search recipients job Signed-off-by: Claudio Cambra --- src/gui/sharing/searchrecipientsjob.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/gui/sharing/searchrecipientsjob.cpp b/src/gui/sharing/searchrecipientsjob.cpp index f9b8afab31c81..8fcca37c16926 100644 --- a/src/gui/sharing/searchrecipientsjob.cpp +++ b/src/gui/sharing/searchrecipientsjob.cpp @@ -13,12 +13,18 @@ using namespace Qt::StringLiterals; namespace OCC::Gui::Sharing { +namespace +{ +constexpr auto requestTimeoutMsec = 10 * 1000; +} + SearchRecipientsJob::SearchRecipientsJob(AccountPtr account, const QString &query, qint64 offset, qint64 limit) : UnifiedSharingRequest{std::move(account), "/ocs/v2.php/apps/sharing/api/v1/recipients"_L1, "GET"_ba, {{"query"_L1, query}, {"offset"_L1, QString::number(offset)}, {"limit"_L1, QString::number(limit)}}} { + setTimeout(requestTimeoutMsec); connect(this, &OcsJob::jobFinished, this, [this](const QJsonDocument &json, int) { Q_EMIT recipientsFound(json.object().value("ocs"_L1).toObject().value("data"_L1).toArray()); }); From 6f699c3b614662a9c1f7e277bd05a72ffa3d73e6 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 18:19:40 +0800 Subject: [PATCH 044/104] feat(sharing): Add debouncing to recipientsearchmodel Signed-off-by: Claudio Cambra --- src/gui/sharing/recipientsearchmodel.cpp | 30 ++++++++++++++++++++---- src/gui/sharing/recipientsearchmodel.h | 9 +++++-- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/gui/sharing/recipientsearchmodel.cpp b/src/gui/sharing/recipientsearchmodel.cpp index c67200e327a1b..1a622e86a9fd2 100644 --- a/src/gui/sharing/recipientsearchmodel.cpp +++ b/src/gui/sharing/recipientsearchmodel.cpp @@ -17,9 +17,18 @@ using namespace Qt::StringLiterals; using namespace OCC; using namespace OCC::Gui::Sharing; +namespace +{ +constexpr auto searchDelayMsec = 300; +} + RecipientSearchModel::RecipientSearchModel(QObject *parent) : QAbstractListModel{parent} -{} +{ + _searchTimer.setSingleShot(true); + _searchTimer.setInterval(searchDelayMsec); + connect(&_searchTimer, &QTimer::timeout, this, &RecipientSearchModel::search); +} int RecipientSearchModel::rowCount(const QModelIndex &parent) const { @@ -70,7 +79,9 @@ void RecipientSearchModel::setAccount(AccountPtr account) } beginResetModel(); + _searchTimer.stop(); _account = account; + _searchResults = {}; Q_EMIT accountChanged(); endResetModel(); } @@ -95,15 +106,26 @@ void RecipientSearchModel::setQuery(const QString &query) Q_EMIT queryChanged(); if (_query.isEmpty()) { + _searchTimer.stop(); beginResetModel(); _searchResults = {}; endResetModel(); return; } - // TODO: start timer for search job - const auto job = UnifiedSharingApi{_account}.searchRecipients(query, 0, 10); - connect(job, &SearchRecipientsJob::recipientsFound, this, [this](const QJsonArray &recipients) { + _searchTimer.start(); +} + +void RecipientSearchModel::search() +{ + const auto query = _query; + const auto account = _account; + const auto job = UnifiedSharingApi{account}.searchRecipients(query, 0, 10); + connect(job, &SearchRecipientsJob::recipientsFound, this, [this, account, query](const QJsonArray &recipients) { + if (_account != account || _query != query) { + return; + } + beginResetModel(); _searchResults = recipients; endResetModel(); diff --git a/src/gui/sharing/recipientsearchmodel.h b/src/gui/sharing/recipientsearchmodel.h index c3a571ff1a2de..b08dbfd3e3b78 100644 --- a/src/gui/sharing/recipientsearchmodel.h +++ b/src/gui/sharing/recipientsearchmodel.h @@ -6,13 +6,15 @@ #pragma once #include +#include -#include #include +#include #include "accountfwd.h" -namespace OCC::Gui::Sharing { +namespace OCC::Gui::Sharing +{ class RecipientSearchModel : public QAbstractListModel { @@ -50,6 +52,9 @@ class RecipientSearchModel : public QAbstractListModel AccountPtr _account = nullptr; QJsonArray _searchResults; QString _query; + QTimer _searchTimer; + + void search(); }; } From f67713152a8082881f0bcd63a50ffd3fc53f76e8 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 18:29:34 +0800 Subject: [PATCH 045/104] feat(sharing): Add unified sharing test Signed-off-by: Claudio Cambra --- test/CMakeLists.txt | 2 + test/testunifiedsharing.cpp | 247 ++++++++++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 test/testunifiedsharing.cpp diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 217a7d6a1f172..c151fead65da2 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -92,6 +92,8 @@ if (NOT WIN32) endif() nextcloud_add_test(Capabilities) +nextcloud_add_test(UnifiedSharing) +target_link_libraries(UnifiedSharingTest PRIVATE nextcloudGuiSharing) nextcloud_add_test(PushNotifications) nextcloud_add_test(Theme) nextcloud_add_test(IconUtils) diff --git a/test/testunifiedsharing.cpp b/test/testunifiedsharing.cpp new file mode 100644 index 0000000000000..3b544656d7dff --- /dev/null +++ b/test/testunifiedsharing.cpp @@ -0,0 +1,247 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "gui/sharing/createsharejob.h" +#include "gui/sharing/destroysharejob.h" +#include "gui/sharing/recipientsearchmodel.h" +#include "gui/sharing/searchrecipientsjob.h" +#include "gui/sharing/share.h" +#include "gui/sharing/unifiedsharingapi.h" +#include "gui/sharing/unifiedsharingrequest.h" +#include "gui/sharing/updatesharejob.h" +#include "syncenginetestutils.h" + +#include +#include +#include + +using namespace OCC; +using namespace OCC::Gui::Sharing; +using namespace Qt::StringLiterals; + +class TestUnifiedSharing : public QObject +{ + Q_OBJECT + +private slots: + void requestsAreConfiguredBeforeTheyStart() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto requestCount = 0; + auto requestPath = QString{}; + auto requestVerb = QByteArray{}; + + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + ++requestCount; + requestPath = request.url().path(); + requestVerb = request.attribute(QNetworkRequest::CustomVerbAttribute).toByteArray(); + if (requestVerb.isEmpty()) { + switch (operation) { + case QNetworkAccessManager::GetOperation: + requestVerb = "GET"; + break; + case QNetworkAccessManager::PostOperation: + requestVerb = "POST"; + break; + case QNetworkAccessManager::PutOperation: + requestVerb = "PUT"; + break; + case QNetworkAccessManager::DeleteOperation: + requestVerb = "DELETE"; + break; + default: + break; + } + } + + return new FakePayloadReply{operation, + request, + R"json({ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": { + "id": "share-1" + } + } + })json", + this}; + }); + + UnifiedSharingApi api{fakeFolder.account()}; + const auto share = Share::fromJson(QJsonDocument::fromJson(R"json({"ocs":{"data":{"id":"share-1"}}})json"), fakeFolder.account()); + + const auto verifyRequest = [&](UnifiedSharingRequest *job, + const QByteArray &expectedVerb, + const QString &expectedPath, + const QHash &expectedParameters = {}) { + for (auto it = expectedParameters.cbegin(); it != expectedParameters.cend(); ++it) { + QCOMPARE(job->getParamValue(it.key()), it.value()); + } + + QSignalSpy finishedSpy{job, &UnifiedSharingRequest::jobFinished}; + job->start(); + QVERIFY(finishedSpy.wait()); + QCOMPARE(requestVerb, expectedVerb); + QVERIFY2(requestPath.endsWith(expectedPath), qPrintable(requestPath)); + }; + + verifyRequest(api.createShare(), "POST", "/ocs/v2.php/apps/sharing/api/v1/share"); + verifyRequest(api.destroyShare("share-1"_L1), "DELETE", "/ocs/v2.php/apps/sharing/api/v1/share/share-1"); + verifyRequest(api.addSource(share, "42"_L1), + "POST", + "/ocs/v2.php/apps/sharing/api/v1/share/share-1/source", + {{"class"_L1, "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1}, {"value"_L1, "42"_L1}}); + verifyRequest(api.addRecipient(share, "recipient-class"_L1, "alice"_L1), + "POST", + "/ocs/v2.php/apps/sharing/api/v1/share/share-1/recipient", + {{"class"_L1, "recipient-class"_L1}, {"value"_L1, "alice"_L1}}); + verifyRequest(api.removeRecipient(share, "recipient-class"_L1, "alice"_L1), + "DELETE", + "/ocs/v2.php/apps/sharing/api/v1/share/share-1/recipient", + {{"class"_L1, "recipient-class"_L1}, {"value"_L1, "alice"_L1}}); + verifyRequest(api.searchRecipients("ali"_L1, 10, 20), + "GET", + "/ocs/v2.php/apps/sharing/api/v1/recipients", + {{"query"_L1, "ali"_L1}, {"offset"_L1, "10"_L1}, {"limit"_L1, "20"_L1}}); + verifyRequest(api.setPermission(share, "permission-class"_L1, true), + "PUT", + "/ocs/v2.php/apps/sharing/api/v1/share/share-1/permission", + {{"class"_L1, "permission-class"_L1}, {"enabled"_L1, "true"_L1}}); + verifyRequest(api.setPermissionPreset(share, "preset-class"_L1), + "PUT", + "/ocs/v2.php/apps/sharing/api/v1/share/share-1/permission/preset", + {{"permissionPreset"_L1, "preset-class"_L1}}); + + QCOMPARE(requestCount, 8); + delete share; + } + + void requestStartsOnlyOnce() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto requestCount = 0; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + ++requestCount; + return new FakePayloadReply{operation, + request, + R"json({ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": {} + } + })json", + this}; + }); + + UnifiedSharingApi api{fakeFolder.account()}; + const auto job = api.searchRecipients("alice"_L1, 0, 10); + QSignalSpy finishedSpy{job, &UnifiedSharingRequest::jobFinished}; + + job->start(); + job->start(); + + QVERIFY(finishedSpy.wait()); + QCOMPARE(requestCount, 1); + } + + void typedJobsParseTheirResults() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + const auto data = + request.url().path().endsWith("/recipients"_L1) ? R"json([{"label":"Alice"}])json" : R"json({"id":"share-1","state":"active"})json"; + const auto statusCode = operation == QNetworkAccessManager::DeleteOperation ? 204 : 200; + const auto payload = QString{R"json({"ocs":{"meta":{"status":"ok","statuscode":%1,"message":"OK"},"data":%2}})json"} + .arg(statusCode) + .arg(QString::fromUtf8(data)) + .toUtf8(); + return new FakePayloadReply{operation, request, payload, this}; + }); + + UnifiedSharingApi api{fakeFolder.account()}; + + QPointer createdShare; + const auto createJob = api.createShare(); + connect(createJob, &CreateShareJob::shareCreated, this, [&](QPointer share) { + createdShare = share; + }); + createJob->start(); + QTRY_VERIFY(createdShare); + QCOMPARE(createdShare->id(), "share-1"_L1); + + auto updateReceived = false; + const auto updateJob = api.setPermission(createdShare, "permission-class"_L1, true); + connect(updateJob, &UpdateShareJob::shareUpdated, this, [&](QPointer share) { + updateReceived = share == createdShare; + }); + updateJob->start(); + QTRY_VERIFY(updateReceived); + QCOMPARE(createdShare->state(), Share::ShareState::Active); + + auto recipients = QJsonArray{}; + const auto searchJob = api.searchRecipients("ali"_L1, 0, 10); + QCOMPARE(searchJob->timeoutMsec(), 10'000); + connect(searchJob, &SearchRecipientsJob::recipientsFound, this, [&](const QJsonArray &result) { + recipients = result; + }); + searchJob->start(); + QTRY_COMPARE(recipients.size(), 1); + QCOMPARE(recipients.at(0).toObject().value("label"_L1).toString(), "Alice"_L1); + + auto destroyed = false; + const auto destroyJob = api.destroyShare(createdShare->id()); + connect(destroyJob, &DestroyShareJob::jobFinished, this, [&](const QJsonDocument &, int) { + destroyed = true; + }); + destroyJob->start(); + QTRY_VERIFY(destroyed); + + delete createdShare; + } + + void recipientSearchIsDebouncedAndIgnoresStaleResults() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto requestCount = 0; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + ++requestCount; + const auto query = QUrlQuery{request.url()}.queryItemValue("query"_L1); + const auto payload = + QString{R"json({"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":[{"display_name":"%1"}]}})json"}.arg(query).toUtf8(); + const auto delay = query == "old"_L1 ? 800 : FakePayloadReply::defaultDelay; + return new FakePayloadReply{operation, request, payload, delay, this}; + }); + + RecipientSearchModel model; + model.setAccount(fakeFolder.account()); + model.setQuery("o"_L1); + model.setQuery("ol"_L1); + model.setQuery("old"_L1); + + QTest::qWait(200); + QCOMPARE(requestCount, 0); + QTRY_COMPARE_WITH_TIMEOUT(requestCount, 1, 500); + + model.setQuery("new"_L1); + QTRY_COMPARE_WITH_TIMEOUT(requestCount, 2, 500); + QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 1, 500); + QCOMPARE(model.data(model.index(0), RecipientSearchModel::DisplayNameRole).toString(), "new"_L1); + + QTest::qWait(500); + QCOMPARE(model.data(model.index(0), RecipientSearchModel::DisplayNameRole).toString(), "new"_L1); + } +}; + +QTEST_GUILESS_MAIN(TestUnifiedSharing) + +#include "testunifiedsharing.moc" From 54f4f8b2caa8a7a6c6f353a78eaf1068ac97f3ec Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 18:48:28 +0800 Subject: [PATCH 046/104] feat(sharing): Add specific update job classes Signed-off-by: Claudio Cambra --- src/gui/sharing/CMakeLists.txt | 12 ++- src/gui/sharing/addrecipientjob.cpp | 24 ++++++ src/gui/sharing/addrecipientjob.h | 20 +++++ src/gui/sharing/addsourcejob.cpp | 24 ++++++ src/gui/sharing/addsourcejob.h | 20 +++++ src/gui/sharing/createsharejob.h | 8 +- src/gui/sharing/destroysharejob.h | 5 +- src/gui/sharing/recipientsearchmodel.cpp | 3 +- src/gui/sharing/removerecipientjob.cpp | 24 ++++++ src/gui/sharing/removerecipientjob.h | 20 +++++ src/gui/sharing/searchrecipientsjob.h | 9 +-- src/gui/sharing/setpermissionjob.cpp | 24 ++++++ src/gui/sharing/setpermissionjob.h | 20 +++++ src/gui/sharing/setpermissionpresetjob.cpp | 24 ++++++ src/gui/sharing/setpermissionpresetjob.h | 20 +++++ src/gui/sharing/sharingcontroller.cpp | 22 +++--- src/gui/sharing/sharingcontroller.h | 2 - src/gui/sharing/unifiedsharingapi.cpp | 90 ---------------------- src/gui/sharing/unifiedsharingapi.h | 45 ----------- src/gui/sharing/updatesharejob.cpp | 8 +- src/gui/sharing/updatesharejob.h | 13 +--- test/testunifiedsharing.cpp | 37 +++++---- 22 files changed, 275 insertions(+), 199 deletions(-) create mode 100644 src/gui/sharing/addrecipientjob.cpp create mode 100644 src/gui/sharing/addrecipientjob.h create mode 100644 src/gui/sharing/addsourcejob.cpp create mode 100644 src/gui/sharing/addsourcejob.h create mode 100644 src/gui/sharing/removerecipientjob.cpp create mode 100644 src/gui/sharing/removerecipientjob.h create mode 100644 src/gui/sharing/setpermissionjob.cpp create mode 100644 src/gui/sharing/setpermissionjob.h create mode 100644 src/gui/sharing/setpermissionpresetjob.cpp create mode 100644 src/gui/sharing/setpermissionpresetjob.h delete mode 100644 src/gui/sharing/unifiedsharingapi.cpp delete mode 100644 src/gui/sharing/unifiedsharingapi.h diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index fe04a454fced9..733b76f4f1a21 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -17,6 +17,16 @@ target_sources(nextcloudGuiSharing searchrecipientsjob.cpp updatesharejob.h updatesharejob.cpp + addsourcejob.h + addsourcejob.cpp + addrecipientjob.h + addrecipientjob.cpp + removerecipientjob.h + removerecipientjob.cpp + setpermissionjob.h + setpermissionjob.cpp + setpermissionpresetjob.h + setpermissionpresetjob.cpp permission.h permission.cpp permissionmodel.h @@ -35,8 +45,6 @@ target_sources(nextcloudGuiSharing share.cpp sharingcontroller.h sharingcontroller.cpp - unifiedsharingapi.h - unifiedsharingapi.cpp ) ecm_add_qml_module(nextcloudGuiSharing diff --git a/src/gui/sharing/addrecipientjob.cpp b/src/gui/sharing/addrecipientjob.cpp new file mode 100644 index 0000000000000..432f2fe7c8d9f --- /dev/null +++ b/src/gui/sharing/addrecipientjob.cpp @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "addrecipientjob.h" + +#include "share.h" + +using namespace Qt::StringLiterals; + +namespace OCC::Gui::Sharing +{ + +AddRecipientJob::AddRecipientJob(AccountPtr account, Share &share, const QString &recipientType, const QString &recipientValue) + : UpdateShareJob{std::move(account), + share, + "/ocs/v2.php/apps/sharing/api/v1/share/%1/recipient"_L1.arg(share.id()), + "POST"_ba, + {{"class"_L1, recipientType}, {"value"_L1, recipientValue}}} +{ +} + +} diff --git a/src/gui/sharing/addrecipientjob.h b/src/gui/sharing/addrecipientjob.h new file mode 100644 index 0000000000000..797c65b7bac11 --- /dev/null +++ b/src/gui/sharing/addrecipientjob.h @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "updatesharejob.h" + +namespace OCC::Gui::Sharing +{ + +/** @brief Adds a recipient to a share. */ +class AddRecipientJob : public UpdateShareJob +{ +public: + explicit AddRecipientJob(AccountPtr account, Share &share, const QString &recipientType, const QString &recipientValue); +}; + +} diff --git a/src/gui/sharing/addsourcejob.cpp b/src/gui/sharing/addsourcejob.cpp new file mode 100644 index 0000000000000..e746fa24a2113 --- /dev/null +++ b/src/gui/sharing/addsourcejob.cpp @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "addsourcejob.h" + +#include "share.h" + +using namespace Qt::StringLiterals; + +namespace OCC::Gui::Sharing +{ + +AddSourceJob::AddSourceJob(AccountPtr account, Share &share, const QString &fileId) + : UpdateShareJob{std::move(account), + share, + "/ocs/v2.php/apps/sharing/api/v1/share/%1/source"_L1.arg(share.id()), + "POST"_ba, + {{"class"_L1, "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1}, {"value"_L1, fileId}}} +{ +} + +} diff --git a/src/gui/sharing/addsourcejob.h b/src/gui/sharing/addsourcejob.h new file mode 100644 index 0000000000000..1ad6137c8f3b0 --- /dev/null +++ b/src/gui/sharing/addsourcejob.h @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "updatesharejob.h" + +namespace OCC::Gui::Sharing +{ + +/** @brief Adds a node source to a share. */ +class AddSourceJob : public UpdateShareJob +{ +public: + explicit AddSourceJob(AccountPtr account, Share &share, const QString &fileId); +}; + +} diff --git a/src/gui/sharing/createsharejob.h b/src/gui/sharing/createsharejob.h index a3ac58bf2798c..e8da873842290 100644 --- a/src/gui/sharing/createsharejob.h +++ b/src/gui/sharing/createsharejob.h @@ -11,7 +11,6 @@ namespace OCC::Gui::Sharing { class Share; -class UnifiedSharingApi; /** * @brief Creates a share and returns its parsed representation. @@ -20,12 +19,11 @@ class CreateShareJob : public UnifiedSharingRequest { Q_OBJECT +public: + explicit CreateShareJob(AccountPtr account); + Q_SIGNALS: void shareCreated(QPointer share); - -private: - friend class UnifiedSharingApi; - explicit CreateShareJob(AccountPtr account); }; } diff --git a/src/gui/sharing/destroysharejob.h b/src/gui/sharing/destroysharejob.h index ef453b4bb484e..ced7651ebbb43 100644 --- a/src/gui/sharing/destroysharejob.h +++ b/src/gui/sharing/destroysharejob.h @@ -10,15 +10,12 @@ namespace OCC::Gui::Sharing { -class UnifiedSharingApi; - /** * @brief Destroys a share. */ class DestroyShareJob : public UnifiedSharingRequest { -private: - friend class UnifiedSharingApi; +public: explicit DestroyShareJob(AccountPtr account, const QString &shareId); }; diff --git a/src/gui/sharing/recipientsearchmodel.cpp b/src/gui/sharing/recipientsearchmodel.cpp index 1a622e86a9fd2..311ee3824f36b 100644 --- a/src/gui/sharing/recipientsearchmodel.cpp +++ b/src/gui/sharing/recipientsearchmodel.cpp @@ -9,7 +9,6 @@ #include #include "searchrecipientsjob.h" -#include "unifiedsharingapi.h" Q_LOGGING_CATEGORY(lcSharingRecipientShareModel, "nextcloud.gui.sharing.recipientsearchmodel", QtInfoMsg) @@ -120,7 +119,7 @@ void RecipientSearchModel::search() { const auto query = _query; const auto account = _account; - const auto job = UnifiedSharingApi{account}.searchRecipients(query, 0, 10); + const auto job = new SearchRecipientsJob{account, query, 0, 10}; connect(job, &SearchRecipientsJob::recipientsFound, this, [this, account, query](const QJsonArray &recipients) { if (_account != account || _query != query) { return; diff --git a/src/gui/sharing/removerecipientjob.cpp b/src/gui/sharing/removerecipientjob.cpp new file mode 100644 index 0000000000000..b7faf353a1c54 --- /dev/null +++ b/src/gui/sharing/removerecipientjob.cpp @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "removerecipientjob.h" + +#include "share.h" + +using namespace Qt::StringLiterals; + +namespace OCC::Gui::Sharing +{ + +RemoveRecipientJob::RemoveRecipientJob(AccountPtr account, Share &share, const QString &recipientType, const QString &recipientValue) + : UpdateShareJob{std::move(account), + share, + "/ocs/v2.php/apps/sharing/api/v1/share/%1/recipient"_L1.arg(share.id()), + "DELETE"_ba, + {{"class"_L1, recipientType}, {"value"_L1, recipientValue}}} +{ +} + +} diff --git a/src/gui/sharing/removerecipientjob.h b/src/gui/sharing/removerecipientjob.h new file mode 100644 index 0000000000000..9d5a8c708106f --- /dev/null +++ b/src/gui/sharing/removerecipientjob.h @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "updatesharejob.h" + +namespace OCC::Gui::Sharing +{ + +/** @brief Removes a recipient from a share. */ +class RemoveRecipientJob : public UpdateShareJob +{ +public: + explicit RemoveRecipientJob(AccountPtr account, Share &share, const QString &recipientType, const QString &recipientValue); +}; + +} diff --git a/src/gui/sharing/searchrecipientsjob.h b/src/gui/sharing/searchrecipientsjob.h index 972eef7836c06..f99c004f29774 100644 --- a/src/gui/sharing/searchrecipientsjob.h +++ b/src/gui/sharing/searchrecipientsjob.h @@ -12,8 +12,6 @@ namespace OCC::Gui::Sharing { -class UnifiedSharingApi; - /** * @brief Searches for recipients and returns the result entries. */ @@ -21,12 +19,11 @@ class SearchRecipientsJob : public UnifiedSharingRequest { Q_OBJECT +public: + explicit SearchRecipientsJob(AccountPtr account, const QString &query, qint64 offset, qint64 limit); + Q_SIGNALS: void recipientsFound(const QJsonArray &recipients); - -private: - friend class UnifiedSharingApi; - explicit SearchRecipientsJob(AccountPtr account, const QString &query, qint64 offset, qint64 limit); }; } diff --git a/src/gui/sharing/setpermissionjob.cpp b/src/gui/sharing/setpermissionjob.cpp new file mode 100644 index 0000000000000..9cc2a4506d068 --- /dev/null +++ b/src/gui/sharing/setpermissionjob.cpp @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "setpermissionjob.h" + +#include "share.h" + +using namespace Qt::StringLiterals; + +namespace OCC::Gui::Sharing +{ + +SetPermissionJob::SetPermissionJob(AccountPtr account, Share &share, const QString &permissionClass, bool enabled) + : UpdateShareJob{std::move(account), + share, + "/ocs/v2.php/apps/sharing/api/v1/share/%1/permission"_L1.arg(share.id()), + "PUT"_ba, + {{"class"_L1, permissionClass}, {"enabled"_L1, enabled ? "true"_L1 : "false"_L1}}} +{ +} + +} diff --git a/src/gui/sharing/setpermissionjob.h b/src/gui/sharing/setpermissionjob.h new file mode 100644 index 0000000000000..726652129a085 --- /dev/null +++ b/src/gui/sharing/setpermissionjob.h @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "updatesharejob.h" + +namespace OCC::Gui::Sharing +{ + +/** @brief Enables or disables one permission on a share. */ +class SetPermissionJob : public UpdateShareJob +{ +public: + explicit SetPermissionJob(AccountPtr account, Share &share, const QString &permissionClass, bool enabled); +}; + +} diff --git a/src/gui/sharing/setpermissionpresetjob.cpp b/src/gui/sharing/setpermissionpresetjob.cpp new file mode 100644 index 0000000000000..b5d798040c668 --- /dev/null +++ b/src/gui/sharing/setpermissionpresetjob.cpp @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "setpermissionpresetjob.h" + +#include "share.h" + +using namespace Qt::StringLiterals; + +namespace OCC::Gui::Sharing +{ + +SetPermissionPresetJob::SetPermissionPresetJob(AccountPtr account, Share &share, const QString &permissionPreset) + : UpdateShareJob{std::move(account), + share, + "/ocs/v2.php/apps/sharing/api/v1/share/%1/permission/preset"_L1.arg(share.id()), + "PUT"_ba, + {{"permissionPreset"_L1, permissionPreset}}} +{ +} + +} diff --git a/src/gui/sharing/setpermissionpresetjob.h b/src/gui/sharing/setpermissionpresetjob.h new file mode 100644 index 0000000000000..50bf2b367783d --- /dev/null +++ b/src/gui/sharing/setpermissionpresetjob.h @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "updatesharejob.h" + +namespace OCC::Gui::Sharing +{ + +/** @brief Applies a permission preset to a share. */ +class SetPermissionPresetJob : public UpdateShareJob +{ +public: + explicit SetPermissionPresetJob(AccountPtr account, Share &share, const QString &permissionPreset); +}; + +} diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index 4492c9cfa8344..823a26587b3a6 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -8,10 +8,14 @@ #include #include +#include "addrecipientjob.h" +#include "addsourcejob.h" #include "createsharejob.h" #include "destroysharejob.h" +#include "removerecipientjob.h" +#include "setpermissionjob.h" +#include "setpermissionpresetjob.h" #include "share.h" -#include "unifiedsharingapi.h" #include "updatesharejob.h" Q_LOGGING_CATEGORY(lcSharingController, "nextcloud.gui.sharing.sharingcontroller", QtInfoMsg) @@ -38,8 +42,6 @@ void SharingController::setAccount(AccountPtr account) } _account = account; - delete _api; - _api = _account ? new UnifiedSharingApi{_account, this} : nullptr; Q_EMIT accountChanged(); } @@ -55,7 +57,7 @@ void SharingController::createShare(const QString &fileId) return; } - const auto job = _api->createShare(); + const auto job = new CreateShareJob{_account}; connect(job, &CreateShareJob::shareCreated, this, [this, fileId](QPointer share) -> void { if (!share) { qCWarning(lcSharingController) << "share created without a valid Share object"; @@ -82,7 +84,7 @@ void SharingController::destroyShare() return; } - const auto job = _api->destroyShare(_share->id()); + const auto job = new DestroyShareJob{_account, _share->id()}; connect(job, &DestroyShareJob::jobFinished, this, [this](const QJsonDocument &, int) { _share = nullptr; Q_EMIT shareChanged(); // TODO: shareDeleted maybe? @@ -102,7 +104,7 @@ void SharingController::addRecipient(const QString &recipientType, const QString return; } - const auto job = _api->addRecipient(_share, recipientType, recipientValue); + const auto job = new AddRecipientJob{_account, *_share, recipientType, recipientValue}; connect(job, &UpdateShareJob::shareUpdated, this, [this](QPointer) { qCDebug(lcSharingController).nospace() << "recipient added" << " id=" << _share->id(); @@ -122,7 +124,7 @@ void SharingController::removeRecipient(const QString &recipientType, const QStr return; } - const auto job = _api->removeRecipient(_share, recipientType, recipientValue); + const auto job = new RemoveRecipientJob{_account, *_share, recipientType, recipientValue}; connect(job, &UpdateShareJob::shareUpdated, this, [this](QPointer) { qCDebug(lcSharingController).nospace() << "recipient removed" << " id=" << _share->id(); @@ -142,7 +144,7 @@ void SharingController::setPermission(const QString &permissionClass, bool enabl return; } - const auto job = _api->setPermission(_share, permissionClass, enabled); + const auto job = new SetPermissionJob{_account, *_share, permissionClass, enabled}; connect(job, &UpdateShareJob::shareUpdated, this, [this](QPointer) { qCDebug(lcSharingController).nospace() << "permissions updated" << " id=" << _share->id(); @@ -167,7 +169,7 @@ void SharingController::setPermissionPreset(const QString &permissionPreset) return; } - const auto job = _api->setPermissionPreset(_share, permissionPreset); + const auto job = new SetPermissionPresetJob{_account, *_share, permissionPreset}; connect(job, &UpdateShareJob::shareUpdated, this, [this](QPointer) { qCDebug(lcSharingController).nospace() << "permissions updated" << " id=" << _share->id(); @@ -177,7 +179,7 @@ void SharingController::setPermissionPreset(const QString &permissionPreset) void SharingController::addSourceAfterCreation(const QString &fileId) { - const auto job = _api->addSource(_share, fileId); + const auto job = new AddSourceJob{_account, *_share, fileId}; connect(job, &UpdateShareJob::shareUpdated, this, [this, fileId](QPointer) { qCDebug(lcSharingController).nospace() << "share created" << " id=" << _share->id() << " fileId=" << fileId; diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index a8d6e5c5e17de..2aa06363e3666 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -15,7 +15,6 @@ namespace OCC::Gui::Sharing { class Share; -class UnifiedSharingApi; class SharingController : public QObject { @@ -48,7 +47,6 @@ class SharingController : public QObject private: AccountPtr _account; QPointer _share; - UnifiedSharingApi *_api = nullptr; void addSourceAfterCreation(const QString &fileId); }; diff --git a/src/gui/sharing/unifiedsharingapi.cpp b/src/gui/sharing/unifiedsharingapi.cpp deleted file mode 100644 index 533d861bd2b13..0000000000000 --- a/src/gui/sharing/unifiedsharingapi.cpp +++ /dev/null @@ -1,90 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#include "unifiedsharingapi.h" - -#include "createsharejob.h" -#include "destroysharejob.h" -#include "searchrecipientsjob.h" -#include "share.h" -#include "updatesharejob.h" - -using namespace Qt::StringLiterals; - -namespace OCC::Gui::Sharing -{ - -namespace -{ -constexpr auto nodeSourceType = "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1; -} - -UnifiedSharingApi::UnifiedSharingApi(AccountPtr account, QObject *parent) - : QObject{parent} - , _account{std::move(account)} -{ -} - -CreateShareJob *UnifiedSharingApi::createShare() const -{ - return new CreateShareJob{_account}; -} - -DestroyShareJob *UnifiedSharingApi::destroyShare(const QString &shareId) const -{ - return new DestroyShareJob{_account, shareId}; -} - -UpdateShareJob *UnifiedSharingApi::addSource(QPointer share, const QString &fileId) const -{ - return new UpdateShareJob{_account, - share, - "/ocs/v2.php/apps/sharing/api/v1/share/%1/source"_L1.arg(share->id()), - "POST"_ba, - {{"class"_L1, nodeSourceType}, {"value"_L1, fileId}}}; -} - -UpdateShareJob *UnifiedSharingApi::addRecipient(QPointer share, const QString &recipientType, const QString &recipientValue) const -{ - return new UpdateShareJob{_account, - share, - "/ocs/v2.php/apps/sharing/api/v1/share/%1/recipient"_L1.arg(share->id()), - "POST"_ba, - {{"class"_L1, recipientType}, {"value"_L1, recipientValue}}}; -} - -UpdateShareJob *UnifiedSharingApi::removeRecipient(QPointer share, const QString &recipientType, const QString &recipientValue) const -{ - return new UpdateShareJob{_account, - share, - "/ocs/v2.php/apps/sharing/api/v1/share/%1/recipient"_L1.arg(share->id()), - "DELETE"_ba, - {{"class"_L1, recipientType}, {"value"_L1, recipientValue}}}; -} - -SearchRecipientsJob *UnifiedSharingApi::searchRecipients(const QString &query, qint64 offset, qint64 limit) const -{ - return new SearchRecipientsJob{_account, query, offset, limit}; -} - -UpdateShareJob *UnifiedSharingApi::setPermission(QPointer share, const QString &permissionClass, bool enabled) const -{ - return new UpdateShareJob{_account, - share, - "/ocs/v2.php/apps/sharing/api/v1/share/%1/permission"_L1.arg(share->id()), - "PUT"_ba, - {{"class"_L1, permissionClass}, {"enabled"_L1, enabled ? "true"_L1 : "false"_L1}}}; -} - -UpdateShareJob *UnifiedSharingApi::setPermissionPreset(QPointer share, const QString &permissionPreset) const -{ - return new UpdateShareJob{_account, - share, - "/ocs/v2.php/apps/sharing/api/v1/share/%1/permission/preset"_L1.arg(share->id()), - "PUT"_ba, - {{"permissionPreset"_L1, permissionPreset}}}; -} - -} diff --git a/src/gui/sharing/unifiedsharingapi.h b/src/gui/sharing/unifiedsharingapi.h deleted file mode 100644 index 125d06d977fa3..0000000000000 --- a/src/gui/sharing/unifiedsharingapi.h +++ /dev/null @@ -1,45 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#pragma once - -#include -#include - -#include "accountfwd.h" - -namespace OCC::Gui::Sharing -{ - -class CreateShareJob; -class DestroyShareJob; -class SearchRecipientsJob; -class Share; -class UpdateShareJob; - -/** - * @brief Creates typed jobs for the Unified Sharing API. - */ -class UnifiedSharingApi : public QObject -{ - Q_OBJECT - -public: - explicit UnifiedSharingApi(AccountPtr account, QObject *parent = nullptr); - - [[nodiscard]] CreateShareJob *createShare() const; - [[nodiscard]] DestroyShareJob *destroyShare(const QString &shareId) const; - [[nodiscard]] UpdateShareJob *addSource(QPointer share, const QString &fileId) const; - [[nodiscard]] UpdateShareJob *addRecipient(QPointer share, const QString &recipientType, const QString &recipientValue) const; - [[nodiscard]] UpdateShareJob *removeRecipient(QPointer share, const QString &recipientType, const QString &recipientValue) const; - [[nodiscard]] SearchRecipientsJob *searchRecipients(const QString &query, qint64 offset, qint64 limit) const; - [[nodiscard]] UpdateShareJob *setPermission(QPointer share, const QString &permissionClass, bool enabled) const; - [[nodiscard]] UpdateShareJob *setPermissionPreset(QPointer share, const QString &permissionPreset) const; - -private: - AccountPtr _account; -}; - -} diff --git a/src/gui/sharing/updatesharejob.cpp b/src/gui/sharing/updatesharejob.cpp index a55e61f7da5c3..035fa09e0d694 100644 --- a/src/gui/sharing/updatesharejob.cpp +++ b/src/gui/sharing/updatesharejob.cpp @@ -10,14 +10,10 @@ namespace OCC::Gui::Sharing { -UpdateShareJob::UpdateShareJob(AccountPtr account, - QPointer share, - const QString &path, - const QByteArray &verb, - const QList> ¶meters) +UpdateShareJob::UpdateShareJob(AccountPtr account, Share &share, const QString &path, const QByteArray &verb, const QList> ¶meters) : UnifiedSharingRequest{std::move(account), path, verb, parameters} { - connect(this, &OcsJob::jobFinished, this, [this, share](const QJsonDocument &json, int) { + connect(this, &OcsJob::jobFinished, this, [this, share = QPointer{&share}](const QJsonDocument &json, int) { if (share) { share->updateFromJson(json); } diff --git a/src/gui/sharing/updatesharejob.h b/src/gui/sharing/updatesharejob.h index 87363089623af..33e7f27f5c2ba 100644 --- a/src/gui/sharing/updatesharejob.h +++ b/src/gui/sharing/updatesharejob.h @@ -11,8 +11,6 @@ namespace OCC::Gui::Sharing { class Share; -class UnifiedSharingApi; - /** * @brief Applies a server response to an existing share. */ @@ -20,16 +18,11 @@ class UpdateShareJob : public UnifiedSharingRequest { Q_OBJECT +protected: + explicit UpdateShareJob(AccountPtr account, Share &share, const QString &path, const QByteArray &verb, const QList> ¶meters); + Q_SIGNALS: void shareUpdated(QPointer share); - -private: - friend class UnifiedSharingApi; - explicit UpdateShareJob(AccountPtr account, - QPointer share, - const QString &path, - const QByteArray &verb, - const QList> ¶meters); }; } diff --git a/test/testunifiedsharing.cpp b/test/testunifiedsharing.cpp index 3b544656d7dff..c7cf5f24bb29d 100644 --- a/test/testunifiedsharing.cpp +++ b/test/testunifiedsharing.cpp @@ -3,12 +3,16 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ +#include "gui/sharing/addrecipientjob.h" +#include "gui/sharing/addsourcejob.h" #include "gui/sharing/createsharejob.h" #include "gui/sharing/destroysharejob.h" #include "gui/sharing/recipientsearchmodel.h" +#include "gui/sharing/removerecipientjob.h" #include "gui/sharing/searchrecipientsjob.h" +#include "gui/sharing/setpermissionjob.h" +#include "gui/sharing/setpermissionpresetjob.h" #include "gui/sharing/share.h" -#include "gui/sharing/unifiedsharingapi.h" #include "gui/sharing/unifiedsharingrequest.h" #include "gui/sharing/updatesharejob.h" #include "syncenginetestutils.h" @@ -73,7 +77,7 @@ private slots: this}; }); - UnifiedSharingApi api{fakeFolder.account()}; + const auto account = fakeFolder.account(); const auto share = Share::fromJson(QJsonDocument::fromJson(R"json({"ocs":{"data":{"id":"share-1"}}})json"), fakeFolder.account()); const auto verifyRequest = [&](UnifiedSharingRequest *job, @@ -91,29 +95,29 @@ private slots: QVERIFY2(requestPath.endsWith(expectedPath), qPrintable(requestPath)); }; - verifyRequest(api.createShare(), "POST", "/ocs/v2.php/apps/sharing/api/v1/share"); - verifyRequest(api.destroyShare("share-1"_L1), "DELETE", "/ocs/v2.php/apps/sharing/api/v1/share/share-1"); - verifyRequest(api.addSource(share, "42"_L1), + verifyRequest(new CreateShareJob{account}, "POST", "/ocs/v2.php/apps/sharing/api/v1/share"); + verifyRequest(new DestroyShareJob{account, "share-1"_L1}, "DELETE", "/ocs/v2.php/apps/sharing/api/v1/share/share-1"); + verifyRequest(new AddSourceJob{account, *share, "42"_L1}, "POST", "/ocs/v2.php/apps/sharing/api/v1/share/share-1/source", {{"class"_L1, "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1}, {"value"_L1, "42"_L1}}); - verifyRequest(api.addRecipient(share, "recipient-class"_L1, "alice"_L1), + verifyRequest(new AddRecipientJob{account, *share, "recipient-class"_L1, "alice"_L1}, "POST", "/ocs/v2.php/apps/sharing/api/v1/share/share-1/recipient", {{"class"_L1, "recipient-class"_L1}, {"value"_L1, "alice"_L1}}); - verifyRequest(api.removeRecipient(share, "recipient-class"_L1, "alice"_L1), + verifyRequest(new RemoveRecipientJob{account, *share, "recipient-class"_L1, "alice"_L1}, "DELETE", "/ocs/v2.php/apps/sharing/api/v1/share/share-1/recipient", {{"class"_L1, "recipient-class"_L1}, {"value"_L1, "alice"_L1}}); - verifyRequest(api.searchRecipients("ali"_L1, 10, 20), + verifyRequest(new SearchRecipientsJob{account, "ali"_L1, 10, 20}, "GET", "/ocs/v2.php/apps/sharing/api/v1/recipients", {{"query"_L1, "ali"_L1}, {"offset"_L1, "10"_L1}, {"limit"_L1, "20"_L1}}); - verifyRequest(api.setPermission(share, "permission-class"_L1, true), + verifyRequest(new SetPermissionJob{account, *share, "permission-class"_L1, true}, "PUT", "/ocs/v2.php/apps/sharing/api/v1/share/share-1/permission", {{"class"_L1, "permission-class"_L1}, {"enabled"_L1, "true"_L1}}); - verifyRequest(api.setPermissionPreset(share, "preset-class"_L1), + verifyRequest(new SetPermissionPresetJob{account, *share, "preset-class"_L1}, "PUT", "/ocs/v2.php/apps/sharing/api/v1/share/share-1/permission/preset", {{"permissionPreset"_L1, "preset-class"_L1}}); @@ -143,8 +147,7 @@ private slots: this}; }); - UnifiedSharingApi api{fakeFolder.account()}; - const auto job = api.searchRecipients("alice"_L1, 0, 10); + const auto job = new SearchRecipientsJob{fakeFolder.account(), "alice"_L1, 0, 10}; QSignalSpy finishedSpy{job, &UnifiedSharingRequest::jobFinished}; job->start(); @@ -168,10 +171,10 @@ private slots: return new FakePayloadReply{operation, request, payload, this}; }); - UnifiedSharingApi api{fakeFolder.account()}; + const auto account = fakeFolder.account(); QPointer createdShare; - const auto createJob = api.createShare(); + const auto createJob = new CreateShareJob{account}; connect(createJob, &CreateShareJob::shareCreated, this, [&](QPointer share) { createdShare = share; }); @@ -180,7 +183,7 @@ private slots: QCOMPARE(createdShare->id(), "share-1"_L1); auto updateReceived = false; - const auto updateJob = api.setPermission(createdShare, "permission-class"_L1, true); + const auto updateJob = new SetPermissionJob{account, *createdShare, "permission-class"_L1, true}; connect(updateJob, &UpdateShareJob::shareUpdated, this, [&](QPointer share) { updateReceived = share == createdShare; }); @@ -189,7 +192,7 @@ private slots: QCOMPARE(createdShare->state(), Share::ShareState::Active); auto recipients = QJsonArray{}; - const auto searchJob = api.searchRecipients("ali"_L1, 0, 10); + const auto searchJob = new SearchRecipientsJob{account, "ali"_L1, 0, 10}; QCOMPARE(searchJob->timeoutMsec(), 10'000); connect(searchJob, &SearchRecipientsJob::recipientsFound, this, [&](const QJsonArray &result) { recipients = result; @@ -199,7 +202,7 @@ private slots: QCOMPARE(recipients.at(0).toObject().value("label"_L1).toString(), "Alice"_L1); auto destroyed = false; - const auto destroyJob = api.destroyShare(createdShare->id()); + const auto destroyJob = new DestroyShareJob{account, createdShare->id()}; connect(destroyJob, &DestroyShareJob::jobFinished, this, [&](const QJsonDocument &, int) { destroyed = true; }); From 431b3f4f8ac23a70ad68b40eff9d5acc923ac5d5 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 19:22:42 +0800 Subject: [PATCH 047/104] feat(sharing): test for failure states Signed-off-by: Claudio Cambra --- test/testunifiedsharing.cpp | 141 ++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/test/testunifiedsharing.cpp b/test/testunifiedsharing.cpp index c7cf5f24bb29d..f4b4e3cf4d5e2 100644 --- a/test/testunifiedsharing.cpp +++ b/test/testunifiedsharing.cpp @@ -212,6 +212,147 @@ private slots: delete createdShare; } + void ocsErrorsAreSeparateFromSuccessfulResults() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + return new FakePayloadReply{operation, + request, + R"json({ + "ocs": { + "meta": { + "status": "failure", + "statuscode": 400, + "message": "Invalid permission" + }, + "data": { + "id": "changed-share", + "state": "active" + } + } + })json", + this}; + }); + + auto jobFinished = false; + auto ocsError = false; + const auto job = new UnifiedSharingRequest{fakeFolder.account(), "/ocs/v2.php/apps/sharing/api/v1/share"_L1, "GET"_ba}; + connect(job, &UnifiedSharingRequest::jobFinished, this, [&](const QJsonDocument &, int) { + jobFinished = true; + }); + connect(job, &UnifiedSharingRequest::ocsError, this, [&](int, const QString &) { + ocsError = true; + }); + + job->start(); + + QTRY_VERIFY(ocsError); + QVERIFY(!jobFinished); + } + + void failedUpdatesDoNotMutateShares() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + return new FakePayloadReply{operation, + request, + R"json({ + "ocs": { + "meta": { + "status": "failure", + "statuscode": 400, + "message": "Invalid update" + }, + "data": { + "id": "changed-share", + "state": "active" + } + } + })json", + this}; + }); + + const auto account = fakeFolder.account(); + const auto share = Share::fromJson(QJsonDocument::fromJson(R"json({"ocs":{"data":{"id":"share-1","state":"draft"}}})json"), account); + const auto jobs = QList{ + new AddSourceJob{account, *share, "42"_L1}, + new AddRecipientJob{account, *share, "recipient-class"_L1, "alice"_L1}, + new RemoveRecipientJob{account, *share, "recipient-class"_L1, "alice"_L1}, + new SetPermissionJob{account, *share, "permission-class"_L1, true}, + new SetPermissionPresetJob{account, *share, "preset-class"_L1}, + }; + + for (const auto job : jobs) { + auto shareUpdated = false; + auto ocsError = false; + connect(job, &UpdateShareJob::shareUpdated, this, [&](QPointer) { + shareUpdated = true; + }); + connect(job, &UpdateShareJob::ocsError, this, [&](int, const QString &) { + ocsError = true; + }); + + job->start(); + + QTRY_VERIFY(ocsError); + QVERIFY(!shareUpdated); + QCOMPARE(share->id(), "share-1"_L1); + QCOMPARE(share->state(), Share::ShareState::Draft); + } + + delete share; + } + + void networkErrorsAreSeparateFromSuccessfulResults() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + return new FakeErrorReply{operation, request, this, 500}; + }); + + auto jobFinished = false; + auto networkError = false; + const auto job = new UnifiedSharingRequest{fakeFolder.account(), "/ocs/v2.php/apps/sharing/api/v1/share"_L1, "GET"_ba}; + connect(job, &UnifiedSharingRequest::jobFinished, this, [&](const QJsonDocument &, int) { + jobFinished = true; + }); + connect(job, &UnifiedSharingRequest::networkError, this, [&](QNetworkReply *) { + networkError = true; + }); + + job->start(); + + QTRY_VERIFY(networkError); + QVERIFY(!jobFinished); + } + + void timeoutsAreSeparateFromSuccessfulResults() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + return new FakeHangingReply{operation, request, this}; + }); + + auto jobFinished = false; + auto networkError = false; + auto timedOut = false; + const auto job = new UnifiedSharingRequest{fakeFolder.account(), "/ocs/v2.php/apps/sharing/api/v1/share"_L1, "GET"_ba}; + job->setTimeout(10); + connect(job, &UnifiedSharingRequest::jobFinished, this, [&](const QJsonDocument &, int) { + jobFinished = true; + }); + connect(job, &UnifiedSharingRequest::networkError, this, [&, job](QNetworkReply *) { + networkError = true; + timedOut = job->timedOut(); + }); + + job->start(); + + QTRY_VERIFY(networkError); + QVERIFY(!jobFinished); + QVERIFY(timedOut); + } + void recipientSearchIsDebouncedAndIgnoresStaleResults() { FakeFolder fakeFolder{{}, {}, {}, false}; From e61e66ac2781c71221c223a60a38140958498109 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 20:48:35 +0800 Subject: [PATCH 048/104] feat(sharing): Allow ocsjob to have multiple values for a given parameter type Signed-off-by: Claudio Cambra --- src/gui/ocsjob.cpp | 21 ++++++++++++--------- src/gui/ocsjob.h | 7 ++++--- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/gui/ocsjob.cpp b/src/gui/ocsjob.cpp index 20de002cbb3af..ebd23332e1a43 100644 --- a/src/gui/ocsjob.cpp +++ b/src/gui/ocsjob.cpp @@ -32,7 +32,7 @@ void OcsJob::setVerb(const QByteArray &verb) void OcsJob::addParam(const QString &name, const QString &value) { - _params.insert(name, value); + _params.emplaceBack(name, value); } void OcsJob::addPassStatusCode(int code) @@ -52,19 +52,22 @@ void OcsJob::addRawHeader(const QByteArray &headerName, const QByteArray &value) QString OcsJob::getParamValue(const QString &key) const { - return _params.value(key); + const auto parameter = std::find_if(_params.cbegin(), _params.cend(), [&key](const auto &entry) { + return entry.first == key; + }); + return parameter == _params.cend() ? QString{} : parameter->second; } static QUrlQuery percentEncodeQueryItems( - const QHash &items) + const QList> &items) { QUrlQuery result; // Note: QUrlQuery::setQueryItems() does not fully percent encode // the query items, see #5042 - for (auto it = std::cbegin(items); it != std::cend(items); ++it) { + for (const auto &[name, value] : items) { result.addQueryItem( - QUrl::toPercentEncoding(it.key()), - QUrl::toPercentEncoding(it.value())); + QUrl::toPercentEncoding(name), + QUrl::toPercentEncoding(value)); } return result; } @@ -82,13 +85,13 @@ void OcsJob::start() } else if (_verb == "POST" || _verb == "PUT") { // Url encode the _postParams and put them in a buffer. QByteArray postData; - for (auto it = std::cbegin(_params); it != std::cend(_params); ++it) { + for (const auto &[name, value] : std::as_const(_params)) { if (!postData.isEmpty()) { postData.append("&"); } - postData.append(QUrl::toPercentEncoding(it.key())); + postData.append(QUrl::toPercentEncoding(name)); postData.append("="); - postData.append(QUrl::toPercentEncoding(it.value())); + postData.append(QUrl::toPercentEncoding(value)); } buffer->setData(postData); } diff --git a/src/gui/ocsjob.h b/src/gui/ocsjob.h index bcb060c491c5a..af9cdd05bbb8d 100644 --- a/src/gui/ocsjob.h +++ b/src/gui/ocsjob.h @@ -10,9 +10,10 @@ #include "accountfwd.h" #include "abstractnetworkjob.h" -#include -#include +#include +#include #include +#include #define OCS_SUCCESS_STATUS_CODE 100 // Apparently the v2.php URLs can return that @@ -142,7 +143,7 @@ private slots: private: QByteArray _verb; - QHash _params; + QList> _params; QVector _passStatusCodes; QNetworkRequest _request; }; From 8508e3f3f14098549edda55416a2d7cbd7a608a1 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 20:52:24 +0800 Subject: [PATCH 049/104] feat(sharing): Allow ocsjob to have a JSON body Signed-off-by: Claudio Cambra --- src/gui/ocsjob.cpp | 16 ++++++++++++++-- src/gui/ocsjob.h | 11 +++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/gui/ocsjob.cpp b/src/gui/ocsjob.cpp index ebd23332e1a43..a5b01d5d1c9d7 100644 --- a/src/gui/ocsjob.cpp +++ b/src/gui/ocsjob.cpp @@ -12,6 +12,9 @@ #include #include +#include +#include + namespace OCC { Q_LOGGING_CATEGORY(lcOcs, "nextcloud.gui.sharing.ocs", QtInfoMsg) @@ -35,6 +38,11 @@ void OcsJob::addParam(const QString &name, const QString &value) _params.emplaceBack(name, value); } +void OcsJob::setJsonBody(const QJsonObject &body) +{ + _jsonBody = QJsonDocument{body}.toJson(QJsonDocument::Compact); +} + void OcsJob::addPassStatusCode(int code) { _passStatusCodes.append(code); @@ -75,7 +83,6 @@ static QUrlQuery percentEncodeQueryItems( void OcsJob::start() { addRawHeader("Ocs-APIREQUEST", "true"); - addRawHeader("Content-Type", "application/x-www-form-urlencoded"); auto *buffer = new QBuffer; @@ -83,7 +90,11 @@ void OcsJob::start() if (_verb == "GET" || _verb == "DELETE") { queryItems = percentEncodeQueryItems(_params); } else if (_verb == "POST" || _verb == "PUT") { - // Url encode the _postParams and put them in a buffer. + if (_jsonBody.has_value()) { + addRawHeader("Content-Type", "application/json"); + buffer->setData(*_jsonBody); + } else { + addRawHeader("Content-Type", "application/x-www-form-urlencoded"); QByteArray postData; for (const auto &[name, value] : std::as_const(_params)) { if (!postData.isEmpty()) { @@ -95,6 +106,7 @@ void OcsJob::start() } buffer->setData(postData); } + } queryItems.addQueryItem(QLatin1String("format"), QLatin1String("json")); QUrl url = Utility::concatUrlPath(account()->url(), path(), queryItems); sendRequest(_verb, url, _request, buffer); diff --git a/src/gui/ocsjob.h b/src/gui/ocsjob.h index af9cdd05bbb8d..466f3a0985fb1 100644 --- a/src/gui/ocsjob.h +++ b/src/gui/ocsjob.h @@ -15,6 +15,8 @@ #include #include +#include + #define OCS_SUCCESS_STATUS_CODE 100 // Apparently the v2.php URLs can return that #define OCS_SUCCESS_STATUS_CODE_V2 200 @@ -24,6 +26,7 @@ #define OCS_NOT_MODIFIED_STATUS_CODE_V2 304 class QJsonDocument; +class QJsonObject; namespace OCC { @@ -60,12 +63,11 @@ class OcsJob : public AbstractNetworkJob void addParam(const QString &name, const QString &value); /** - * Set the post parameters + * Send a JSON object as the request body. * - * @param postParams list of pairs to add (urlEncoded) to the body of the - * request + * @param body JSON body for a POST or PUT request */ - void setPostParams(const QList> &postParams); + void setJsonBody(const QJsonObject &body); /** * List of expected statuscodes for this request @@ -144,6 +146,7 @@ private slots: private: QByteArray _verb; QList> _params; + std::optional _jsonBody; QVector _passStatusCodes; QNetworkRequest _request; }; From d8af2cc85d590edf710758ffad44f0f47e74ca7d Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 20:54:09 +0800 Subject: [PATCH 050/104] feat(sharing): Allow ocsjob passing status codes to be modified Signed-off-by: Claudio Cambra --- src/gui/ocsjob.cpp | 19 ++++++++++++------- src/gui/ocsjob.h | 7 +++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/gui/ocsjob.cpp b/src/gui/ocsjob.cpp index a5b01d5d1c9d7..0b975340f31c0 100644 --- a/src/gui/ocsjob.cpp +++ b/src/gui/ocsjob.cpp @@ -48,6 +48,11 @@ void OcsJob::addPassStatusCode(int code) _passStatusCodes.append(code); } +void OcsJob::setPassStatusCodes(const QList &codes) +{ + _passStatusCodes = codes; +} + void OcsJob::appendPath(const QString &id) { setPath(path() + QLatin1Char('/') + id); @@ -95,17 +100,17 @@ void OcsJob::start() buffer->setData(*_jsonBody); } else { addRawHeader("Content-Type", "application/x-www-form-urlencoded"); - QByteArray postData; + QByteArray postData; for (const auto &[name, value] : std::as_const(_params)) { - if (!postData.isEmpty()) { - postData.append("&"); - } + if (!postData.isEmpty()) { + postData.append("&"); + } postData.append(QUrl::toPercentEncoding(name)); - postData.append("="); + postData.append("="); postData.append(QUrl::toPercentEncoding(value)); + } + buffer->setData(postData); } - buffer->setData(postData); - } } queryItems.addQueryItem(QLatin1String("format"), QLatin1String("json")); QUrl url = Utility::concatUrlPath(account()->url(), path(), queryItems); diff --git a/src/gui/ocsjob.h b/src/gui/ocsjob.h index 466f3a0985fb1..0535898d9a3ec 100644 --- a/src/gui/ocsjob.h +++ b/src/gui/ocsjob.h @@ -78,6 +78,13 @@ class OcsJob : public AbstractNetworkJob */ void addPassStatusCode(int code); + /** + * Replace the accepted status codes for this request. + * + * @param codes Complete list of accepted OCS status codes + */ + void setPassStatusCodes(const QList &codes); + /** * The base path for an OcsJob is always the same. But it could be the case that * certain operations need to append something to the URL. From 0aa2c7eacc9aa6954fff58d0ce35b958da68403b Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 21:47:07 +0800 Subject: [PATCH 051/104] feat(sharing): Finalise api jobs Signed-off-by: Claudio Cambra --- src/gui/sharing/CMakeLists.txt | 14 +++++ src/gui/sharing/addrecipientjob.cpp | 22 +++++++- src/gui/sharing/addrecipientjob.h | 22 +++++++- src/gui/sharing/addsourcejob.cpp | 2 +- src/gui/sharing/addsourcejob.h | 9 ++- src/gui/sharing/createsharejob.cpp | 5 +- src/gui/sharing/createsharejob.h | 8 ++- src/gui/sharing/destroysharejob.cpp | 5 +- src/gui/sharing/destroysharejob.h | 6 +- src/gui/sharing/generatesecretjob.cpp | 24 ++++++++ src/gui/sharing/generatesecretjob.h | 32 +++++++++++ src/gui/sharing/getsharejob.cpp | 49 +++++++++++++++++ src/gui/sharing/getsharejob.h | 52 ++++++++++++++++++ src/gui/sharing/getsharesjob.cpp | 64 ++++++++++++++++++++++ src/gui/sharing/getsharesjob.h | 53 ++++++++++++++++++ src/gui/sharing/removerecipientjob.cpp | 22 +++++++- src/gui/sharing/removerecipientjob.h | 23 +++++++- src/gui/sharing/removesourcejob.cpp | 24 ++++++++ src/gui/sharing/removesourcejob.h | 27 +++++++++ src/gui/sharing/searchrecipientsjob.cpp | 20 ++++++- src/gui/sharing/searchrecipientsjob.h | 19 ++++++- src/gui/sharing/setpermissionjob.cpp | 2 +- src/gui/sharing/setpermissionjob.h | 14 ++++- src/gui/sharing/setpermissionpresetjob.cpp | 2 +- src/gui/sharing/setpermissionpresetjob.h | 13 ++++- src/gui/sharing/setpropertyjob.cpp | 29 ++++++++++ src/gui/sharing/setpropertyjob.h | 40 ++++++++++++++ src/gui/sharing/setrecipientsecretjob.cpp | 48 ++++++++++++++++ src/gui/sharing/setrecipientsecretjob.h | 41 ++++++++++++++ src/gui/sharing/setsharestatejob.cpp | 38 +++++++++++++ src/gui/sharing/setsharestatejob.h | 33 +++++++++++ src/gui/sharing/unifiedsharingrequest.cpp | 12 ++-- src/gui/sharing/unifiedsharingrequest.h | 16 +++++- src/gui/sharing/updatesharejob.cpp | 8 ++- src/gui/sharing/updatesharejob.h | 15 ++++- 35 files changed, 780 insertions(+), 33 deletions(-) create mode 100644 src/gui/sharing/generatesecretjob.cpp create mode 100644 src/gui/sharing/generatesecretjob.h create mode 100644 src/gui/sharing/getsharejob.cpp create mode 100644 src/gui/sharing/getsharejob.h create mode 100644 src/gui/sharing/getsharesjob.cpp create mode 100644 src/gui/sharing/getsharesjob.h create mode 100644 src/gui/sharing/removesourcejob.cpp create mode 100644 src/gui/sharing/removesourcejob.h create mode 100644 src/gui/sharing/setpropertyjob.cpp create mode 100644 src/gui/sharing/setpropertyjob.h create mode 100644 src/gui/sharing/setrecipientsecretjob.cpp create mode 100644 src/gui/sharing/setrecipientsecretjob.h create mode 100644 src/gui/sharing/setsharestatejob.cpp create mode 100644 src/gui/sharing/setsharestatejob.h diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index 733b76f4f1a21..e4d0b0f75aac2 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -13,20 +13,34 @@ target_sources(nextcloudGuiSharing createsharejob.cpp destroysharejob.h destroysharejob.cpp + generatesecretjob.h + generatesecretjob.cpp + getsharejob.h + getsharejob.cpp + getsharesjob.h + getsharesjob.cpp searchrecipientsjob.h searchrecipientsjob.cpp updatesharejob.h updatesharejob.cpp addsourcejob.h addsourcejob.cpp + removesourcejob.h + removesourcejob.cpp addrecipientjob.h addrecipientjob.cpp removerecipientjob.h removerecipientjob.cpp + setpropertyjob.h + setpropertyjob.cpp setpermissionjob.h setpermissionjob.cpp setpermissionpresetjob.h setpermissionpresetjob.cpp + setrecipientsecretjob.h + setrecipientsecretjob.cpp + setsharestatejob.h + setsharestatejob.cpp permission.h permission.cpp permissionmodel.h diff --git a/src/gui/sharing/addrecipientjob.cpp b/src/gui/sharing/addrecipientjob.cpp index 432f2fe7c8d9f..7d2e82ac84a65 100644 --- a/src/gui/sharing/addrecipientjob.cpp +++ b/src/gui/sharing/addrecipientjob.cpp @@ -12,12 +12,30 @@ using namespace Qt::StringLiterals; namespace OCC::Gui::Sharing { -AddRecipientJob::AddRecipientJob(AccountPtr account, Share &share, const QString &recipientType, const QString &recipientValue) +namespace +{ +QJsonObject addRecipientBody(const QString &recipientTypeClass, + const QString &recipientValue, + const std::optional &instance) +{ + auto body = QJsonObject{{"class"_L1, recipientTypeClass}, {"value"_L1, recipientValue}}; + if (instance) { + body.insert("instance"_L1, *instance); + } + return body; +} +} + +AddRecipientJob::AddRecipientJob(AccountPtr account, + Share &share, + const QString &recipientTypeClass, + const QString &recipientValue, + const std::optional &instance) : UpdateShareJob{std::move(account), share, "/ocs/v2.php/apps/sharing/api/v1/share/%1/recipient"_L1.arg(share.id()), "POST"_ba, - {{"class"_L1, recipientType}, {"value"_L1, recipientValue}}} + {.body = addRecipientBody(recipientTypeClass, recipientValue, instance)}} { } diff --git a/src/gui/sharing/addrecipientjob.h b/src/gui/sharing/addrecipientjob.h index 797c65b7bac11..d5101d3d4c8a5 100644 --- a/src/gui/sharing/addrecipientjob.h +++ b/src/gui/sharing/addrecipientjob.h @@ -7,14 +7,32 @@ #include "updatesharejob.h" +#include + namespace OCC::Gui::Sharing { -/** @brief Adds a recipient to a share. */ +/** + * @brief Grants a recipient access to an existing share. + * + * A recipient is identified by its registered recipient type, value, and + * optional remote instance. The server returns the complete updated share + */ class AddRecipientJob : public UpdateShareJob { public: - explicit AddRecipientJob(AccountPtr account, Share &share, const QString &recipientType, const QString &recipientValue); + /** + * @brief Creates a request to add a recipient to share. + * + * @param recipientTypeClass Registered server class describing the recipient type + * @param recipientValue Identifier understood by the recipient type + * @param instance Absolute URL of the recipient's remote instance, or no value for a local recipient + */ + explicit AddRecipientJob(AccountPtr account, + Share &share, + const QString &recipientTypeClass, + const QString &recipientValue, + const std::optional &instance = std::nullopt); }; } diff --git a/src/gui/sharing/addsourcejob.cpp b/src/gui/sharing/addsourcejob.cpp index e746fa24a2113..cb0d4298aad65 100644 --- a/src/gui/sharing/addsourcejob.cpp +++ b/src/gui/sharing/addsourcejob.cpp @@ -17,7 +17,7 @@ AddSourceJob::AddSourceJob(AccountPtr account, Share &share, const QString &file share, "/ocs/v2.php/apps/sharing/api/v1/share/%1/source"_L1.arg(share.id()), "POST"_ba, - {{"class"_L1, "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1}, {"value"_L1, fileId}}} + {.body = QJsonObject{{"class"_L1, "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1}, {"value"_L1, fileId}}}} { } diff --git a/src/gui/sharing/addsourcejob.h b/src/gui/sharing/addsourcejob.h index 1ad6137c8f3b0..378a7fe7aa474 100644 --- a/src/gui/sharing/addsourcejob.h +++ b/src/gui/sharing/addsourcejob.h @@ -10,10 +10,17 @@ namespace OCC::Gui::Sharing { -/** @brief Adds a node source to a share. */ +/** + * @brief Adds a local filesystem node as content of an existing share. + * + * In Unified Sharing, a source is an object being shared. This job registers + * the node identified by fileId as a node source and applies the server's + * complete updated representation to the supplied Share object. + */ class AddSourceJob : public UpdateShareJob { public: + /** @brief Creates a request to add the node identified by fileId to share. */ explicit AddSourceJob(AccountPtr account, Share &share, const QString &fileId); }; diff --git a/src/gui/sharing/createsharejob.cpp b/src/gui/sharing/createsharejob.cpp index 41f04b4174115..10431b8acde56 100644 --- a/src/gui/sharing/createsharejob.cpp +++ b/src/gui/sharing/createsharejob.cpp @@ -13,7 +13,10 @@ namespace OCC::Gui::Sharing { CreateShareJob::CreateShareJob(AccountPtr account) - : UnifiedSharingRequest{account, "/ocs/v2.php/apps/sharing/api/v1/share"_L1, "POST"_ba, {}, {201}} + : UnifiedSharingRequest{account, + "/ocs/v2.php/apps/sharing/api/v1/share"_L1, + "POST"_ba, + {.passStatusCodes = QList{201}}} { connect(this, &OcsJob::jobFinished, this, [this, account = std::move(account)](const QJsonDocument &json, int) { Q_EMIT shareCreated(Share::fromJson(json, account)); diff --git a/src/gui/sharing/createsharejob.h b/src/gui/sharing/createsharejob.h index e8da873842290..b8a04ea8b4c92 100644 --- a/src/gui/sharing/createsharejob.h +++ b/src/gui/sharing/createsharejob.h @@ -13,16 +13,22 @@ namespace OCC::Gui::Sharing class Share; /** - * @brief Creates a share and returns its parsed representation. + * @brief Creates a new server-side share container. + * + * This operation creates the share itself without selecting a source or + * recipient. Those are attached by separate update jobs. The returned JSON is + * parsed into a new Share object. */ class CreateShareJob : public UnifiedSharingRequest { Q_OBJECT public: + /** @brief Creates a request to create a share for account. */ explicit CreateShareJob(AccountPtr account); Q_SIGNALS: + /** @brief Emitted with the newly created share after a successful request. */ void shareCreated(QPointer share); }; diff --git a/src/gui/sharing/destroysharejob.cpp b/src/gui/sharing/destroysharejob.cpp index 797e5dfdae885..9b244696e692d 100644 --- a/src/gui/sharing/destroysharejob.cpp +++ b/src/gui/sharing/destroysharejob.cpp @@ -11,7 +11,10 @@ namespace OCC::Gui::Sharing { DestroyShareJob::DestroyShareJob(AccountPtr account, const QString &shareId) - : UnifiedSharingRequest{std::move(account), "/ocs/v2.php/apps/sharing/api/v1/share/%1"_L1.arg(shareId), "DELETE"_ba, {}, {204}} + : UnifiedSharingRequest{std::move(account), + "/ocs/v2.php/apps/sharing/api/v1/share/%1"_L1.arg(shareId), + "DELETE"_ba, + {.passStatusCodes = QList{204}}} { } diff --git a/src/gui/sharing/destroysharejob.h b/src/gui/sharing/destroysharejob.h index ced7651ebbb43..e4ec6e75c5a73 100644 --- a/src/gui/sharing/destroysharejob.h +++ b/src/gui/sharing/destroysharejob.h @@ -11,11 +11,15 @@ namespace OCC::Gui::Sharing { /** - * @brief Destroys a share. + * @brief Permanently removes a share from the server. + * + * This deletes the share identified by shareId rather than changing its + * lifecycle state or removing only one source or recipient. */ class DestroyShareJob : public UnifiedSharingRequest { public: + /** @brief Creates a request to delete the share identified by shareId. */ explicit DestroyShareJob(AccountPtr account, const QString &shareId); }; diff --git a/src/gui/sharing/generatesecretjob.cpp b/src/gui/sharing/generatesecretjob.cpp new file mode 100644 index 0000000000000..18ae7e1f52600 --- /dev/null +++ b/src/gui/sharing/generatesecretjob.cpp @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "generatesecretjob.h" + +#include +#include + +using namespace Qt::StringLiterals; + +namespace OCC::Gui::Sharing +{ + +GenerateSecretJob::GenerateSecretJob(AccountPtr account) + : UnifiedSharingRequest{std::move(account), "/ocs/v2.php/apps/sharing/api/v1/secret"_L1, "GET"_ba} +{ + connect(this, &OcsJob::jobFinished, this, [this](const QJsonDocument &json, int) { + Q_EMIT secretGenerated(json.object().value("ocs"_L1).toObject().value("data"_L1).toString()); + }); +} + +} diff --git a/src/gui/sharing/generatesecretjob.h b/src/gui/sharing/generatesecretjob.h new file mode 100644 index 0000000000000..cd739dea8a3ed --- /dev/null +++ b/src/gui/sharing/generatesecretjob.h @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "unifiedsharingrequest.h" + +namespace OCC::Gui::Sharing +{ + +/** + * @brief Requests a new opaque sharing secret from the server. + * + * The operation only generates and returns a secret. It does not attach the + * secret to a recipient; SetRecipientSecretJob performs that update. + */ +class GenerateSecretJob : public UnifiedSharingRequest +{ + Q_OBJECT + +public: + /** @brief Creates a request to generate one secret. */ + explicit GenerateSecretJob(AccountPtr account); + +Q_SIGNALS: + /** @brief Emitted with the generated secret after a successful request. */ + void secretGenerated(const QString &secret); +}; + +} diff --git a/src/gui/sharing/getsharejob.cpp b/src/gui/sharing/getsharejob.cpp new file mode 100644 index 0000000000000..0ef67534104d3 --- /dev/null +++ b/src/gui/sharing/getsharejob.cpp @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "getsharejob.h" + +#include "share.h" + +using namespace Qt::StringLiterals; + +namespace OCC::Gui::Sharing +{ + +namespace +{ +std::optional getShareBody(const std::optional &secret, + const std::optional &arguments) +{ + if (!secret && !arguments) { + return std::nullopt; + } + + auto body = QJsonObject{}; + if (secret) { + body.insert("secret"_L1, *secret); + } + if (arguments) { + body.insert("arguments"_L1, *arguments); + } + return body; +} +} + +GetShareJob::GetShareJob(AccountPtr account, + const QString &shareId, + const std::optional &secret, + const std::optional &arguments) + : UnifiedSharingRequest{account, + "/ocs/v2.php/apps/sharing/api/v1/share/%1"_L1.arg(shareId), + "POST"_ba, + {.body = getShareBody(secret, arguments)}} +{ + connect(this, &OcsJob::jobFinished, this, [this, account = std::move(account)](const QJsonDocument &json, int) { + Q_EMIT shareFetched(Share::fromJson(json, account)); + }); +} + +} diff --git a/src/gui/sharing/getsharejob.h b/src/gui/sharing/getsharejob.h new file mode 100644 index 0000000000000..3edcaf23e927f --- /dev/null +++ b/src/gui/sharing/getsharejob.h @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "unifiedsharingrequest.h" + +#include +#include + +#include + +namespace OCC::Gui::Sharing +{ + +class Share; + +/** + * @brief Fetches the complete representation of one share. + * + * The result includes the share's lifecycle state, sources, recipients, + * properties, permissions, and selected permission preset. A secret and + * recipient-type-specific access arguments can be supplied when required to + * access the share. + */ +class GetShareJob : public UnifiedSharingRequest +{ + Q_OBJECT + +public: + /** + * @brief Creates a request to fetch one share. + * + * A missing secret and missing arguments are omitted from the request body. + * + * @param shareId Server ID of the share to fetch + * @param secret Secret used to access the share, or no value when none is required + * @param arguments Recipient- or property-type-specific access arguments, or no value + */ + explicit GetShareJob(AccountPtr account, + const QString &shareId, + const std::optional &secret = std::nullopt, + const std::optional &arguments = std::nullopt); + +Q_SIGNALS: + /** @brief Emitted with the fetched share after a successful request. */ + void shareFetched(QPointer share); +}; + +} diff --git a/src/gui/sharing/getsharesjob.cpp b/src/gui/sharing/getsharesjob.cpp new file mode 100644 index 0000000000000..e3468d6bbcbfb --- /dev/null +++ b/src/gui/sharing/getsharesjob.cpp @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "getsharesjob.h" + +#include "share.h" + +#include +#include +#include + +using namespace Qt::StringLiterals; + +namespace OCC::Gui::Sharing +{ + +namespace +{ +QList> getSharesParameters(const std::optional &sourceTypeClass, + const std::optional &sourceTypeValue, + const std::optional &lastShareId, + qint64 limit) +{ + auto parameters = QList>{{"limit"_L1, QString::number(limit)}}; + if (sourceTypeClass) { + parameters.emplaceBack("filterSourceTypeClass"_L1, *sourceTypeClass); + } + if (sourceTypeValue) { + parameters.emplaceBack("filterSourceTypeValue"_L1, *sourceTypeValue); + } + if (lastShareId) { + parameters.emplaceBack("lastShareID"_L1, *lastShareId); + } + return parameters; +} +} + +GetSharesJob::GetSharesJob(AccountPtr account, + const std::optional &sourceTypeClass, + const std::optional &sourceTypeValue, + const std::optional &lastShareId, + qint64 limit) + : UnifiedSharingRequest{account, + "/ocs/v2.php/apps/sharing/api/v1/shares"_L1, + "GET"_ba, + {.parameters = getSharesParameters(sourceTypeClass, sourceTypeValue, lastShareId, limit)}} +{ + connect(this, &OcsJob::jobFinished, this, [this, account = std::move(account)](const QJsonDocument &json, int) { + auto shares = QList>{}; + const auto data = json.object().value("ocs"_L1).toObject().value("data"_L1).toArray(); + shares.reserve(data.size()); + for (const auto &value : data) { + const auto shareJson = QJsonDocument{QJsonObject{ + {"ocs"_L1, QJsonObject{{"data"_L1, value.toObject()}}}, + }}; + shares.append(Share::fromJson(shareJson, account)); + } + Q_EMIT sharesFetched(shares); + }); +} + +} diff --git a/src/gui/sharing/getsharesjob.h b/src/gui/sharing/getsharesjob.h new file mode 100644 index 0000000000000..49ba2087d534b --- /dev/null +++ b/src/gui/sharing/getsharesjob.h @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "unifiedsharingrequest.h" + +#include +#include + +#include + +namespace OCC::Gui::Sharing +{ + +class Share; + +/** + * @brief Fetches a page of shares accessible to the account. + * + * Results can be restricted to shares containing a particular source type and + * source value. Pagination continues after lastShareId and returns at most + * limit complete Share objects. + */ +class GetSharesJob : public UnifiedSharingRequest +{ + Q_OBJECT + +public: + /** + * @brief Creates a request to fetch shares. + * + * Missing filters and a missing last share ID are omitted. + * + * @param sourceTypeClass Registered source type class to match, or no value for all types + * @param sourceTypeValue Source identifier to match, or no value for all values + * @param lastShareId ID after which the server continues the result set, or no value for the first page + * @param limit Maximum number of shares to return + */ + explicit GetSharesJob(AccountPtr account, + const std::optional &sourceTypeClass = std::nullopt, + const std::optional &sourceTypeValue = std::nullopt, + const std::optional &lastShareId = std::nullopt, + qint64 limit = 100); + +Q_SIGNALS: + /** @brief Emitted with the fetched shares after a successful request. */ + void sharesFetched(const QList> &shares); +}; + +} diff --git a/src/gui/sharing/removerecipientjob.cpp b/src/gui/sharing/removerecipientjob.cpp index b7faf353a1c54..876ccede37a9f 100644 --- a/src/gui/sharing/removerecipientjob.cpp +++ b/src/gui/sharing/removerecipientjob.cpp @@ -12,12 +12,30 @@ using namespace Qt::StringLiterals; namespace OCC::Gui::Sharing { -RemoveRecipientJob::RemoveRecipientJob(AccountPtr account, Share &share, const QString &recipientType, const QString &recipientValue) +namespace +{ +QList> removeRecipientParameters(const QString &recipientTypeClass, + const QString &recipientValue, + const std::optional &instance) +{ + auto parameters = QList>{{"class"_L1, recipientTypeClass}, {"value"_L1, recipientValue}}; + if (instance) { + parameters.emplaceBack("instance"_L1, *instance); + } + return parameters; +} +} + +RemoveRecipientJob::RemoveRecipientJob(AccountPtr account, + Share &share, + const QString &recipientTypeClass, + const QString &recipientValue, + const std::optional &instance) : UpdateShareJob{std::move(account), share, "/ocs/v2.php/apps/sharing/api/v1/share/%1/recipient"_L1.arg(share.id()), "DELETE"_ba, - {{"class"_L1, recipientType}, {"value"_L1, recipientValue}}} + {.parameters = removeRecipientParameters(recipientTypeClass, recipientValue, instance)}} { } diff --git a/src/gui/sharing/removerecipientjob.h b/src/gui/sharing/removerecipientjob.h index 9d5a8c708106f..fe163ed5dfd27 100644 --- a/src/gui/sharing/removerecipientjob.h +++ b/src/gui/sharing/removerecipientjob.h @@ -7,14 +7,33 @@ #include "updatesharejob.h" +#include + namespace OCC::Gui::Sharing { -/** @brief Removes a recipient from a share. */ +/** + * @brief Revokes one recipient's access to an existing share. + * + * The recipient is selected by the same type, value, and optional instance + * tuple used when it was added. Other recipients remain attached to the share. + * The returned representation is applied to the supplied Share object. + */ class RemoveRecipientJob : public UpdateShareJob { public: - explicit RemoveRecipientJob(AccountPtr account, Share &share, const QString &recipientType, const QString &recipientValue); + /** + * @brief Creates a request to remove a recipient from share. + * + * @param recipientTypeClass Registered server class describing the recipient type + * @param recipientValue Identifier understood by the recipient type + * @param instance Absolute URL of the recipient's remote instance, or no value for a local recipient + */ + explicit RemoveRecipientJob(AccountPtr account, + Share &share, + const QString &recipientTypeClass, + const QString &recipientValue, + const std::optional &instance = std::nullopt); }; } diff --git a/src/gui/sharing/removesourcejob.cpp b/src/gui/sharing/removesourcejob.cpp new file mode 100644 index 0000000000000..4f25d80c03d41 --- /dev/null +++ b/src/gui/sharing/removesourcejob.cpp @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "removesourcejob.h" + +#include "share.h" + +using namespace Qt::StringLiterals; + +namespace OCC::Gui::Sharing +{ + +RemoveSourceJob::RemoveSourceJob(AccountPtr account, Share &share, const QString &fileId) + : UpdateShareJob{std::move(account), + share, + "/ocs/v2.php/apps/sharing/api/v1/share/%1/source"_L1.arg(share.id()), + "DELETE"_ba, + {.parameters = {{"class"_L1, "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1}, {"value"_L1, fileId}}}} +{ +} + +} diff --git a/src/gui/sharing/removesourcejob.h b/src/gui/sharing/removesourcejob.h new file mode 100644 index 0000000000000..392eb61191cf0 --- /dev/null +++ b/src/gui/sharing/removesourcejob.h @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "updatesharejob.h" + +namespace OCC::Gui::Sharing +{ + +/** + * @brief Removes a local filesystem node from an existing share. + * + * This stops the node identified by fileId from being content of the share. It + * does not delete the node or the share itself. The returned representation is + * applied to the supplied Share object. + */ +class RemoveSourceJob : public UpdateShareJob +{ +public: + /** @brief Creates a request to remove the node identified by fileId. */ + explicit RemoveSourceJob(AccountPtr account, Share &share, const QString &fileId); +}; + +} diff --git a/src/gui/sharing/searchrecipientsjob.cpp b/src/gui/sharing/searchrecipientsjob.cpp index 8fcca37c16926..1aae23ca49464 100644 --- a/src/gui/sharing/searchrecipientsjob.cpp +++ b/src/gui/sharing/searchrecipientsjob.cpp @@ -16,13 +16,29 @@ namespace OCC::Gui::Sharing namespace { constexpr auto requestTimeoutMsec = 10 * 1000; + +QList> searchRecipientsParameters(const QString &query, + qint64 offset, + qint64 limit, + const QList &recipientTypeClasses) +{ + auto parameters = QList>{ + {"query"_L1, query}, + {"offset"_L1, QString::number(offset)}, + {"limit"_L1, QString::number(limit)}, + }; + for (const auto &recipientTypeClass : recipientTypeClasses) { + parameters.emplaceBack("recipientTypeClasses[]"_L1, recipientTypeClass); + } + return parameters; +} } -SearchRecipientsJob::SearchRecipientsJob(AccountPtr account, const QString &query, qint64 offset, qint64 limit) +SearchRecipientsJob::SearchRecipientsJob(AccountPtr account, const QString &query, qint64 offset, qint64 limit, const QList &recipientTypeClasses) : UnifiedSharingRequest{std::move(account), "/ocs/v2.php/apps/sharing/api/v1/recipients"_L1, "GET"_ba, - {{"query"_L1, query}, {"offset"_L1, QString::number(offset)}, {"limit"_L1, QString::number(limit)}}} + {.parameters = searchRecipientsParameters(query, offset, limit, recipientTypeClasses)}} { setTimeout(requestTimeoutMsec); connect(this, &OcsJob::jobFinished, this, [this](const QJsonDocument &json, int) { diff --git a/src/gui/sharing/searchrecipientsjob.h b/src/gui/sharing/searchrecipientsjob.h index f99c004f29774..4b9224c1d8f5e 100644 --- a/src/gui/sharing/searchrecipientsjob.h +++ b/src/gui/sharing/searchrecipientsjob.h @@ -8,21 +8,36 @@ #include "unifiedsharingrequest.h" #include +#include namespace OCC::Gui::Sharing { /** - * @brief Searches for recipients and returns the result entries. + * @brief Searches for recipients that can be added to a share. + * + * The server searches registered recipient types, such as users, groups, or + * other sharing backends. The optional recipient type list restricts which + * backends are searched. This job only returns candidates; it does not add a + * recipient to a share. */ class SearchRecipientsJob : public UnifiedSharingRequest { Q_OBJECT public: - explicit SearchRecipientsJob(AccountPtr account, const QString &query, qint64 offset, qint64 limit); + /** + * @brief Creates a paginated recipient search request. + * + * @param query Text used by the server's recipient search backends + * @param offset Number of matching entries to skip + * @param limit Maximum number of matching entries to return + * @param recipientTypeClasses Registered recipient type classes to search, or empty for all + */ + explicit SearchRecipientsJob(AccountPtr account, const QString &query, qint64 offset, qint64 limit, const QList &recipientTypeClasses = {}); Q_SIGNALS: + /** @brief Emitted with the matching recipient descriptions after a successful request. */ void recipientsFound(const QJsonArray &recipients); }; diff --git a/src/gui/sharing/setpermissionjob.cpp b/src/gui/sharing/setpermissionjob.cpp index 9cc2a4506d068..2e30aa2be6994 100644 --- a/src/gui/sharing/setpermissionjob.cpp +++ b/src/gui/sharing/setpermissionjob.cpp @@ -17,7 +17,7 @@ SetPermissionJob::SetPermissionJob(AccountPtr account, Share &share, const QStri share, "/ocs/v2.php/apps/sharing/api/v1/share/%1/permission"_L1.arg(share.id()), "PUT"_ba, - {{"class"_L1, permissionClass}, {"enabled"_L1, enabled ? "true"_L1 : "false"_L1}}} + {.body = QJsonObject{{"class"_L1, permissionClass}, {"enabled"_L1, enabled}}}} { } diff --git a/src/gui/sharing/setpermissionjob.h b/src/gui/sharing/setpermissionjob.h index 726652129a085..aa6b407bf82e3 100644 --- a/src/gui/sharing/setpermissionjob.h +++ b/src/gui/sharing/setpermissionjob.h @@ -10,10 +10,22 @@ namespace OCC::Gui::Sharing { -/** @brief Enables or disables one permission on a share. */ +/** + * @brief Enables or disables one capability for a share's recipients. + * + * The permission class identifies a server-registered capability compatible + * with the share's sources, such as viewing or editing node content. The + * server returns the complete updated share. + */ class SetPermissionJob : public UpdateShareJob { public: + /** + * @brief Creates a request to change one permission on share. + * + * @param permissionClass Registered permission type class to update + * @param enabled Whether recipients should receive that capability + */ explicit SetPermissionJob(AccountPtr account, Share &share, const QString &permissionClass, bool enabled); }; diff --git a/src/gui/sharing/setpermissionpresetjob.cpp b/src/gui/sharing/setpermissionpresetjob.cpp index b5d798040c668..d531d5250d426 100644 --- a/src/gui/sharing/setpermissionpresetjob.cpp +++ b/src/gui/sharing/setpermissionpresetjob.cpp @@ -17,7 +17,7 @@ SetPermissionPresetJob::SetPermissionPresetJob(AccountPtr account, Share &share, share, "/ocs/v2.php/apps/sharing/api/v1/share/%1/permission/preset"_L1.arg(share.id()), "PUT"_ba, - {{"permissionPreset"_L1, permissionPreset}}} + {.body = QJsonObject{{"permissionPresetClass"_L1, permissionPreset}}}} { } diff --git a/src/gui/sharing/setpermissionpresetjob.h b/src/gui/sharing/setpermissionpresetjob.h index 50bf2b367783d..c1188a6ee305f 100644 --- a/src/gui/sharing/setpermissionpresetjob.h +++ b/src/gui/sharing/setpermissionpresetjob.h @@ -10,10 +10,21 @@ namespace OCC::Gui::Sharing { -/** @brief Applies a permission preset to a share. */ +/** + * @brief Selects a server-defined permission preset for a share. + * + * A preset represents a named server configuration of the share's individual + * permissions. The server applies the preset and returns the complete updated + * share. + */ class SetPermissionPresetJob : public UpdateShareJob { public: + /** + * @brief Creates a request to select a permission preset for share. + * + * @param permissionPreset Registered permission preset class to select + */ explicit SetPermissionPresetJob(AccountPtr account, Share &share, const QString &permissionPreset); }; diff --git a/src/gui/sharing/setpropertyjob.cpp b/src/gui/sharing/setpropertyjob.cpp new file mode 100644 index 0000000000000..bd9f0d0d95f8b --- /dev/null +++ b/src/gui/sharing/setpropertyjob.cpp @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "setpropertyjob.h" + +#include "share.h" + +#include + +using namespace Qt::StringLiterals; + +namespace OCC::Gui::Sharing +{ + +SetPropertyJob::SetPropertyJob(AccountPtr account, + Share &share, + const QString &propertyClass, + const std::optional &value) + : UpdateShareJob{std::move(account), + share, + "/ocs/v2.php/apps/sharing/api/v1/share/%1/property"_L1.arg(share.id()), + "PUT"_ba, + {.body = QJsonObject{{"class"_L1, propertyClass}, {"value"_L1, value ? QJsonValue{*value} : QJsonValue{QJsonValue::Null}}}}} +{ +} + +} diff --git a/src/gui/sharing/setpropertyjob.h b/src/gui/sharing/setpropertyjob.h new file mode 100644 index 0000000000000..367f98895c8c9 --- /dev/null +++ b/src/gui/sharing/setpropertyjob.h @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "updatesharejob.h" + +#include + +namespace OCC::Gui::Sharing +{ + +/** + * @brief Sets or clears one piece of share configuration. + * + * Properties are server-registered, typed settings compatible with the + * share's sources or recipients, such as a label, note, password, or + * expiration date. The returned representation is applied to the supplied + * Share object. + */ +class SetPropertyJob : public UpdateShareJob +{ +public: + /** + * @brief Creates a request to update a property. + * + * A missing value sends JSON null and clears the property. + * + * @param propertyClass Registered property type class to update + * @param value Serialized property value, or no value to clear it + */ + explicit SetPropertyJob(AccountPtr account, + Share &share, + const QString &propertyClass, + const std::optional &value); +}; + +} diff --git a/src/gui/sharing/setrecipientsecretjob.cpp b/src/gui/sharing/setrecipientsecretjob.cpp new file mode 100644 index 0000000000000..a9bed7033142c --- /dev/null +++ b/src/gui/sharing/setrecipientsecretjob.cpp @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "setrecipientsecretjob.h" + +#include "share.h" + +using namespace Qt::StringLiterals; + +namespace OCC::Gui::Sharing +{ + +namespace +{ +QJsonObject setRecipientSecretBody(const QString &recipientTypeClass, + const QString &recipientValue, + const QString &secret, + const std::optional &instance) +{ + auto body = QJsonObject{ + {"class"_L1, recipientTypeClass}, + {"value"_L1, recipientValue}, + {"secret"_L1, secret}, + }; + if (instance) { + body.insert("instance"_L1, *instance); + } + return body; +} +} + +SetRecipientSecretJob::SetRecipientSecretJob(AccountPtr account, + Share &share, + const QString &recipientTypeClass, + const QString &recipientValue, + const QString &secret, + const std::optional &instance) + : UpdateShareJob{std::move(account), + share, + "/ocs/v2.php/apps/sharing/api/v1/share/%1/recipient/secret"_L1.arg(share.id()), + "PUT"_ba, + {.body = setRecipientSecretBody(recipientTypeClass, recipientValue, secret, instance)}} +{ +} + +} diff --git a/src/gui/sharing/setrecipientsecretjob.h b/src/gui/sharing/setrecipientsecretjob.h new file mode 100644 index 0000000000000..5d34cac778a05 --- /dev/null +++ b/src/gui/sharing/setrecipientsecretjob.h @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "updatesharejob.h" + +#include + +namespace OCC::Gui::Sharing +{ + +/** + * @brief Replaces the access secret associated with one share recipient. + * + * The recipient is selected by its type, value, and optional instance. This + * changes only that recipient's secret and applies the returned complete share + * representation to the supplied Share object. + */ +class SetRecipientSecretJob : public UpdateShareJob +{ +public: + /** + * @brief Creates a request to replace a recipient's secret. + * + * @param recipientTypeClass Registered server class describing the recipient type + * @param recipientValue Identifier understood by the recipient type + * @param secret New access secret for the recipient + * @param instance Absolute URL of the recipient's remote instance, or no value for a local recipient + */ + explicit SetRecipientSecretJob(AccountPtr account, + Share &share, + const QString &recipientTypeClass, + const QString &recipientValue, + const QString &secret, + const std::optional &instance = std::nullopt); +}; + +} diff --git a/src/gui/sharing/setsharestatejob.cpp b/src/gui/sharing/setsharestatejob.cpp new file mode 100644 index 0000000000000..bb3c98b2c5cf5 --- /dev/null +++ b/src/gui/sharing/setsharestatejob.cpp @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "setsharestatejob.h" + +using namespace Qt::StringLiterals; + +namespace OCC::Gui::Sharing +{ + +namespace +{ +QString stateName(Share::ShareState state) +{ + switch (state) { + case Share::ShareState::Active: + return "active"_L1; + case Share::ShareState::Deleted: + return "deleted"_L1; + case Share::ShareState::Draft: + return "draft"_L1; + } + Q_UNREACHABLE_RETURN({}); +} +} + +SetShareStateJob::SetShareStateJob(AccountPtr account, Share &share, Share::ShareState state) + : UpdateShareJob{std::move(account), + share, + "/ocs/v2.php/apps/sharing/api/v1/share/%1/state"_L1.arg(share.id()), + "PUT"_ba, + {.body = QJsonObject{{"state"_L1, stateName(state)}}}} +{ +} + +} diff --git a/src/gui/sharing/setsharestatejob.h b/src/gui/sharing/setsharestatejob.h new file mode 100644 index 0000000000000..b3c6a1d7e442b --- /dev/null +++ b/src/gui/sharing/setsharestatejob.h @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "share.h" +#include "updatesharejob.h" + +namespace OCC::Gui::Sharing +{ + +/** + * @brief Changes a share's lifecycle state. + * + * Unified shares can be draft, active, or deleted. This operation changes that + * state and applies the server's complete updated representation to the + * supplied Share object. It does not permanently remove the share record; + * DestroyShareJob performs that operation. + */ +class SetShareStateJob : public UpdateShareJob +{ +public: + /** + * @brief Creates a request to change the state of share. + * + * @param state New lifecycle state + */ + explicit SetShareStateJob(AccountPtr account, Share &share, Share::ShareState state); +}; + +} diff --git a/src/gui/sharing/unifiedsharingrequest.cpp b/src/gui/sharing/unifiedsharingrequest.cpp index 0f6c7c043afca..46ec117debdf4 100644 --- a/src/gui/sharing/unifiedsharingrequest.cpp +++ b/src/gui/sharing/unifiedsharingrequest.cpp @@ -15,17 +15,19 @@ using namespace OCC::Gui::Sharing; UnifiedSharingRequest::UnifiedSharingRequest(AccountPtr account, const QString &path, const QByteArray &verb, - const QList> ¶meters, - const QList &passStatusCodes) + const UnifiedSharingRequestOptions &options) : OcsJob{account} { setPath(path); setVerb(verb); - for (const auto &[name, value] : parameters) { + for (const auto &[name, value] : options.parameters) { addParam(name, value); } - for (const auto statusCode : passStatusCodes) { - addPassStatusCode(statusCode); + if (options.passStatusCodes) { + setPassStatusCodes(*options.passStatusCodes); + } + if (options.body) { + setJsonBody(*options.body); } } diff --git a/src/gui/sharing/unifiedsharingrequest.h b/src/gui/sharing/unifiedsharingrequest.h index 6b2fc3b0f2527..e9d8e7e3fc52d 100644 --- a/src/gui/sharing/unifiedsharingrequest.h +++ b/src/gui/sharing/unifiedsharingrequest.h @@ -5,10 +5,13 @@ #pragma once +#include #include #include #include +#include + #include "ocsjob.h" #include "accountfwd.h" @@ -16,6 +19,16 @@ namespace OCC::Gui::Sharing { +/** + * @brief Optional parts of a Unified Sharing request. + */ +struct UnifiedSharingRequestOptions +{ + QList> parameters; //!< Query or form parameters to send + std::optional> passStatusCodes; //!< Accepted status codes, or no value to keep the OCS defaults + std::optional body; //!< JSON body to send, or no value to send no JSON body +}; + /** * @brief Configures and starts one request to the Unified Sharing API. */ @@ -27,8 +40,7 @@ class UnifiedSharingRequest : public OcsJob explicit UnifiedSharingRequest(AccountPtr account, const QString &path, const QByteArray &verb, - const QList> ¶meters = {}, - const QList &passStatusCodes = {}); + const UnifiedSharingRequestOptions &options = {}); void start() override; diff --git a/src/gui/sharing/updatesharejob.cpp b/src/gui/sharing/updatesharejob.cpp index 035fa09e0d694..6ac64e361b8aa 100644 --- a/src/gui/sharing/updatesharejob.cpp +++ b/src/gui/sharing/updatesharejob.cpp @@ -10,8 +10,12 @@ namespace OCC::Gui::Sharing { -UpdateShareJob::UpdateShareJob(AccountPtr account, Share &share, const QString &path, const QByteArray &verb, const QList> ¶meters) - : UnifiedSharingRequest{std::move(account), path, verb, parameters} +UpdateShareJob::UpdateShareJob(AccountPtr account, + Share &share, + const QString &path, + const QByteArray &verb, + const UnifiedSharingRequestOptions &options) + : UnifiedSharingRequest{std::move(account), path, verb, options} { connect(this, &OcsJob::jobFinished, this, [this, share = QPointer{&share}](const QJsonDocument &json, int) { if (share) { diff --git a/src/gui/sharing/updatesharejob.h b/src/gui/sharing/updatesharejob.h index 33e7f27f5c2ba..12c2bbe2a29ad 100644 --- a/src/gui/sharing/updatesharejob.h +++ b/src/gui/sharing/updatesharejob.h @@ -11,17 +11,28 @@ namespace OCC::Gui::Sharing { class Share; + /** - * @brief Applies a server response to an existing share. + * @brief Base for operations that mutate one existing share. + * + * Successful Unified Sharing mutation endpoints return the complete updated + * share. This base applies that response to the same Share object supplied to + * the concrete job and then emits shareUpdated. It does not own the Share and + * safely handles the object being deleted while the request is running. */ class UpdateShareJob : public UnifiedSharingRequest { Q_OBJECT protected: - explicit UpdateShareJob(AccountPtr account, Share &share, const QString &path, const QByteArray &verb, const QList> ¶meters); + explicit UpdateShareJob(AccountPtr account, + Share &share, + const QString &path, + const QByteArray &verb, + const UnifiedSharingRequestOptions &options = {}); Q_SIGNALS: + /** @brief Emitted after a successful response, or with null if the Share was deleted while the request was running. */ void shareUpdated(QPointer share); }; From 99be208996ed6456593d33097a3be94db68fa858 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 21:48:31 +0800 Subject: [PATCH 052/104] feat(sharing): adapt tests to new jobs Signed-off-by: Claudio Cambra --- test/testunifiedsharing.cpp | 237 ++++++++++++++++++++++++++++++++---- 1 file changed, 210 insertions(+), 27 deletions(-) diff --git a/test/testunifiedsharing.cpp b/test/testunifiedsharing.cpp index f4b4e3cf4d5e2..211349c233262 100644 --- a/test/testunifiedsharing.cpp +++ b/test/testunifiedsharing.cpp @@ -7,11 +7,18 @@ #include "gui/sharing/addsourcejob.h" #include "gui/sharing/createsharejob.h" #include "gui/sharing/destroysharejob.h" +#include "gui/sharing/generatesecretjob.h" +#include "gui/sharing/getsharejob.h" +#include "gui/sharing/getsharesjob.h" #include "gui/sharing/recipientsearchmodel.h" #include "gui/sharing/removerecipientjob.h" +#include "gui/sharing/removesourcejob.h" #include "gui/sharing/searchrecipientsjob.h" #include "gui/sharing/setpermissionjob.h" #include "gui/sharing/setpermissionpresetjob.h" +#include "gui/sharing/setpropertyjob.h" +#include "gui/sharing/setrecipientsecretjob.h" +#include "gui/sharing/setsharestatejob.h" #include "gui/sharing/share.h" #include "gui/sharing/unifiedsharingrequest.h" #include "gui/sharing/updatesharejob.h" @@ -21,6 +28,8 @@ #include #include +#include + using namespace OCC; using namespace OCC::Gui::Sharing; using namespace Qt::StringLiterals; @@ -36,10 +45,26 @@ private slots: auto requestCount = 0; auto requestPath = QString{}; auto requestVerb = QByteArray{}; + auto requestQuery = QList>{}; + auto requestBody = QJsonObject{}; + auto requestContentType = QByteArray{}; - fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *outgoingData) { ++requestCount; requestPath = request.url().path(); + requestQuery = QUrlQuery{request.url()}.queryItems(); + requestQuery.removeAll({"format"_L1, "json"_L1}); + std::ranges::sort(requestQuery); + requestContentType = request.rawHeader("Content-Type"); + if (outgoingData) { + if (!outgoingData->isOpen()) { + outgoingData->open(QIODevice::ReadOnly); + } + requestBody = QJsonDocument::fromJson(outgoingData->peek(outgoingData->bytesAvailable())).object(); + outgoingData->reset(); + } else { + requestBody = {}; + } requestVerb = request.attribute(QNetworkRequest::CustomVerbAttribute).toByteArray(); if (requestVerb.isEmpty()) { switch (operation) { @@ -60,21 +85,27 @@ private slots: } } - return new FakePayloadReply{operation, - request, - R"json({ + auto statusCode = 200; + if (requestVerb == "POST" && requestPath.endsWith("/api/v1/share"_L1)) { + statusCode = 201; + } else if (requestVerb == "DELETE" && requestPath.endsWith("/api/v1/share/share-1"_L1)) { + statusCode = 204; + } + const auto response = QString{R"json({ "ocs": { "meta": { "status": "ok", - "statuscode": 200, + "statuscode": %1, "message": "OK" }, "data": { "id": "share-1" } } - })json", - this}; + })json"} + .arg(statusCode) + .toUtf8(); + return new FakePayloadReply{operation, request, response, this}; }); const auto account = fakeFolder.account(); @@ -83,46 +114,110 @@ private slots: const auto verifyRequest = [&](UnifiedSharingRequest *job, const QByteArray &expectedVerb, const QString &expectedPath, - const QHash &expectedParameters = {}) { - for (auto it = expectedParameters.cbegin(); it != expectedParameters.cend(); ++it) { - QCOMPARE(job->getParamValue(it.key()), it.value()); - } - + QList> expectedQuery = {}, + const QJsonObject &expectedBody = {}) { QSignalSpy finishedSpy{job, &UnifiedSharingRequest::jobFinished}; job->start(); QVERIFY(finishedSpy.wait()); QCOMPARE(requestVerb, expectedVerb); QVERIFY2(requestPath.endsWith(expectedPath), qPrintable(requestPath)); + std::ranges::sort(expectedQuery); + QCOMPARE(requestQuery, expectedQuery); + QCOMPARE(requestBody, expectedBody); + if (!expectedBody.isEmpty()) { + QCOMPARE(requestContentType, "application/json"); + } }; + verifyRequest(new SearchRecipientsJob{account, "ali"_L1, 10, 20, {"user-class"_L1, "group-class"_L1}}, + "GET", + "/ocs/v2.php/apps/sharing/api/v1/recipients", + {{"query"_L1, "ali"_L1}, + {"offset"_L1, "10"_L1}, + {"limit"_L1, "20"_L1}, + {"recipientTypeClasses%5B%5D"_L1, "user-class"_L1}, + {"recipientTypeClasses%5B%5D"_L1, "group-class"_L1}}); + verifyRequest(new GenerateSecretJob{account}, "GET", "/ocs/v2.php/apps/sharing/api/v1/secret"); verifyRequest(new CreateShareJob{account}, "POST", "/ocs/v2.php/apps/sharing/api/v1/share"); - verifyRequest(new DestroyShareJob{account, "share-1"_L1}, "DELETE", "/ocs/v2.php/apps/sharing/api/v1/share/share-1"); + verifyRequest(new SetShareStateJob{account, *share, Share::ShareState::Active}, + "PUT", + "/ocs/v2.php/apps/sharing/api/v1/share/share-1/state", + {}, + {{"state"_L1, "active"_L1}}); verifyRequest(new AddSourceJob{account, *share, "42"_L1}, "POST", "/ocs/v2.php/apps/sharing/api/v1/share/share-1/source", + {}, {{"class"_L1, "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1}, {"value"_L1, "42"_L1}}); - verifyRequest(new AddRecipientJob{account, *share, "recipient-class"_L1, "alice"_L1}, + verifyRequest(new RemoveSourceJob{account, *share, "42"_L1}, + "DELETE", + "/ocs/v2.php/apps/sharing/api/v1/share/share-1/source", + {{"class"_L1, "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1}, {"value"_L1, "42"_L1}}); + verifyRequest(new AddRecipientJob{account, *share, "recipient-class"_L1, "alice"_L1, "https://example.com"_L1}, "POST", "/ocs/v2.php/apps/sharing/api/v1/share/share-1/recipient", - {{"class"_L1, "recipient-class"_L1}, {"value"_L1, "alice"_L1}}); - verifyRequest(new RemoveRecipientJob{account, *share, "recipient-class"_L1, "alice"_L1}, + {}, + {{"class"_L1, "recipient-class"_L1}, {"value"_L1, "alice"_L1}, {"instance"_L1, "https://example.com"_L1}}); + verifyRequest(new RemoveRecipientJob{account, *share, "recipient-class"_L1, "alice"_L1, "https://example.com"_L1}, "DELETE", "/ocs/v2.php/apps/sharing/api/v1/share/share-1/recipient", - {{"class"_L1, "recipient-class"_L1}, {"value"_L1, "alice"_L1}}); - verifyRequest(new SearchRecipientsJob{account, "ali"_L1, 10, 20}, - "GET", - "/ocs/v2.php/apps/sharing/api/v1/recipients", - {{"query"_L1, "ali"_L1}, {"offset"_L1, "10"_L1}, {"limit"_L1, "20"_L1}}); + {{"class"_L1, "recipient-class"_L1}, {"value"_L1, "alice"_L1}, {"instance"_L1, "https%3A%2F%2Fexample.com"_L1}}); + verifyRequest(new SetRecipientSecretJob{account, *share, "recipient-class"_L1, "alice"_L1, "secret"_L1, "https://example.com"_L1}, + "PUT", + "/ocs/v2.php/apps/sharing/api/v1/share/share-1/recipient/secret", + {}, + {{"class"_L1, "recipient-class"_L1}, {"value"_L1, "alice"_L1}, {"secret"_L1, "secret"_L1}, {"instance"_L1, "https://example.com"_L1}}); + verifyRequest(new SetPropertyJob{account, *share, "property-class"_L1, std::nullopt}, + "PUT", + "/ocs/v2.php/apps/sharing/api/v1/share/share-1/property", + {}, + {{"class"_L1, "property-class"_L1}, {"value"_L1, QJsonValue::Null}}); verifyRequest(new SetPermissionJob{account, *share, "permission-class"_L1, true}, "PUT", "/ocs/v2.php/apps/sharing/api/v1/share/share-1/permission", - {{"class"_L1, "permission-class"_L1}, {"enabled"_L1, "true"_L1}}); + {}, + {{"class"_L1, "permission-class"_L1}, {"enabled"_L1, true}}); verifyRequest(new SetPermissionPresetJob{account, *share, "preset-class"_L1}, "PUT", "/ocs/v2.php/apps/sharing/api/v1/share/share-1/permission/preset", - {{"permissionPreset"_L1, "preset-class"_L1}}); + {}, + {{"permissionPresetClass"_L1, "preset-class"_L1}}); + verifyRequest(new DestroyShareJob{account, "share-1"_L1}, "DELETE", "/ocs/v2.php/apps/sharing/api/v1/share/share-1"); + verifyRequest(new GetShareJob{account, + "share-1"_L1, + "secret"_L1, + QJsonObject{{"argument-class"_L1, QJsonObject{{"key"_L1, "value"_L1}}}}}, + "POST", + "/ocs/v2.php/apps/sharing/api/v1/share/share-1", + {}, + {{"secret"_L1, "secret"_L1}, {"arguments"_L1, QJsonObject{{"argument-class"_L1, QJsonObject{{"key"_L1, "value"_L1}}}}}}); + verifyRequest( + new GetSharesJob{account, "source-class"_L1, "42"_L1, "share-0"_L1, 50}, + "GET", + "/ocs/v2.php/apps/sharing/api/v1/shares", + {{"filterSourceTypeClass"_L1, "source-class"_L1}, {"filterSourceTypeValue"_L1, "42"_L1}, {"lastShareID"_L1, "share-0"_L1}, {"limit"_L1, "50"_L1}}); + verifyRequest(new AddRecipientJob{account, *share, "recipient-class"_L1, "alice"_L1}, + "POST", + "/ocs/v2.php/apps/sharing/api/v1/share/share-1/recipient", + {}, + {{"class"_L1, "recipient-class"_L1}, {"value"_L1, "alice"_L1}}); + verifyRequest(new GetShareJob{account, "share-1"_L1}, + "POST", + "/ocs/v2.php/apps/sharing/api/v1/share/share-1"); + QCOMPARE(requestContentType, "application/x-www-form-urlencoded"); + verifyRequest(new UnifiedSharingRequest{account, + "/ocs/v2.php/apps/sharing/api/v1/share/share-1"_L1, + "POST"_ba, + {.body = QJsonObject{}}}, + "POST", + "/ocs/v2.php/apps/sharing/api/v1/share/share-1"); + QCOMPARE(requestContentType, "application/json"); + verifyRequest(new GetSharesJob{account}, + "GET", + "/ocs/v2.php/apps/sharing/api/v1/shares", + {{"limit"_L1, "100"_L1}}); - QCOMPARE(requestCount, 8); + QCOMPARE(requestCount, 19); delete share; } @@ -157,13 +252,68 @@ private slots: QCOMPARE(requestCount, 1); } + void operationSpecificStatusCodesAreEnforced() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + return new FakePayloadReply{operation, + request, + R"json({ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": {} + } + })json", + this}; + }); + + const auto jobs = QList{ + new CreateShareJob{fakeFolder.account()}, + new DestroyShareJob{fakeFolder.account(), "share-1"_L1}, + }; + for (const auto job : jobs) { + auto jobFinished = false; + auto ocsError = false; + connect(job, &UnifiedSharingRequest::jobFinished, this, [&](const QJsonDocument &, int) { + jobFinished = true; + }); + connect(job, &UnifiedSharingRequest::ocsError, this, [&](int, const QString &) { + ocsError = true; + }); + + job->start(); + + QTRY_VERIFY(ocsError); + QVERIFY(!jobFinished); + } + } + void typedJobsParseTheirResults() { FakeFolder fakeFolder{{}, {}, {}, false}; fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { - const auto data = - request.url().path().endsWith("/recipients"_L1) ? R"json([{"label":"Alice"}])json" : R"json({"id":"share-1","state":"active"})json"; - const auto statusCode = operation == QNetworkAccessManager::DeleteOperation ? 204 : 200; + const auto path = request.url().path(); + auto data = QByteArray{R"json({"id":"share-1","state":"active"})json"}; + if (path.endsWith("/recipients"_L1)) { + data = R"json([{"label":"Alice"}])json"; + } else if (path.endsWith("/secret"_L1)) { + data = R"json("generated-secret")json"; + } else if (path.endsWith("/shares"_L1)) { + data = R"json([{"id":"share-1","state":"active"},{"id":"share-2","state":"draft"}])json"; + } + + auto statusCode = 200; + const auto customVerb = request.attribute(QNetworkRequest::CustomVerbAttribute).toByteArray(); + if ((operation == QNetworkAccessManager::DeleteOperation || customVerb == "DELETE") && !path.endsWith("/source"_L1) + && !path.endsWith("/recipient"_L1)) { + statusCode = 204; + } else if (operation == QNetworkAccessManager::PostOperation && path.endsWith("/share"_L1)) { + statusCode = 201; + } const auto payload = QString{R"json({"ocs":{"meta":{"status":"ok","statuscode":%1,"message":"OK"},"data":%2}})json"} .arg(statusCode) .arg(QString::fromUtf8(data)) @@ -201,6 +351,33 @@ private slots: QTRY_COMPARE(recipients.size(), 1); QCOMPARE(recipients.at(0).toObject().value("label"_L1).toString(), "Alice"_L1); + auto generatedSecret = QString{}; + const auto secretJob = new GenerateSecretJob{account}; + connect(secretJob, &GenerateSecretJob::secretGenerated, this, [&](const QString &secret) { + generatedSecret = secret; + }); + secretJob->start(); + QTRY_COMPARE(generatedSecret, "generated-secret"_L1); + + QPointer fetchedShare; + const auto getShareJob = new GetShareJob{account, "share-1"_L1}; + connect(getShareJob, &GetShareJob::shareFetched, this, [&](QPointer share) { + fetchedShare = share; + }); + getShareJob->start(); + QTRY_VERIFY(fetchedShare); + QCOMPARE(fetchedShare->id(), "share-1"_L1); + + auto fetchedShares = QList>{}; + const auto getSharesJob = new GetSharesJob{account}; + connect(getSharesJob, &GetSharesJob::sharesFetched, this, [&](const QList> &shares) { + fetchedShares = shares; + }); + getSharesJob->start(); + QTRY_COMPARE(fetchedShares.size(), 2); + QCOMPARE(fetchedShares.at(0)->id(), "share-1"_L1); + QCOMPARE(fetchedShares.at(1)->id(), "share-2"_L1); + auto destroyed = false; const auto destroyJob = new DestroyShareJob{account, createdShare->id()}; connect(destroyJob, &DestroyShareJob::jobFinished, this, [&](const QJsonDocument &, int) { @@ -210,6 +387,8 @@ private slots: QTRY_VERIFY(destroyed); delete createdShare; + delete fetchedShare; + qDeleteAll(fetchedShares); } void ocsErrorsAreSeparateFromSuccessfulResults() @@ -276,10 +455,14 @@ private slots: const auto share = Share::fromJson(QJsonDocument::fromJson(R"json({"ocs":{"data":{"id":"share-1","state":"draft"}}})json"), account); const auto jobs = QList{ new AddSourceJob{account, *share, "42"_L1}, + new RemoveSourceJob{account, *share, "42"_L1}, new AddRecipientJob{account, *share, "recipient-class"_L1, "alice"_L1}, new RemoveRecipientJob{account, *share, "recipient-class"_L1, "alice"_L1}, + new SetRecipientSecretJob{account, *share, "recipient-class"_L1, "alice"_L1, "secret"_L1}, + new SetPropertyJob{account, *share, "property-class"_L1, "value"_L1}, new SetPermissionJob{account, *share, "permission-class"_L1, true}, new SetPermissionPresetJob{account, *share, "preset-class"_L1}, + new SetShareStateJob{account, *share, Share::ShareState::Active}, }; for (const auto job : jobs) { From 911c8d821c25922786fbd1a63d319e36109c1deb Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 22:29:40 +0800 Subject: [PATCH 053/104] feat(sharing): Do not auto-create share on share dialog completion Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareDialog.qml | 2 +- src/gui/sharing/sharingcontroller.cpp | 41 +++++++++++++++++++++++++++ src/gui/sharing/sharingcontroller.h | 11 +++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 624cf09374d99..bfe1cf23784e5 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -46,7 +46,7 @@ WizardStyledWindow { } Component.onCompleted: { - sharingController.createShare(root.fileId) + sharingController.initialize(root.fileId) } ColumnLayout { diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index 823a26587b3a6..ac7d1cdbc2bc3 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -12,10 +12,12 @@ #include "addsourcejob.h" #include "createsharejob.h" #include "destroysharejob.h" +#include "getsharesjob.h" #include "removerecipientjob.h" #include "setpermissionjob.h" #include "setpermissionpresetjob.h" #include "share.h" +#include "sharingconstants.h" #include "updatesharejob.h" Q_LOGGING_CATEGORY(lcSharingController, "nextcloud.gui.sharing.sharingcontroller", QtInfoMsg) @@ -50,6 +52,23 @@ Share *SharingController::share() const return _share.get(); } +void SharingController::initialize(const QString &fileId) +{ + if (!_account) { + qCWarning(lcSharingController) << "attempted to initialize sharing without an account set"; + return; + } + + if (fileId.isEmpty()) { + qCWarning(lcSharingController) << "attempted to initialize sharing without a file ID"; + return; + } + + const auto job = new GetSharesJob{_account, SourceTypeClasses::node, fileId}; + connect(job, &GetSharesJob::sharesFetched, this, &SharingController::handleSharesFetched); + job->start(); +} + void SharingController::createShare(const QString &fileId) { if (!_account) { @@ -186,3 +205,25 @@ void SharingController::addSourceAfterCreation(const QString &fileId) }); job->start(); } + +void SharingController::handleSharesFetched(const QList> &shares) +{ + for (const auto &share : _shares) { + if (share) { + share->deleteLater(); + } + } + + _shares = shares; + for (const auto &share : _shares) { + if (share) { + share->setParent(this); + } + } + + if (_share) { + _share = nullptr; + Q_EMIT shareChanged(); + } + Q_EMIT sharesChanged(); +} diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index 2aa06363e3666..01666b4f633d2 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -5,6 +5,7 @@ #pragma once +#include #include #include #include @@ -30,8 +31,15 @@ class SharingController : public QObject [[nodiscard]] AccountPtr account() const; void setAccount(AccountPtr account); + /** @brief Returns the share currently being created or edited. */ [[nodiscard]] Share *share() const; + /** + * @brief Loads all shares associated with a file without creating a share. + * + * @param fileId Server file ID used to filter the shares request + */ + Q_INVOKABLE void initialize(const QString &fileId); Q_INVOKABLE void createShare(const QString &fileId); Q_INVOKABLE void destroyShare(); Q_INVOKABLE void addRecipient(const QString &recipientType, const QString &recipientValue); @@ -43,12 +51,15 @@ class SharingController : public QObject Q_SIGNALS: void accountChanged(); void shareChanged(); + void sharesChanged(); private: AccountPtr _account; QPointer _share; + QList> _shares; void addSourceAfterCreation(const QString &fileId); + void handleSharesFetched(const QList> &shares); }; } From db2d2db62744e6e7985a82c0e1a1b85d7a77a2b1 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 22:49:57 +0800 Subject: [PATCH 054/104] feat(sharing): Unify node class string def Signed-off-by: Claudio Cambra --- src/gui/sharing/CMakeLists.txt | 1 + src/gui/sharing/addsourcejob.cpp | 3 ++- src/gui/sharing/removesourcejob.cpp | 3 ++- src/gui/sharing/sharingconstants.h | 15 +++++++++++++++ 4 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 src/gui/sharing/sharingconstants.h diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index e4d0b0f75aac2..4e2d2bb421649 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -7,6 +7,7 @@ target_sources(nextcloudGuiSharing PRIVATE abstractsharemodel.h abstractsharemodel.cpp + sharingconstants.h unifiedsharingrequest.h unifiedsharingrequest.cpp createsharejob.h diff --git a/src/gui/sharing/addsourcejob.cpp b/src/gui/sharing/addsourcejob.cpp index cb0d4298aad65..13fb80a4d808d 100644 --- a/src/gui/sharing/addsourcejob.cpp +++ b/src/gui/sharing/addsourcejob.cpp @@ -6,6 +6,7 @@ #include "addsourcejob.h" #include "share.h" +#include "sharingconstants.h" using namespace Qt::StringLiterals; @@ -17,7 +18,7 @@ AddSourceJob::AddSourceJob(AccountPtr account, Share &share, const QString &file share, "/ocs/v2.php/apps/sharing/api/v1/share/%1/source"_L1.arg(share.id()), "POST"_ba, - {.body = QJsonObject{{"class"_L1, "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1}, {"value"_L1, fileId}}}} + {.body = QJsonObject{{"class"_L1, SourceTypeClasses::node}, {"value"_L1, fileId}}}} { } diff --git a/src/gui/sharing/removesourcejob.cpp b/src/gui/sharing/removesourcejob.cpp index 4f25d80c03d41..2da8eadea513f 100644 --- a/src/gui/sharing/removesourcejob.cpp +++ b/src/gui/sharing/removesourcejob.cpp @@ -6,6 +6,7 @@ #include "removesourcejob.h" #include "share.h" +#include "sharingconstants.h" using namespace Qt::StringLiterals; @@ -17,7 +18,7 @@ RemoveSourceJob::RemoveSourceJob(AccountPtr account, Share &share, const QString share, "/ocs/v2.php/apps/sharing/api/v1/share/%1/source"_L1.arg(share.id()), "DELETE"_ba, - {.parameters = {{"class"_L1, "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1}, {"value"_L1, fileId}}}} + {.parameters = {{"class"_L1, SourceTypeClasses::node}, {"value"_L1, fileId}}}} { } diff --git a/src/gui/sharing/sharingconstants.h b/src/gui/sharing/sharingconstants.h new file mode 100644 index 0000000000000..8a32b89dfd303 --- /dev/null +++ b/src/gui/sharing/sharingconstants.h @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include + +namespace OCC::Gui::Sharing::SourceTypeClasses +{ + +inline constexpr auto node = QLatin1StringView{"OCA\\Files\\Sharing\\Source\\NodeShareSourceType"}; + +} From c804d1b1a786c8392d1830b39c2998c7ecc4f960 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 23:06:12 +0800 Subject: [PATCH 055/104] feat(sharing): Remove single share value within sharing controller Signed-off-by: Claudio Cambra --- src/gui/sharing/MainPage.qml | 17 ++-- src/gui/sharing/SettingsPage.qml | 3 +- src/gui/sharing/ShareDialog.qml | 8 +- src/gui/sharing/sharingcontroller.cpp | 125 +++++++++++--------------- src/gui/sharing/sharingcontroller.h | 29 +++--- 5 files changed, 88 insertions(+), 94 deletions(-) diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index 12622053e9e30..9e4f775f7b895 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -23,6 +23,7 @@ Page { property string shortLocalPath: "" required property SharingController sharingController property bool isLinkShare: false + readonly property Share share: sharingController.shares.length === 1 ? sharingController.shares[0] : null title: qsTr("Share \"%1\"").arg(root.shortLocalPath) @@ -51,7 +52,9 @@ Page { account: root.sharingController.account onRecipientSelected: (recipientType, recipientValue) => { - root.sharingController.addRecipient(recipientType, recipientValue) + if (root.share) { + root.sharingController.addRecipient(root.share, recipientType, recipientValue) + } } } @@ -73,7 +76,9 @@ Page { return; } - root.sharingController.setPermissionPreset(currentValue) + if (root.share) { + root.sharingController.setPermissionPreset(root.share, currentValue) + } } } @@ -82,7 +87,7 @@ Page { Layout.fillWidth: true model: PermissionModel { - share: root.sharingController.share + share: root.share } delegate: ItemDelegate { @@ -102,7 +107,9 @@ Page { if (model.enabled === checked) { return; } - root.sharingController.setPermission(model.className, checked) + if (root.share) { + root.sharingController.setPermission(root.share, model.className, checked) + } } } } @@ -122,7 +129,7 @@ Page { // } model: PropertyModel { // TODO: only show properties with prio=1 - share: root.sharingController.share + share: root.share } delegate: FieldDelegate { diff --git a/src/gui/sharing/SettingsPage.qml b/src/gui/sharing/SettingsPage.qml index 1d118e7acd307..3df3424e3e79e 100644 --- a/src/gui/sharing/SettingsPage.qml +++ b/src/gui/sharing/SettingsPage.qml @@ -17,6 +17,7 @@ Page { id: root required property SharingController sharingController + property Share share title: qsTr("Sharing settings") @@ -42,7 +43,7 @@ Page { // recipientTypes: root.recipientTypes // } model: PropertyModel { - share: root.sharingController.share + share: root.share } delegate: FieldDelegate { diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index bfe1cf23784e5..ea259d5b77759 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -116,6 +116,7 @@ WizardStyledWindow { onClicked: stack.push(Qt.createComponent("com.nextcloud.desktopclient.sharing", "SettingsPage").createObject(root, { sharingController: sharingController, + share: mainPage.share, })) } } @@ -146,12 +147,15 @@ WizardStyledWindow { ButtonGroup.group: shareType onCheckedChanged: { + if (!mainPage.share) { + return + } const parameters = ["OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType", "somevaluethatislongerthan32characters12345678"] if (checked) { - sharingController.addRecipient(...parameters) + sharingController.addRecipient(mainPage.share, ...parameters) return } - sharingController.removeRecipient(...parameters) + sharingController.removeRecipient(mainPage.share, ...parameters) } } } diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index ac7d1cdbc2bc3..30002ddb1dbbc 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -18,7 +18,6 @@ #include "setpermissionpresetjob.h" #include "share.h" #include "sharingconstants.h" -#include "updatesharejob.h" Q_LOGGING_CATEGORY(lcSharingController, "nextcloud.gui.sharing.sharingcontroller", QtInfoMsg) @@ -32,6 +31,11 @@ SharingController::SharingController(QObject *parent) { } +SharingController::~SharingController() +{ + qDeleteAll(_shares); +} + AccountPtr SharingController::account() const { return _account; @@ -47,9 +51,9 @@ void SharingController::setAccount(AccountPtr account) Q_EMIT accountChanged(); } -Share *SharingController::share() const +const QList &SharingController::shares() const { - return _share.get(); + return _shares; } void SharingController::initialize(const QString &fileId) @@ -65,7 +69,16 @@ void SharingController::initialize(const QString &fileId) } const auto job = new GetSharesJob{_account, SourceTypeClasses::node, fileId}; - connect(job, &GetSharesJob::sharesFetched, this, &SharingController::handleSharesFetched); + connect(job, &GetSharesJob::sharesFetched, this, [this](const QList> &shares) { + auto ownedShares = QList{}; + ownedShares.reserve(shares.size()); + for (const auto &share : shares) { + if (share) { + ownedShares.append(share); + } + } + replaceShares(ownedShares); + }); job->start(); } @@ -83,103 +96,91 @@ void SharingController::createShare(const QString &fileId) return; } - share->setParent(this); - _share = share; - Q_EMIT shareChanged(); - addSourceAfterCreation(fileId); + _shares.append(share); + Q_EMIT sharesChanged(); + addSourceAfterCreation(*share, fileId); }); job->start(); } -void SharingController::destroyShare() +void SharingController::destroyShare(Share *share) { if (!_account) { - qCWarning(lcSharingController) << "attempted to create a new share without an account set"; + qCWarning(lcSharingController) << "attempted to destroy a share without an account set"; return; } - if (!_share) { - qCWarning(lcSharingController) << "attempted to destroty a share without a share"; + if (!containsShare(share)) { + qCWarning(lcSharingController) << "attempted to destroy a share not owned by this controller"; return; } - const auto job = new DestroyShareJob{_account, _share->id()}; - connect(job, &DestroyShareJob::jobFinished, this, [this](const QJsonDocument &, int) { - _share = nullptr; - Q_EMIT shareChanged(); // TODO: shareDeleted maybe? + const auto job = new DestroyShareJob{_account, share->id()}; + connect(job, &DestroyShareJob::jobFinished, this, [this, share](const QJsonDocument &, int) { + _shares.removeAll(share); + share->deleteLater(); + Q_EMIT sharesChanged(); }); job->start(); } -void SharingController::addRecipient(const QString &recipientType, const QString &recipientValue) +void SharingController::addRecipient(Share *share, const QString &recipientType, const QString &recipientValue) { if (!_account) { qCWarning(lcSharingController) << "attempted to add a new recipient to a share without an account set"; return; } - if (!_share) { - qCWarning(lcSharingController) << "attempted to add a new recipient without a share"; + if (!containsShare(share)) { + qCWarning(lcSharingController) << "attempted to add a recipient to a share not owned by this controller"; return; } - const auto job = new AddRecipientJob{_account, *_share, recipientType, recipientValue}; - connect(job, &UpdateShareJob::shareUpdated, this, [this](QPointer) { - qCDebug(lcSharingController).nospace() << "recipient added" - << " id=" << _share->id(); - }); + const auto job = new AddRecipientJob{_account, *share, recipientType, recipientValue}; job->start(); } -void SharingController::removeRecipient(const QString &recipientType, const QString &recipientValue) +void SharingController::removeRecipient(Share *share, const QString &recipientType, const QString &recipientValue) { if (!_account) { qCWarning(lcSharingController) << "attempted to remove a new recipient to a share without an account set"; return; } - if (!_share) { - qCWarning(lcSharingController) << "attempted to remove a new recipient without a share"; + if (!containsShare(share)) { + qCWarning(lcSharingController) << "attempted to remove a recipient from a share not owned by this controller"; return; } - const auto job = new RemoveRecipientJob{_account, *_share, recipientType, recipientValue}; - connect(job, &UpdateShareJob::shareUpdated, this, [this](QPointer) { - qCDebug(lcSharingController).nospace() << "recipient removed" - << " id=" << _share->id(); - }); + const auto job = new RemoveRecipientJob{_account, *share, recipientType, recipientValue}; job->start(); } -void SharingController::setPermission(const QString &permissionClass, bool enabled) +void SharingController::setPermission(Share *share, const QString &permissionClass, bool enabled) { if (!_account) { qCWarning(lcSharingController) << "attempted to set permission without an account set"; return; } - if (!_share) { - qCWarning(lcSharingController) << "attempted to set permission without a share"; + if (!containsShare(share)) { + qCWarning(lcSharingController) << "attempted to set permission on a share not owned by this controller"; return; } - const auto job = new SetPermissionJob{_account, *_share, permissionClass, enabled}; - connect(job, &UpdateShareJob::shareUpdated, this, [this](QPointer) { - qCDebug(lcSharingController).nospace() << "permissions updated" - << " id=" << _share->id(); - }); + const auto job = new SetPermissionJob{_account, *share, permissionClass, enabled}; job->start(); } -void SharingController::setPermissionPreset(const QString &permissionPreset) +void SharingController::setPermissionPreset(Share *share, const QString &permissionPreset) { if (!_account) { qCWarning(lcSharingController) << "attempted to set permission preset without an account set"; return; } - if (!_share) { - qCWarning(lcSharingController) << "attempted to set permission preset without a share"; + if (!containsShare(share)) { + qCWarning(lcSharingController) << "attempted to set a permission preset on a share not owned by this controller"; return; } @@ -188,42 +189,24 @@ void SharingController::setPermissionPreset(const QString &permissionPreset) return; } - const auto job = new SetPermissionPresetJob{_account, *_share, permissionPreset}; - connect(job, &UpdateShareJob::shareUpdated, this, [this](QPointer) { - qCDebug(lcSharingController).nospace() << "permissions updated" - << " id=" << _share->id(); - }); + const auto job = new SetPermissionPresetJob{_account, *share, permissionPreset}; job->start(); } -void SharingController::addSourceAfterCreation(const QString &fileId) +bool SharingController::containsShare(const Share *share) const { - const auto job = new AddSourceJob{_account, *_share, fileId}; - connect(job, &UpdateShareJob::shareUpdated, this, [this, fileId](QPointer) { - qCDebug(lcSharingController).nospace() << "share created" - << " id=" << _share->id() << " fileId=" << fileId; - }); - job->start(); + return share && _shares.contains(share); } -void SharingController::handleSharesFetched(const QList> &shares) +void SharingController::addSourceAfterCreation(Share &share, const QString &fileId) { - for (const auto &share : _shares) { - if (share) { - share->deleteLater(); - } - } + const auto job = new AddSourceJob{_account, share, fileId}; + job->start(); +} +void SharingController::replaceShares(const QList &shares) +{ + qDeleteAll(_shares); _shares = shares; - for (const auto &share : _shares) { - if (share) { - share->setParent(this); - } - } - - if (_share) { - _share = nullptr; - Q_EMIT shareChanged(); - } Q_EMIT sharesChanged(); } diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index 01666b4f633d2..bdaf65798fe37 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -7,7 +7,6 @@ #include #include -#include #include #include "accountfwd.h" @@ -23,43 +22,43 @@ class SharingController : public QObject QML_ELEMENT Q_PROPERTY(AccountPtr account READ account WRITE setAccount NOTIFY accountChanged) - Q_PROPERTY(Share *share READ share NOTIFY shareChanged) + Q_PROPERTY(QList shares READ shares NOTIFY sharesChanged) public: SharingController(QObject *parent = nullptr); + ~SharingController() override; [[nodiscard]] AccountPtr account() const; void setAccount(AccountPtr account); - /** @brief Returns the share currently being created or edited. */ - [[nodiscard]] Share *share() const; + /** @brief Returns all shares associated with the initialized file. */ + [[nodiscard]] const QList &shares() const; /** * @brief Loads all shares associated with a file without creating a share. * * @param fileId Server file ID used to filter the shares request - */ + */ Q_INVOKABLE void initialize(const QString &fileId); Q_INVOKABLE void createShare(const QString &fileId); - Q_INVOKABLE void destroyShare(); - Q_INVOKABLE void addRecipient(const QString &recipientType, const QString &recipientValue); - Q_INVOKABLE void removeRecipient(const QString &recipientType, const QString &recipientValue); + Q_INVOKABLE void destroyShare(Share *share); + Q_INVOKABLE void addRecipient(Share *share, const QString &recipientType, const QString &recipientValue); + Q_INVOKABLE void removeRecipient(Share *share, const QString &recipientType, const QString &recipientValue); - Q_INVOKABLE void setPermission(const QString &permissionClass, bool enabled); - Q_INVOKABLE void setPermissionPreset(const QString &permissionPreset); + Q_INVOKABLE void setPermission(Share *share, const QString &permissionClass, bool enabled); + Q_INVOKABLE void setPermissionPreset(Share *share, const QString &permissionPreset); Q_SIGNALS: void accountChanged(); - void shareChanged(); void sharesChanged(); private: AccountPtr _account; - QPointer _share; - QList> _shares; + QList _shares; - void addSourceAfterCreation(const QString &fileId); - void handleSharesFetched(const QList> &shares); + [[nodiscard]] bool containsShare(const Share *share) const; + void addSourceAfterCreation(Share &share, const QString &fileId); + void replaceShares(const QList &shares); }; } From b794e80c50a007d5597dccca864e115c0e829e83 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 23:07:51 +0800 Subject: [PATCH 056/104] feat(sharing): Verify sharing controller initialises without creating share Signed-off-by: Claudio Cambra --- test/testunifiedsharing.cpp | 49 +++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/testunifiedsharing.cpp b/test/testunifiedsharing.cpp index 211349c233262..40d88e0801dfc 100644 --- a/test/testunifiedsharing.cpp +++ b/test/testunifiedsharing.cpp @@ -252,6 +252,55 @@ private slots: QCOMPARE(requestCount, 1); } + void sharingControllerLoadsAllSharesWithoutCreatingOne() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto requestCount = 0; + auto requestVerb = QByteArray{}; + auto requestQuery = QList>{}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + ++requestCount; + requestVerb = request.attribute(QNetworkRequest::CustomVerbAttribute).toByteArray(); + requestQuery = QUrlQuery{request.url()}.queryItems(); + requestQuery.removeAll({"format"_L1, "json"_L1}); + return new FakePayloadReply{operation, + request, + R"json({ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": [ + {"id": "share-1", "state": "active"}, + {"id": "share-2", "state": "draft"} + ] + } + })json", + this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + QSignalSpy sharesChangedSpy{&controller, &SharingController::sharesChanged}; + + controller.initialize("42"_L1); + + QTRY_COMPARE(sharesChangedSpy.size(), 1); + QCOMPARE(requestCount, 1); + QCOMPARE(requestVerb, "GET"); + QCOMPARE(requestQuery, + (QList>{ + {"limit"_L1, "100"_L1}, + {"filterSourceTypeClass"_L1, SourceTypeClasses::node}, + {"filterSourceTypeValue"_L1, "42"_L1}, + })); + QCOMPARE(controller.shares().size(), 2); + QCOMPARE(controller.shares().at(0)->id(), "share-1"_L1); + QCOMPARE(controller.shares().at(1)->id(), "share-2"_L1); + } + void operationSpecificStatusCodesAreEnforced() { FakeFolder fakeFolder{{}, {}, {}, false}; From d80390ad1a6aa8421fa1ad196db7a1caa45054f1 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 28 Jul 2026 23:08:12 +0800 Subject: [PATCH 057/104] feat(sharing): Use constants in unified sharing test Signed-off-by: Claudio Cambra --- test/testunifiedsharing.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/testunifiedsharing.cpp b/test/testunifiedsharing.cpp index 40d88e0801dfc..87b85241ef841 100644 --- a/test/testunifiedsharing.cpp +++ b/test/testunifiedsharing.cpp @@ -20,6 +20,8 @@ #include "gui/sharing/setrecipientsecretjob.h" #include "gui/sharing/setsharestatejob.h" #include "gui/sharing/share.h" +#include "gui/sharing/sharingconstants.h" +#include "gui/sharing/sharingcontroller.h" #include "gui/sharing/unifiedsharingrequest.h" #include "gui/sharing/updatesharejob.h" #include "syncenginetestutils.h" @@ -148,11 +150,11 @@ private slots: "POST", "/ocs/v2.php/apps/sharing/api/v1/share/share-1/source", {}, - {{"class"_L1, "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1}, {"value"_L1, "42"_L1}}); + {{"class"_L1, SourceTypeClasses::node}, {"value"_L1, "42"_L1}}); verifyRequest(new RemoveSourceJob{account, *share, "42"_L1}, "DELETE", "/ocs/v2.php/apps/sharing/api/v1/share/share-1/source", - {{"class"_L1, "OCA\\Files\\Sharing\\Source\\NodeShareSourceType"_L1}, {"value"_L1, "42"_L1}}); + {{"class"_L1, SourceTypeClasses::node}, {"value"_L1, "42"_L1}}); verifyRequest(new AddRecipientJob{account, *share, "recipient-class"_L1, "alice"_L1, "https://example.com"_L1}, "POST", "/ocs/v2.php/apps/sharing/api/v1/share/share-1/recipient", @@ -204,7 +206,6 @@ private slots: verifyRequest(new GetShareJob{account, "share-1"_L1}, "POST", "/ocs/v2.php/apps/sharing/api/v1/share/share-1"); - QCOMPARE(requestContentType, "application/x-www-form-urlencoded"); verifyRequest(new UnifiedSharingRequest{account, "/ocs/v2.php/apps/sharing/api/v1/share/share-1"_L1, "POST"_ba, From 54645cf3a51ab1ec6bd72b9f53a92f8111fc406a Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 29 Jul 2026 15:14:02 +0800 Subject: [PATCH 058/104] feat(sharing): Re-layout sharing window to master/detail view Signed-off-by: Claudio Cambra --- src/gui/sharing/CMakeLists.txt | 2 + src/gui/sharing/MainPage.qml | 130 +++++++-------------------- src/gui/sharing/ShareDetailsPage.qml | 125 ++++++++++++++++++++++++++ src/gui/sharing/ShareDialog.qml | 54 ++--------- src/gui/sharing/ShareEntry.qml | 58 ++++++++++++ 5 files changed, 224 insertions(+), 145 deletions(-) create mode 100644 src/gui/sharing/ShareDetailsPage.qml create mode 100644 src/gui/sharing/ShareEntry.qml diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index 4e2d2bb421649..84338ba983361 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -69,7 +69,9 @@ ecm_add_qml_module(nextcloudGuiSharing FieldDelegate.qml MainPage.qml RecipientSearchField.qml + ShareDetailsPage.qml SettingsPage.qml + ShareEntry.qml ShareDialog.qml ) diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index 9e4f775f7b895..6c6f0fbc72317 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -6,144 +6,76 @@ pragma ComponentBehavior: Bound import QtQuick -import QtQuick.Window import QtQuick.Layouts import QtQuick.Controls import com.nextcloud.desktopclient import Style -// import "qrc:/qml/src/gui" -// import "qrc:/qml/src/gui/tray" -import "qrc:/qml/src/gui/wizard/qml" Page { id: root property string localPath: "" property string shortLocalPath: "" + property string fileId: "" required property SharingController sharingController - property bool isLinkShare: false - readonly property Share share: sharingController.shares.length === 1 ? sharingController.shares[0] : null + + signal shareSelected(Share share) title: qsTr("Share \"%1\"").arg(root.shortLocalPath) ColumnLayout { - id: windowContent anchors.fill: parent - ScrollView { - Layout.fillHeight: true - Layout.fillWidth: true - - ScrollBar.vertical.policy: propertyList.contentHeight > propertyList.height ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff - contentWidth: availableWidth - contentHeight: availableHeight - rightPadding: ScrollBar.vertical.policy == ScrollBar.AlwaysOn ? ScrollBar.vertical.width + Style.standardSpacing : 0 - - ColumnLayout { - width: parent.width - - RecipientSearchField { - id: searchField + Label { Layout.fillWidth: true - visible: !root.isLinkShare - - account: root.sharingController.account - - onRecipientSelected: (recipientType, recipientValue) => { - if (root.share) { - root.sharingController.addRecipient(root.share, recipientType, recipientValue) - } - } + text: qsTr("Shares") + font.bold: true } - WizardComboBox { - id: permissionPresetSelector + Label { Layout.fillWidth: true + Layout.fillHeight: true - model: [ - { preset: "view", text: qsTr("Can view") }, - { preset: "edit", text: qsTr("Can edit") }, - { preset: null, text: qsTr("Can…") }, - ] - textRole: "text" - valueRole: "preset" - - // value: root.sharingController.share.permissionPreset - onCurrentValueChanged: { - if (!currentValue) { - return; - } - - if (root.share) { - root.sharingController.setPermissionPreset(root.share, currentValue) - } - } - } + text: qsTr("This item has not been shared yet.") + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + wrapMode: Text.Wrap - Repeater { - id: permissionsList - Layout.fillWidth: true - - model: PermissionModel { - share: root.share + visible: root.sharingController.shares.length === 0 } - delegate: ItemDelegate { - // Layout.fillWidth: true - visible: !permissionPresetSelector.currentValue + ListView { + id: sharesList - required property var model - RowLayout { Layout.fillWidth: true - Label { - text: model.label - Layout.fillWidth: true - } - Switch { - checked: model.enabled - onCheckedChanged: { - if (model.enabled === checked) { - return; - } - if (root.share) { - root.sharingController.setPermission(root.share, model.className, checked) - } - } - } - } - } - } - - } + Layout.fillHeight: true - ListView { - id: propertyList clip: true + spacing: Style.smallSpacing + model: root.sharingController.shares + visible: count > 0 - // model: SharingFilterModel { - // filterType: SharingFilterModel.General - // sourceModel: root.sharingModel - // recipientTypes: root.recipientTypes - // } - model: PropertyModel { - // TODO: only show properties with prio=1 - share: root.share - } + delegate: ShareEntry { + required property Share modelData - delegate: FieldDelegate { - width: propertyList.contentItem.width - } + width: sharesList.width + share: modelData + + onClicked: root.shareSelected(share) } } - RowLayout { - Button { + Label { Layout.fillWidth: true - text: root.isLinkShare ? qsTr("Copy public link") : qsTr("Copy private link") + text: root.sharingController.shareCreationError + color: Style.wizardErrorText + wrapMode: Text.Wrap + visible: text.length > 0 } + Button { Layout.fillWidth: true diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml new file mode 100644 index 0000000000000..125c9e4c46226 --- /dev/null +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +import com.nextcloud.desktopclient +import Style +import "qrc:/qml/src/gui/wizard/qml" + +Page { + id: root + + required property SharingController sharingController + required property Share share + + title: qsTr("Share details") + + ColumnLayout { + anchors.fill: parent + + ScrollView { + Layout.fillHeight: true + Layout.fillWidth: true + + ScrollBar.vertical.policy: propertyList.contentHeight > propertyList.height ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff + contentWidth: availableWidth + contentHeight: availableHeight + rightPadding: ScrollBar.vertical.policy == ScrollBar.AlwaysOn ? ScrollBar.vertical.width + Style.standardSpacing : 0 + + ColumnLayout { + width: parent.width + + RecipientSearchField { + Layout.fillWidth: true + + account: root.sharingController.account + + onRecipientSelected: (recipientType, recipientValue) => { + root.sharingController.addRecipient(root.share, recipientType, recipientValue) + } + } + + WizardComboBox { + id: permissionPresetSelector + Layout.fillWidth: true + + model: [ + { preset: "view", text: qsTr("Can view") }, + { preset: "edit", text: qsTr("Can edit") }, + { preset: null, text: qsTr("Can…") }, + ] + textRole: "text" + valueRole: "preset" + + // value: root.share.permissionPreset + onCurrentValueChanged: { + if (currentValue) { + root.sharingController.setPermissionPreset(root.share, currentValue) + } + } + } + + Repeater { + Layout.fillWidth: true + + model: PermissionModel { + share: root.share + } + + delegate: ItemDelegate { + visible: !permissionPresetSelector.currentValue + + required property var model + RowLayout { + Layout.fillWidth: true + Label { + text: model.label + Layout.fillWidth: true + } + Switch { + checked: model.enabled + onCheckedChanged: { + if (model.enabled !== checked) { + root.sharingController.setPermission(root.share, model.className, checked) + } + } + } + } + } + } + } + + ListView { + id: propertyList + clip: true + + model: PropertyModel { + // TODO: only show properties with prio=1 + share: root.share + } + + delegate: FieldDelegate { + width: propertyList.contentItem.width + } + } + } + + RowLayout { + Button { + Layout.fillWidth: true + text: qsTr("Copy link") + } + Button { + Layout.fillWidth: true + text: qsTr("Send") + } + } + } +} diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index ea259d5b77759..1e1a8fb593910 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -31,10 +31,6 @@ WizardStyledWindow { minimumWidth: Style.wizardStandaloneWindowMinimumWidth minimumHeight: Style.wizardStandaloneWindowMinimumHeight - ButtonGroup { - id: shareType - } - SharingController { id: sharingController account: root.account @@ -112,54 +108,15 @@ WizardStyledWindow { id: settingsButton iconSource: "image://svgimage-custom-color/settings.svg/" + palette.windowText - visible: stack.depth < 2 + visible: stack.depth === 2 && stack.currentItem && stack.currentItem.share onClicked: stack.push(Qt.createComponent("com.nextcloud.desktopclient.sharing", "SettingsPage").createObject(root, { sharingController: sharingController, - share: mainPage.share, + share: stack.currentItem.share, })) } } - RowLayout { - Layout.fillWidth: true - - WizardButton { - Layout.fillWidth: true - - id: viewInvitedPeople - text: qsTr("Invited people") - - checkable: true - checked: true - primary: checked // TODO: extend WizardButton for checked state - ButtonGroup.group: shareType - } - - WizardButton { - Layout.fillWidth: true - - id: viewAnyone - text: qsTr("Anyone") - - checkable: true - primary: checked // TODO: extend WizardButton for checked state - ButtonGroup.group: shareType - - onCheckedChanged: { - if (!mainPage.share) { - return - } - const parameters = ["OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType", "somevaluethatislongerthan32characters12345678"] - if (checked) { - sharingController.addRecipient(mainPage.share, ...parameters) - return - } - sharingController.removeRecipient(mainPage.share, ...parameters) - } - } - } - StackView { Layout.fillWidth: true Layout.fillHeight: true @@ -175,6 +132,11 @@ WizardStyledWindow { sharingController: sharingController localPath: root.localPath shortLocalPath: root.shortLocalPath - isLinkShare: viewAnyone.checked + fileId: root.fileId + + onShareSelected: share => stack.push(Qt.createComponent("com.nextcloud.desktopclient.sharing", "ShareDetailsPage").createObject(root, { + sharingController: sharingController, + share: share, + })) } } diff --git a/src/gui/sharing/ShareEntry.qml b/src/gui/sharing/ShareEntry.qml new file mode 100644 index 0000000000000..925bd9c0070b9 --- /dev/null +++ b/src/gui/sharing/ShareEntry.qml @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +import com.nextcloud.desktopclient +import Style + +ItemDelegate { + id: root + + required property Share share + + function recipientNames(): string { + const names = [] + for (const recipient of root.share.recipients) { + if (recipient.displayName) { + names.push(recipient.displayName) + } + } + return names.length > 0 ? names.join(", ") : qsTr("No recipients") + } + + contentItem: RowLayout { + ColumnLayout { + Layout.fillWidth: true + spacing: Style.extraSmallSpacing + + Label { + Layout.fillWidth: true + + text: root.recipientNames() + elide: Text.ElideRight + } + + Label { + Layout.fillWidth: true + + text: root.share.permissionPreset + color: palette.placeholderText + elide: Text.ElideRight + visible: text.length > 0 + } + } + + Label { + text: "›" + font.pixelSize: Style.largeIconSize + color: palette.placeholderText + } + } +} From 75f87f494c4a7720d85b41ee9b0645f92cd24668 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 13:12:43 +0800 Subject: [PATCH 059/104] fix(sharing): Ensure correct fileId is passed to new sharing dialog Signed-off-by: Claudio Cambra --- src/gui/systray.cpp | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index d3a27905e42a5..a0618bef58b77 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -4,22 +4,23 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ +#include "systray.h" +#include "accessmanager.h" #include "accountmanager.h" #include "accountstate.h" #include "activity/syncstatussummary.h" -#include "systray.h" -#include "theme.h" -#include "config.h" +#include "callstatechecker.h" +#include "common/syncjournalfilerecord.h" #include "common/utility.h" -#include "tray/svgimageprovider.h" +#include "config.h" +#include "configfile.h" +#include "guiutility.h" #include "search/unifiedsearchresultslistmodel.h" +#include "theme.h" +#include "tray/svgimageprovider.h" +#include "tray/trayimageprovider.h" #include "tray/usermodel.h" #include "wheelhandler.h" -#include "tray/trayimageprovider.h" -#include "configfile.h" -#include "accessmanager.h" -#include "callstatechecker.h" -#include "guiutility.h" #ifdef Q_OS_MACOS #include "foregroundbackground_interface.h" @@ -862,6 +863,15 @@ void Systray::createFileDetailsDialog(const QString &localPath, const QString &f return; } + auto resolvedFileId = fileId; + if (resolvedFileId.isEmpty()) { + const auto relativePath = localPath.mid(folder->cleanPath().length() + 1); + auto fileRecord = SyncJournalFileRecord{}; + if (folder->journalDb()->getFileRecord(relativePath, &fileRecord)) { + resolvedFileId = QString::fromUtf8(fileRecord._fileId); + } + } + if (folder->accountState()->account()->capabilities().unifiedSharingAvailable()) { // we have a server with the new unified sharing system, let's show the new fancy one // TODO: reduce code duplication @@ -869,7 +879,7 @@ void Systray::createFileDetailsDialog(const QString &localPath, const QString &f const QVariantMap initialProperties{ {"account", QVariant::fromValue(folder->accountState()->account())}, {"localPath", localPath}, - {"fileId", fileId}, + {"fileId", resolvedFileId}, }; QQmlComponent fileDetailsDialog(trayEngine(), "com.nextcloud.desktopclient.sharing"_L1, "ShareDialog"_L1); From 605a3b6eecaffcafa2a0882fef62bd6e5bf147fc Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 13:14:34 +0800 Subject: [PATCH 060/104] feat(sharing): Add ability to create share Signed-off-by: Claudio Cambra --- src/gui/sharing/MainPage.qml | 28 +++---- src/gui/sharing/sharingcontroller.cpp | 105 ++++++++++++++++++++++++-- src/gui/sharing/sharingcontroller.h | 32 +++++++- 3 files changed, 144 insertions(+), 21 deletions(-) diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index 6c6f0fbc72317..756e711dcc5b5 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -28,14 +28,14 @@ Page { anchors.fill: parent Label { - Layout.fillWidth: true + Layout.fillWidth: true text: qsTr("Shares") font.bold: true - } + } Label { - Layout.fillWidth: true + Layout.fillWidth: true Layout.fillHeight: true text: qsTr("This item has not been shared yet.") @@ -44,15 +44,15 @@ Page { wrapMode: Text.Wrap visible: root.sharingController.shares.length === 0 - } + } ListView { id: sharesList - Layout.fillWidth: true + Layout.fillWidth: true Layout.fillHeight: true - clip: true + clip: true spacing: Style.smallSpacing model: root.sharingController.shares visible: count > 0 @@ -68,21 +68,21 @@ Page { } Label { - Layout.fillWidth: true + Layout.fillWidth: true text: root.sharingController.shareCreationError color: Style.wizardErrorText wrapMode: Text.Wrap visible: text.length > 0 - } + } - Button { - Layout.fillWidth: true + Button { + Layout.fillWidth: true - text: qsTr("Send") - visible: !root.isLinkShare - enabled: !root.isLinkShare - } + text: root.sharingController.creatingShare ? qsTr("Creating share…") : qsTr("Create share") + enabled: !root.sharingController.creatingShare && root.fileId.length > 0 + + onClicked: root.sharingController.createShare(root.fileId) } } } diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index 30002ddb1dbbc..36a659100f2ec 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -7,6 +7,7 @@ #include #include +#include #include "addrecipientjob.h" #include "addsourcejob.h" @@ -56,6 +57,16 @@ const QList &SharingController::shares() const return _shares; } +bool SharingController::creatingShare() const +{ + return _creatingShare; +} + +QString SharingController::shareCreationError() const +{ + return _shareCreationError; +} + void SharingController::initialize(const QString &fileId) { if (!_account) { @@ -89,16 +100,35 @@ void SharingController::createShare(const QString &fileId) return; } + if (fileId.isEmpty()) { + qCWarning(lcSharingController) << "attempted to create a new share without a file ID"; + return; + } + + if (_creatingShare) { + qCDebug(lcSharingController) << "ignoring attempt to create a share while another creation is in progress"; + return; + } + + setShareCreationError({}); + setCreatingShare(true); + const auto job = new CreateShareJob{_account}; connect(job, &CreateShareJob::shareCreated, this, [this, fileId](QPointer share) -> void { - if (!share) { + if (!share || share->id().isEmpty()) { qCWarning(lcSharingController) << "share created without a valid Share object"; + failShareCreation(tr("The server returned an invalid share."), share); return; } - _shares.append(share); - Q_EMIT sharesChanged(); - addSourceAfterCreation(*share, fileId); + share->setParent(this); + addSourceAfterCreation(share, fileId); + }); + connect(job, &CreateShareJob::ocsError, this, [this](int, const QString &message) { + failShareCreation(message.isEmpty() ? tr("Could not create the share.") : message); + }); + connect(job, &CreateShareJob::networkError, this, [this](const QNetworkReply *reply) { + failShareCreation(reply ? reply->errorString() : tr("Could not create the share.")); }); job->start(); } @@ -198,12 +228,75 @@ bool SharingController::containsShare(const Share *share) const return share && _shares.contains(share); } -void SharingController::addSourceAfterCreation(Share &share, const QString &fileId) +void SharingController::addSourceAfterCreation(QPointer share, const QString &fileId) { - const auto job = new AddSourceJob{_account, share, fileId}; + if (!share) { + failShareCreation(tr("The newly created share is no longer available.")); + return; + } + + const auto job = new AddSourceJob{_account, *share, fileId}; + connect(job, &AddSourceJob::shareUpdated, this, [this](QPointer updatedShare) { + if (!updatedShare) { + failShareCreation(tr("The newly created share is no longer available.")); + return; + } + finishShareCreation(updatedShare); + }); + connect(job, &AddSourceJob::ocsError, this, [this, share](int, const QString &message) { + failShareCreation(message.isEmpty() ? tr("Could not attach the item to the share.") : message, share); + }); + connect(job, &AddSourceJob::networkError, this, [this, share](const QNetworkReply *reply) { + failShareCreation(reply ? reply->errorString() : tr("Could not attach the item to the share."), share); + }); job->start(); } +void SharingController::finishShareCreation(QPointer share) +{ + _shares.append(share); + setCreatingShare(false); + Q_EMIT sharesChanged(); +} + +void SharingController::failShareCreation(const QString &error, QPointer share) +{ + if (!_creatingShare) { + return; + } + + setShareCreationError(error); + setCreatingShare(false); + + if (!share) { + return; + } + + if (!share->id().isEmpty()) { + const auto cleanupJob = new DestroyShareJob{_account, share->id()}; + cleanupJob->start(); + } + delete share.data(); +} + +void SharingController::setCreatingShare(bool creatingShare) +{ + if (_creatingShare == creatingShare) { + return; + } + _creatingShare = creatingShare; + Q_EMIT creatingShareChanged(); +} + +void SharingController::setShareCreationError(const QString &error) +{ + if (_shareCreationError == error) { + return; + } + _shareCreationError = error; + Q_EMIT shareCreationErrorChanged(); +} + void SharingController::replaceShares(const QList &shares) { qDeleteAll(_shares); diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index bdaf65798fe37..643ddf313bf0a 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -7,6 +7,7 @@ #include #include +#include #include #include "accountfwd.h" @@ -23,6 +24,8 @@ class SharingController : public QObject Q_PROPERTY(AccountPtr account READ account WRITE setAccount NOTIFY accountChanged) Q_PROPERTY(QList shares READ shares NOTIFY sharesChanged) + Q_PROPERTY(bool creatingShare READ creatingShare NOTIFY creatingShareChanged) + Q_PROPERTY(QString shareCreationError READ shareCreationError NOTIFY shareCreationErrorChanged) public: SharingController(QObject *parent = nullptr); @@ -34,12 +37,27 @@ class SharingController : public QObject /** @brief Returns all shares associated with the initialized file. */ [[nodiscard]] const QList &shares() const; + /** @brief Returns whether a share and its source are currently being created. */ + [[nodiscard]] bool creatingShare() const; + + /** @brief Returns the last share creation error, or an empty string after a new attempt starts. */ + [[nodiscard]] QString shareCreationError() const; + /** * @brief Loads all shares associated with a file without creating a share. * * @param fileId Server file ID used to filter the shares request */ Q_INVOKABLE void initialize(const QString &fileId); + + /** + * @brief Creates a draft share and attaches the specified file as its source. + * + * The share is added to shares only after both requests succeed. A second + * call while creation is in progress is ignored. + * + * @param fileId Server file ID to attach to the new share + */ Q_INVOKABLE void createShare(const QString &fileId); Q_INVOKABLE void destroyShare(Share *share); Q_INVOKABLE void addRecipient(Share *share, const QString &recipientType, const QString &recipientValue); @@ -52,12 +70,24 @@ class SharingController : public QObject void accountChanged(); void sharesChanged(); + /** @brief Emitted when creatingShare changes. */ + void creatingShareChanged(); + + /** @brief Emitted when shareCreationError changes. */ + void shareCreationErrorChanged(); + private: AccountPtr _account; QList _shares; + bool _creatingShare = false; + QString _shareCreationError; [[nodiscard]] bool containsShare(const Share *share) const; - void addSourceAfterCreation(Share &share, const QString &fileId); + void addSourceAfterCreation(QPointer share, const QString &fileId); + void finishShareCreation(QPointer share); + void failShareCreation(const QString &error, QPointer share = {}); + void setCreatingShare(bool creatingShare); + void setShareCreationError(const QString &error); void replaceShares(const QList &shares); }; From 7abc3556e385fc5bc847e0a7926f22d806ae20e1 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 13:25:21 +0800 Subject: [PATCH 061/104] fix(activity): Remove debugging labels Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareEntry.qml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/gui/sharing/ShareEntry.qml b/src/gui/sharing/ShareEntry.qml index 925bd9c0070b9..747827d4fee54 100644 --- a/src/gui/sharing/ShareEntry.qml +++ b/src/gui/sharing/ShareEntry.qml @@ -30,7 +30,6 @@ ItemDelegate { contentItem: RowLayout { ColumnLayout { Layout.fillWidth: true - spacing: Style.extraSmallSpacing Label { Layout.fillWidth: true @@ -38,15 +37,6 @@ ItemDelegate { text: root.recipientNames() elide: Text.ElideRight } - - Label { - Layout.fillWidth: true - - text: root.share.permissionPreset - color: palette.placeholderText - elide: Text.ElideRight - visible: text.length > 0 - } } Label { From 1b08567d529154519d91370ab7d346aab51a68cc Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 13:33:28 +0800 Subject: [PATCH 062/104] feat(activity): Add back button svg Signed-off-by: Claudio Cambra --- REUSE.toml | 6 ++++++ theme.qrc.in | 1 + theme/back.svg | 1 + 3 files changed, 8 insertions(+) create mode 100644 theme/back.svg diff --git a/REUSE.toml b/REUSE.toml index 016babf5c6f1c..d80e257720912 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -125,6 +125,12 @@ precedence = "aggregate" SPDX-FileCopyrightText = "2018-2025 Google LLC" SPDX-License-Identifier = "Apache-2.0" +[[annotations]] +path = ["theme/back.svg"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2018-2025 Google LLC" +SPDX-License-Identifier = "Apache-2.0" + [[annotations]] path = ["theme/security.svg", "theme/file-clock-outline.svg"] precedence = "aggregate" diff --git a/theme.qrc.in b/theme.qrc.in index 501cae84d3d83..5403a564a4430 100644 --- a/theme.qrc.in +++ b/theme.qrc.in @@ -275,6 +275,7 @@ theme/public.svg theme/settings.svg theme/advanced.svg + theme/back.svg theme/confirm.svg theme/copy.svg theme/more.svg diff --git a/theme/back.svg b/theme/back.svg new file mode 100644 index 0000000000000..20d4317bcf976 --- /dev/null +++ b/theme/back.svg @@ -0,0 +1 @@ + From c95d8477214a76d15c73fe46b1c0e53a1ddbaf65 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 13:33:39 +0800 Subject: [PATCH 063/104] fix(activity): Use back button icon Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareDialog.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 1e1a8fb593910..feb9b74867afe 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -62,7 +62,7 @@ WizardStyledWindow { flat: true padding: Style.extraSmallSpacing spacing: 0 - icon.source: "image://svgimage-custom-color/confirm.svg/" + palette.windowText // TODO: back button icon! + icon.source: "image://svgimage-custom-color/back.svg/" + palette.windowText icon.width: Style.extraSmallIconSize icon.height: Style.extraSmallIconSize Layout.alignment: Qt.AlignTop | Qt.AlignRight From f14bcb2e7e9ceedd8ce6a49c57e2e67474fbf57f Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 13:38:10 +0800 Subject: [PATCH 064/104] feat(activity): Implement master/detail design Signed-off-by: Claudio Cambra --- src/gui/sharing/MainPage.qml | 5 + src/gui/sharing/ShareDialog.qml | 199 ++++++++++++++++++++------------ src/gui/sharing/ShareEntry.qml | 16 ++- 3 files changed, 139 insertions(+), 81 deletions(-) diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index 756e711dcc5b5..16c414b8aad2d 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -18,11 +18,15 @@ Page { property string localPath: "" property string shortLocalPath: "" property string fileId: "" + property Share selectedShare: null required property SharingController sharingController signal shareSelected(Share share) title: qsTr("Share \"%1\"").arg(root.shortLocalPath) + background: Rectangle { + color: palette.alternateBase + } ColumnLayout { anchors.fill: parent @@ -62,6 +66,7 @@ Page { width: sharesList.width share: modelData + selected: share === root.selectedShare onClicked: root.shareSelected(share) } diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index feb9b74867afe..1687c8db2b341 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -17,32 +17,52 @@ import "qrc:/qml/src/gui/tray" import "qrc:/qml/src/gui/wizard/qml" WizardStyledWindow { - id: root + id: dialog visible: true property var account property string localPath: "" - property string shortLocalPath: root.localPath.split("/").reverse()[0] + property string shortLocalPath: dialog.localPath.split("/").reverse()[0] property string fileId: "" + property Share selectedShare: null + property bool showingSettings: false - title: mainPage.title - width: 400 + title: qsTr("Share \"%1\"").arg(dialog.shortLocalPath) + width: 720 height: 500 - minimumWidth: Style.wizardStandaloneWindowMinimumWidth + minimumWidth: 600 minimumHeight: Style.wizardStandaloneWindowMinimumHeight + function reconcileSelectedShare() { + const shares = sharingController.shares + if (dialog.selectedShare && shares.indexOf(dialog.selectedShare) !== -1) { + return + } + + dialog.selectedShare = shares.length > 0 ? shares[0] : null + dialog.showingSettings = false + } + SharingController { id: sharingController - account: root.account + account: dialog.account } Shortcut { sequences: [StandardKey.Cancel] - onActivated: root.close() + onActivated: dialog.close() } Component.onCompleted: { - sharingController.initialize(root.fileId) + sharingController.initialize(dialog.fileId) + } + + Connections { + target: sharingController + + function onSharesChanged() { + dialog.reconcileSelectedShare() + } } ColumnLayout { @@ -53,90 +73,117 @@ WizardStyledWindow { anchors.bottomMargin: Style.wizardWindowMargin spacing: Style.wizardSectionSpacing - RowLayout { - id: windowHeader + EnforcedPlainTextLabel { + text: dialog.title + elide: Text.ElideRight + font.pixelSize: Style.wizardHeaderTitleFontPixelSize + font.weight: Font.DemiBold + color: palette.text Layout.fillWidth: true + } - Button { - id: backButton - flat: true - padding: Style.extraSmallSpacing - spacing: 0 - icon.source: "image://svgimage-custom-color/back.svg/" + palette.windowText - icon.width: Style.extraSmallIconSize - icon.height: Style.extraSmallIconSize - Layout.alignment: Qt.AlignTop | Qt.AlignRight - Layout.rightMargin: Style.extraSmallSpacing - Layout.topMargin: Style.extraSmallSpacing - background: Rectangle { - color: "transparent" - border.width: parent.hovered ? Style.trayWindowBorderWidth : 0 - border.color: palette.dark - anchors.fill: parent - Layout.margins: Style.extraSmallSpacing - } - - onClicked: stack.pop() - visible: stack.depth > 1 + SplitView { + Layout.fillWidth: true + Layout.fillHeight: true + orientation: Qt.Horizontal + handle: Rectangle { + implicitWidth: 1 + color: palette.mid } - ColumnLayout { - EnforcedPlainTextLabel { - id: headerSettings - text: stack.currentItem.title - elide: Text.ElideRight - font.pixelSize: Style.wizardHeaderTitleFontPixelSize - font.weight: Font.DemiBold - color: palette.text - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft - } - EnforcedPlainTextLabel { - id: headerSettingsLocalPath - text: root.shortLocalPath - elide: Text.ElideRight - color: Style.wizardSecondaryText - font.pixelSize: Style.wizardHeaderAccountServerFontPixelSize - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft - - visible: stack.depth > 1 + MainPage { + id: mainPage + + SplitView.minimumWidth: 180 + SplitView.preferredWidth: 240 + SplitView.maximumWidth: 320 + + sharingController: sharingController + fileId: dialog.fileId + selectedShare: dialog.selectedShare + + onShareSelected: share => { + dialog.selectedShare = share + dialog.showingSettings = false } } - WizardButton { - id: settingsButton - iconSource: "image://svgimage-custom-color/settings.svg/" + palette.windowText + Page { + SplitView.fillWidth: true - visible: stack.depth === 2 && stack.currentItem && stack.currentItem.share + ColumnLayout { + anchors.fill: parent - onClicked: stack.push(Qt.createComponent("com.nextcloud.desktopclient.sharing", "SettingsPage").createObject(root, { - sharingController: sharingController, - share: stack.currentItem.share, - })) + RowLayout { + Layout.fillWidth: true + + Button { + flat: true + padding: Style.extraSmallSpacing + spacing: 0 + icon.source: "image://svgimage-custom-color/back.svg/" + palette.windowText + icon.width: Style.extraSmallIconSize + icon.height: Style.extraSmallIconSize + + visible: dialog.showingSettings + onClicked: dialog.showingSettings = false + } + + EnforcedPlainTextLabel { + text: dialog.showingSettings ? qsTr("Sharing settings") : qsTr("Share details") + elide: Text.ElideRight + font.pixelSize: Style.wizardHeaderTitleFontPixelSize + font.weight: Font.DemiBold + color: palette.text + Layout.fillWidth: true + } + + WizardButton { + iconSource: "image://svgimage-custom-color/settings.svg/" + palette.windowText + + visible: dialog.selectedShare && !dialog.showingSettings + onClicked: dialog.showingSettings = true + } + } + + Loader { + Layout.fillWidth: true + Layout.fillHeight: true + + active: dialog.selectedShare !== null + sourceComponent: dialog.showingSettings ? settingsComponent : detailsComponent + } + + Label { + Layout.fillWidth: true + Layout.fillHeight: true + + text: qsTr("Select a share to view its details.") + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + wrapMode: Text.Wrap + visible: dialog.selectedShare === null + } + } } } + } - StackView { - Layout.fillWidth: true - Layout.fillHeight: true - id: stack + Component { + id: detailsComponent - initialItem: mainPage + ShareDetailsPage { + sharingController: sharingController + share: dialog.selectedShare } } - MainPage { - id: mainPage - - sharingController: sharingController - localPath: root.localPath - shortLocalPath: root.shortLocalPath - fileId: root.fileId + Component { + id: settingsComponent - onShareSelected: share => stack.push(Qt.createComponent("com.nextcloud.desktopclient.sharing", "ShareDetailsPage").createObject(root, { - sharingController: sharingController, - share: share, - })) + SettingsPage { + sharingController: sharingController + share: dialog.selectedShare + } } } diff --git a/src/gui/sharing/ShareEntry.qml b/src/gui/sharing/ShareEntry.qml index 747827d4fee54..a0b7dd5ec58c6 100644 --- a/src/gui/sharing/ShareEntry.qml +++ b/src/gui/sharing/ShareEntry.qml @@ -16,6 +16,9 @@ ItemDelegate { id: root required property Share share + property bool selected: false + + highlighted: root.selected function recipientNames(): string { const names = [] @@ -25,6 +28,14 @@ ItemDelegate { } } return names.length > 0 ? names.join(", ") : qsTr("No recipients") + + background: Rectangle { + color: root.selected + ? palette.midlight + : root.hovered + ? palette.alternateBase + : "transparent" + radius: Style.smallSpacing } contentItem: RowLayout { @@ -39,10 +50,5 @@ ItemDelegate { } } - Label { - text: "›" - font.pixelSize: Style.largeIconSize - color: palette.placeholderText - } } } From 179df4b6814f4c3cb1dc6f3e20f5f8ef892385de Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 13:59:05 +0800 Subject: [PATCH 065/104] feat(activity): Improve labelling for sharing entries Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareEntry.qml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/gui/sharing/ShareEntry.qml b/src/gui/sharing/ShareEntry.qml index a0b7dd5ec58c6..7db246efbffdc 100644 --- a/src/gui/sharing/ShareEntry.qml +++ b/src/gui/sharing/ShareEntry.qml @@ -27,7 +27,8 @@ ItemDelegate { names.push(recipient.displayName) } } - return names.length > 0 ? names.join(", ") : qsTr("No recipients") + return names.join(", ") + } background: Rectangle { color: root.selected @@ -45,7 +46,12 @@ ItemDelegate { Label { Layout.fillWidth: true - text: root.recipientNames() + text: { + const recipients = root.recipientNames() + return recipients.length > 0 + ? qsTr("Shared with %1").arg(recipients) + : qsTr("Share without recipients") + } elide: Text.ElideRight } } From a837608db4241da3ed2ac1be07ede51bdbcde62c Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 14:14:40 +0800 Subject: [PATCH 066/104] feat(sharing): Implement share settings as a popup Signed-off-by: Claudio Cambra --- src/gui/sharing/CMakeLists.txt | 1 + src/gui/sharing/ShareDialog.qml | 65 +++++++++++--------------- src/gui/sharing/ShareSettingsPopup.qml | 32 +++++++++++++ 3 files changed, 60 insertions(+), 38 deletions(-) create mode 100644 src/gui/sharing/ShareSettingsPopup.qml diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index 84338ba983361..c1d290263accc 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -70,6 +70,7 @@ ecm_add_qml_module(nextcloudGuiSharing MainPage.qml RecipientSearchField.qml ShareDetailsPage.qml + ShareSettingsPopup.qml SettingsPage.qml ShareEntry.qml ShareDialog.qml diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 1687c8db2b341..183d658727d3e 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -25,7 +25,6 @@ WizardStyledWindow { property string shortLocalPath: dialog.localPath.split("/").reverse()[0] property string fileId: "" property Share selectedShare: null - property bool showingSettings: false title: qsTr("Share \"%1\"").arg(dialog.shortLocalPath) width: 720 @@ -40,7 +39,7 @@ WizardStyledWindow { } dialog.selectedShare = shares.length > 0 ? shares[0] : null - dialog.showingSettings = false + settingsPopup.close() } SharingController { @@ -104,7 +103,7 @@ WizardStyledWindow { onShareSelected: share => { dialog.selectedShare = share - dialog.showingSettings = false + settingsPopup.close() } } @@ -117,20 +116,8 @@ WizardStyledWindow { RowLayout { Layout.fillWidth: true - Button { - flat: true - padding: Style.extraSmallSpacing - spacing: 0 - icon.source: "image://svgimage-custom-color/back.svg/" + palette.windowText - icon.width: Style.extraSmallIconSize - icon.height: Style.extraSmallIconSize - - visible: dialog.showingSettings - onClicked: dialog.showingSettings = false - } - EnforcedPlainTextLabel { - text: dialog.showingSettings ? qsTr("Sharing settings") : qsTr("Share details") + text: qsTr("Share details") elide: Text.ElideRight font.pixelSize: Style.wizardHeaderTitleFontPixelSize font.weight: Font.DemiBold @@ -141,28 +128,38 @@ WizardStyledWindow { WizardButton { iconSource: "image://svgimage-custom-color/settings.svg/" + palette.windowText - visible: dialog.selectedShare && !dialog.showingSettings - onClicked: dialog.showingSettings = true + visible: dialog.selectedShare + onClicked: settingsPopup.open() } } - Loader { + Item { Layout.fillWidth: true Layout.fillHeight: true - active: dialog.selectedShare !== null - sourceComponent: dialog.showingSettings ? settingsComponent : detailsComponent - } + Loader { + anchors.fill: parent - Label { - Layout.fillWidth: true - Layout.fillHeight: true + active: dialog.selectedShare !== null + sourceComponent: detailsComponent + } + + Label { + anchors.fill: parent + + text: qsTr("Select a share to view its details.") + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + wrapMode: Text.Wrap + visible: dialog.selectedShare === null + } + + ShareSettingsPopup { + id: settingsPopup - text: qsTr("Select a share to view its details.") - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - wrapMode: Text.Wrap - visible: dialog.selectedShare === null + sharingController: sharingController + share: dialog.selectedShare + } } } } @@ -178,12 +175,4 @@ WizardStyledWindow { } } - Component { - id: settingsComponent - - SettingsPage { - sharingController: sharingController - share: dialog.selectedShare - } - } } diff --git a/src/gui/sharing/ShareSettingsPopup.qml b/src/gui/sharing/ShareSettingsPopup.qml new file mode 100644 index 0000000000000..1c6388e8b9bc5 --- /dev/null +++ b/src/gui/sharing/ShareSettingsPopup.qml @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls + +import com.nextcloud.desktopclient +import Style + +Dialog { + id: root + + required property SharingController sharingController + required property Share share + + title: qsTr("Sharing settings") + anchors.centerIn: parent + width: Math.min(parent.width - Style.standardSpacing * 2, 420) + modal: true + dim: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + standardButtons: Dialog.Close + + contentItem: SettingsPage { + sharingController: root.sharingController + share: root.share + } +} From 08e54d17395140631ccfb2ca0faeee7f03f33bd5 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 15:23:06 +0800 Subject: [PATCH 067/104] feat(sharing): Improve sharing dialog design Signed-off-by: Claudio Cambra --- src/gui/sharing/MainPage.qml | 45 ++++++++++++++------- src/gui/sharing/SettingsPage.qml | 56 +++++++++++++------------- src/gui/sharing/ShareDetailsPage.qml | 59 ++++++++++++++++++++-------- src/gui/sharing/ShareDialog.qml | 9 +++-- src/gui/sharing/ShareEntry.qml | 38 +++++------------- 5 files changed, 115 insertions(+), 92 deletions(-) diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index 16c414b8aad2d..bf59d99df0725 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -24,18 +24,32 @@ Page { signal shareSelected(Share share) title: qsTr("Share \"%1\"").arg(root.shortLocalPath) - background: Rectangle { - color: palette.alternateBase - } ColumnLayout { anchors.fill: parent + anchors.margins: Style.standardSpacing - Label { + RowLayout { Layout.fillWidth: true - text: qsTr("Shares") - font.bold: true + Label { + Layout.fillWidth: true + + text: qsTr("Shares") + font.bold: true + } + + ToolButton { + text: root.sharingController.creatingShare ? qsTr("Creating share…") : qsTr("Create share") + display: AbstractButton.IconOnly + icon.source: "image://svgimage-custom-color/add.svg/" + palette.buttonText + enabled: !root.sharingController.creatingShare && root.fileId.length > 0 + + ToolTip.visible: hovered + ToolTip.text: text + + onClicked: root.sharingController.createShare(root.fileId) + } } Label { @@ -60,13 +74,22 @@ Page { spacing: Style.smallSpacing model: root.sharingController.shares visible: count > 0 + currentIndex: { + for (let row = 0; row < count; ++row) { + if (root.sharingController.shares[row] === root.selectedShare) { + return row + } + } + return -1 + } delegate: ShareEntry { + required property int index required property Share modelData width: sharesList.width share: modelData - selected: share === root.selectedShare + highlighted: ListView.isCurrentItem onClicked: root.shareSelected(share) } @@ -81,13 +104,5 @@ Page { visible: text.length > 0 } - Button { - Layout.fillWidth: true - - text: root.sharingController.creatingShare ? qsTr("Creating share…") : qsTr("Create share") - enabled: !root.sharingController.creatingShare && root.fileId.length > 0 - - onClicked: root.sharingController.createShare(root.fileId) - } } } diff --git a/src/gui/sharing/SettingsPage.qml b/src/gui/sharing/SettingsPage.qml index 3df3424e3e79e..60ca0c70eb307 100644 --- a/src/gui/sharing/SettingsPage.qml +++ b/src/gui/sharing/SettingsPage.qml @@ -6,8 +6,6 @@ pragma ComponentBehavior: Bound import QtQuick -import QtQuick.Window -import QtQuick.Layouts import QtQuick.Controls import com.nextcloud.desktopclient @@ -20,36 +18,36 @@ Page { property Share share title: qsTr("Sharing settings") + implicitWidth: 360 + implicitHeight: Math.min(settingsList.contentHeight, 320) + + ListView { + id: settingsList - ColumnLayout { - id: windowContent anchors.fill: parent + clip: true + spacing: Style.standardSpacing + + model: PropertyModel { + share: root.share + } + + delegate: FieldDelegate { + required property var model - ScrollView { - Layout.fillHeight: true - Layout.fillWidth: true - - ScrollBar.vertical.policy: propertyList.contentHeight > propertyList.height ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff - contentWidth: availableWidth - rightPadding: ScrollBar.vertical.policy == ScrollBar.AlwaysOn ? ScrollBar.vertical.width + Style.standardSpacing : 0 - - ListView { - id: propertyList - clip: true - - // model: SharingFilterModel { - // filterType: SharingFilterModel.Settings - // sourceModel: root.sharingModel - // recipientTypes: root.recipientTypes - // } - model: PropertyModel { - share: root.share - } - - delegate: FieldDelegate { - width: propertyList.contentItem.width - } - } + width: settingsList.width } + + ScrollBar.vertical: ScrollBar {} + } + + Label { + anchors.centerIn: parent + width: parent.width + + text: qsTr("No additional sharing settings are available.") + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + visible: settingsList.count === 0 } } diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index 125c9e4c46226..91ba8b59b1b33 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -37,6 +37,7 @@ Page { width: parent.width RecipientSearchField { + id: recipientSearch Layout.fillWidth: true account: root.sharingController.account @@ -46,22 +47,49 @@ Page { } } - WizardComboBox { + ListView { + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + interactive: false + spacing: Style.extraSmallSpacing + + model: RecipientModel { + share: root.share + } + + delegate: ItemDelegate { + required property var model + + width: ListView.view.width + text: model.label + } + } + + ComboBox { id: permissionPresetSelector Layout.fillWidth: true - model: [ - { preset: "view", text: qsTr("Can view") }, - { preset: "edit", text: qsTr("Can edit") }, - { preset: null, text: qsTr("Can…") }, + readonly property var presetValues: [ + "OC\\Core\\Sharing\\Permission\\ViewSharePermissionPreset", + "OC\\Core\\Sharing\\Permission\\EditSharePermissionPreset", + "" ] - textRole: "text" - valueRole: "preset" + model: [qsTr("Can view"), qsTr("Can edit"), qsTr("Custom permissions")] + currentIndex: { + const preset = root.share.permissionPreset + if (preset.endsWith("\\ViewSharePermissionPreset")) { + return 0 + } + if (preset.endsWith("\\EditSharePermissionPreset")) { + return 1 + } + return 2 + } - // value: root.share.permissionPreset - onCurrentValueChanged: { - if (currentValue) { - root.sharingController.setPermissionPreset(root.share, currentValue) + onActivated: function(index) { + const preset = presetValues[index] + if (preset) { + root.sharingController.setPermissionPreset(root.share, preset) } } } @@ -73,12 +101,12 @@ Page { share: root.share } - delegate: ItemDelegate { - visible: !permissionPresetSelector.currentValue - + delegate: RowLayout { + visible: permissionPresetSelector.currentIndex === 2 required property var model - RowLayout { + Layout.fillWidth: true + Label { text: model.label Layout.fillWidth: true @@ -94,7 +122,6 @@ Page { } } } - } ListView { id: propertyList diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 183d658727d3e..e956e8565b05a 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -87,7 +87,7 @@ WizardStyledWindow { orientation: Qt.Horizontal handle: Rectangle { implicitWidth: 1 - color: palette.mid + color: Style.wizardRowBorder } MainPage { @@ -112,6 +112,7 @@ WizardStyledWindow { ColumnLayout { anchors.fill: parent + anchors.leftMargin: Style.standardSpacing RowLayout { Layout.fillWidth: true @@ -125,10 +126,10 @@ WizardStyledWindow { Layout.fillWidth: true } - WizardButton { - iconSource: "image://svgimage-custom-color/settings.svg/" + palette.windowText + ToolButton { + icon.source: "image://svgimage-custom-color/settings.svg/" + palette.windowText - visible: dialog.selectedShare + visible: dialog.selectedShare && dialog.selectedShare.properties.length > 0 onClicked: settingsPopup.open() } } diff --git a/src/gui/sharing/ShareEntry.qml b/src/gui/sharing/ShareEntry.qml index 7db246efbffdc..3917b68710943 100644 --- a/src/gui/sharing/ShareEntry.qml +++ b/src/gui/sharing/ShareEntry.qml @@ -6,7 +6,6 @@ pragma ComponentBehavior: Bound import QtQuick -import QtQuick.Layouts import QtQuick.Controls import com.nextcloud.desktopclient @@ -16,9 +15,6 @@ ItemDelegate { id: root required property Share share - property bool selected: false - - highlighted: root.selected function recipientNames(): string { const names = [] @@ -30,31 +26,17 @@ ItemDelegate { return names.join(", ") } - background: Rectangle { - color: root.selected - ? palette.midlight - : root.hovered - ? palette.alternateBase - : "transparent" - radius: Style.smallSpacing + text: { + const recipients = root.recipientNames() + return recipients.length > 0 + ? qsTr("Shared with %1").arg(recipients) + : qsTr("New share") } - contentItem: RowLayout { - ColumnLayout { - Layout.fillWidth: true - - Label { - Layout.fillWidth: true - - text: { - const recipients = root.recipientNames() - return recipients.length > 0 - ? qsTr("Shared with %1").arg(recipients) - : qsTr("Share without recipients") - } - elide: Text.ElideRight - } - } - + contentItem: Label { + text: root.text + color: root.highlighted ? root.palette.highlightedText : root.palette.text + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight } } From ffb659e29ae248ae8f0d246b62592734eb125fd7 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 15:25:29 +0800 Subject: [PATCH 068/104] feat(sharing): Fix recipients addition and surface failures Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareDetailsPage.qml | 32 +++++++++++++++++++++------ src/gui/sharing/sharingcontroller.cpp | 12 ++++++++++ src/gui/sharing/sharingcontroller.h | 6 +++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index 91ba8b59b1b33..b3b2969907d02 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -18,6 +18,7 @@ Page { required property SharingController sharingController required property Share share + property string recipientAdditionError: "" title: qsTr("Share details") @@ -43,10 +44,20 @@ Page { account: root.sharingController.account onRecipientSelected: (recipientType, recipientValue) => { + root.recipientAdditionError = "" root.sharingController.addRecipient(root.share, recipientType, recipientValue) } } + Label { + Layout.fillWidth: true + + text: root.recipientAdditionError + color: Style.wizardErrorText + wrapMode: Text.Wrap + visible: text.length > 0 + } + ListView { Layout.fillWidth: true Layout.preferredHeight: contentHeight @@ -134,18 +145,25 @@ Page { delegate: FieldDelegate { width: propertyList.contentItem.width + height: item ? item.implicitHeight : 0 } } } - RowLayout { - Button { - Layout.fillWidth: true - text: qsTr("Copy link") } - Button { - Layout.fillWidth: true - text: qsTr("Send") + + Connections { + target: root.sharingController + + function onRecipientAdded(share) { + if (share === root.share) { + recipientSearch.clear() + } + } + + function onRecipientAdditionFailed(share, error) { + if (share === root.share) { + root.recipientAdditionError = error } } } diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index 36a659100f2ec..8c02b207a4030 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -166,7 +166,19 @@ void SharingController::addRecipient(Share *share, const QString &recipientType, return; } + const auto guardedShare = QPointer{share}; const auto job = new AddRecipientJob{_account, *share, recipientType, recipientValue}; + connect(job, &AddRecipientJob::shareUpdated, this, [this](QPointer updatedShare) { + if (updatedShare) { + Q_EMIT recipientAdded(updatedShare); + } + }); + connect(job, &AddRecipientJob::ocsError, this, [this, guardedShare](int, const QString &message) { + Q_EMIT recipientAdditionFailed(guardedShare, message.isEmpty() ? tr("Could not add the recipient.") : message); + }); + connect(job, &AddRecipientJob::networkError, this, [this, guardedShare](const QNetworkReply *reply) { + Q_EMIT recipientAdditionFailed(guardedShare, reply ? reply->errorString() : tr("Could not add the recipient.")); + }); job->start(); } diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index 643ddf313bf0a..8ab298b362a47 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -76,6 +76,12 @@ class SharingController : public QObject /** @brief Emitted when shareCreationError changes. */ void shareCreationErrorChanged(); + /** @brief Emitted after a recipient was added and the Share was updated. */ + void recipientAdded(Share *share); + + /** @brief Emitted when adding a recipient failed. */ + void recipientAdditionFailed(Share *share, const QString &error); + private: AccountPtr _account; QList _shares; From 73088980c6756fdb3fd5d9178580837dbc04aaf5 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 15:27:35 +0800 Subject: [PATCH 069/104] feat(sharing): Conditionally add details to share Signed-off-by: Claudio Cambra --- src/gui/sharing/share.cpp | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/gui/sharing/share.cpp b/src/gui/sharing/share.cpp index ce23b8da64112..dc47a1cd9f94b 100644 --- a/src/gui/sharing/share.cpp +++ b/src/gui/sharing/share.cpp @@ -31,12 +31,24 @@ void Share::updateFromJson(const QJsonDocument &json) { qCDebug(lcSharingShare) << "updating share from json" << json; const auto data = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject(); - setId(data.value("id"_L1).toString()); - setState(data.value("state"_L1).toString()); - setPermissionPreset(data.value("permission_preset"_L1).toString()); - setPermissions(data.value("permissions"_L1).toArray()); - setProperties(data.value("properties"_L1).toArray()); - setRecipients(data.value("recipients"_L1).toArray()); + if (data.contains("id"_L1)) { + setId(data.value("id"_L1).toString()); + } + if (data.contains("state"_L1)) { + setState(data.value("state"_L1).toString()); + } + if (data.contains("permission_preset"_L1)) { + setPermissionPreset(data.value("permission_preset"_L1).toString()); + } + if (data.contains("permissions"_L1)) { + setPermissions(data.value("permissions"_L1).toArray()); + } + if (data.contains("properties"_L1)) { + setProperties(data.value("properties"_L1).toArray()); + } + if (data.contains("recipients"_L1)) { + setRecipients(data.value("recipients"_L1).toArray()); + } } Share::Share(const AccountPtr &account) From fea6d14d3ef0ed37cab0d1a46a0caf1d5dcd9caa Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 15:52:23 +0800 Subject: [PATCH 070/104] feat(sharing): Place settings below recipients Signed-off-by: Claudio Cambra --- src/gui/sharing/CMakeLists.txt | 2 - src/gui/sharing/SettingsPage.qml | 53 -------------------------- src/gui/sharing/ShareDetailsPage.qml | 20 +++++++--- src/gui/sharing/ShareDialog.qml | 16 -------- src/gui/sharing/ShareSettingsPopup.qml | 32 ---------------- 5 files changed, 15 insertions(+), 108 deletions(-) delete mode 100644 src/gui/sharing/SettingsPage.qml delete mode 100644 src/gui/sharing/ShareSettingsPopup.qml diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index c1d290263accc..94947b42c3e72 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -70,8 +70,6 @@ ecm_add_qml_module(nextcloudGuiSharing MainPage.qml RecipientSearchField.qml ShareDetailsPage.qml - ShareSettingsPopup.qml - SettingsPage.qml ShareEntry.qml ShareDialog.qml ) diff --git a/src/gui/sharing/SettingsPage.qml b/src/gui/sharing/SettingsPage.qml deleted file mode 100644 index 60ca0c70eb307..0000000000000 --- a/src/gui/sharing/SettingsPage.qml +++ /dev/null @@ -1,53 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Controls - -import com.nextcloud.desktopclient -import Style - -Page { - id: root - - required property SharingController sharingController - property Share share - - title: qsTr("Sharing settings") - implicitWidth: 360 - implicitHeight: Math.min(settingsList.contentHeight, 320) - - ListView { - id: settingsList - - anchors.fill: parent - clip: true - spacing: Style.standardSpacing - - model: PropertyModel { - share: root.share - } - - delegate: FieldDelegate { - required property var model - - width: settingsList.width - } - - ScrollBar.vertical: ScrollBar {} - } - - Label { - anchors.centerIn: parent - width: parent.width - - text: qsTr("No additional sharing settings are available.") - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.Wrap - visible: settingsList.count === 0 - } -} diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index b3b2969907d02..7e8fd2b83498e 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -29,10 +29,8 @@ Page { Layout.fillHeight: true Layout.fillWidth: true - ScrollBar.vertical.policy: propertyList.contentHeight > propertyList.height ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff contentWidth: availableWidth - contentHeight: availableHeight - rightPadding: ScrollBar.vertical.policy == ScrollBar.AlwaysOn ? ScrollBar.vertical.width + Style.standardSpacing : 0 + rightPadding: ScrollBar.vertical.visible ? ScrollBar.vertical.width + Style.standardSpacing : 0 ColumnLayout { width: parent.width @@ -132,11 +130,22 @@ Page { } } } + + Label { + Layout.fillWidth: true + + text: qsTr("Sharing settings") + font.bold: true + visible: propertyList.count > 0 } ListView { id: propertyList - clip: true + + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + interactive: false + spacing: Style.standardSpacing model: PropertyModel { // TODO: only show properties with prio=1 @@ -144,10 +153,11 @@ Page { } delegate: FieldDelegate { - width: propertyList.contentItem.width + width: propertyList.width height: item ? item.implicitHeight : 0 } } + } } } diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index e956e8565b05a..828e0fd0a7125 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -39,7 +39,6 @@ WizardStyledWindow { } dialog.selectedShare = shares.length > 0 ? shares[0] : null - settingsPopup.close() } SharingController { @@ -103,7 +102,6 @@ WizardStyledWindow { onShareSelected: share => { dialog.selectedShare = share - settingsPopup.close() } } @@ -125,13 +123,6 @@ WizardStyledWindow { color: palette.text Layout.fillWidth: true } - - ToolButton { - icon.source: "image://svgimage-custom-color/settings.svg/" + palette.windowText - - visible: dialog.selectedShare && dialog.selectedShare.properties.length > 0 - onClicked: settingsPopup.open() - } } Item { @@ -154,13 +145,6 @@ WizardStyledWindow { wrapMode: Text.Wrap visible: dialog.selectedShare === null } - - ShareSettingsPopup { - id: settingsPopup - - sharingController: sharingController - share: dialog.selectedShare - } } } } diff --git a/src/gui/sharing/ShareSettingsPopup.qml b/src/gui/sharing/ShareSettingsPopup.qml deleted file mode 100644 index 1c6388e8b9bc5..0000000000000 --- a/src/gui/sharing/ShareSettingsPopup.qml +++ /dev/null @@ -1,32 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Controls - -import com.nextcloud.desktopclient -import Style - -Dialog { - id: root - - required property SharingController sharingController - required property Share share - - title: qsTr("Sharing settings") - anchors.centerIn: parent - width: Math.min(parent.width - Style.standardSpacing * 2, 420) - modal: true - dim: true - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - standardButtons: Dialog.Close - - contentItem: SettingsPage { - sharingController: root.sharingController - share: root.share - } -} From fbe3df323021367583a406d523b7a764924d344b Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 15:59:48 +0800 Subject: [PATCH 071/104] feat(sharing): Fix alignments Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareDetailsPage.qml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index 7e8fd2b83498e..c8cb9e09f0ec2 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -34,6 +34,7 @@ Page { ColumnLayout { width: parent.width + spacing: Style.standardSpacing RecipientSearchField { id: recipientSearch @@ -66,11 +67,12 @@ Page { share: root.share } - delegate: ItemDelegate { + delegate: Label { required property var model width: ListView.view.width text: model.label + elide: Text.ElideRight } } @@ -133,6 +135,7 @@ Page { Label { Layout.fillWidth: true + Layout.topMargin: Style.standardSpacing text: qsTr("Sharing settings") font.bold: true From 361674afba3c5e07cb7ac71825ad17903be50537 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 16:00:49 +0800 Subject: [PATCH 072/104] feat(sharing): Implement property setting in controller Signed-off-by: Claudio Cambra --- src/gui/sharing/FieldDelegate.qml | 8 ++-- src/gui/sharing/ShareDetailsPage.qml | 63 ++++++++++++++++++--------- src/gui/sharing/sharingcontroller.cpp | 37 ++++++++++++++++ src/gui/sharing/sharingcontroller.h | 7 +++ 4 files changed, 91 insertions(+), 24 deletions(-) diff --git a/src/gui/sharing/FieldDelegate.qml b/src/gui/sharing/FieldDelegate.qml index 16bd55e4246c6..82b6368749bf5 100644 --- a/src/gui/sharing/FieldDelegate.qml +++ b/src/gui/sharing/FieldDelegate.qml @@ -17,6 +17,8 @@ Loader { id: instantiator required property var model + signal valueEdited(string propertyClass, string value) + sourceComponent: switch (model.type) { case PropertyModel.Switch: return switchComponent; @@ -41,7 +43,7 @@ Loader { if (instantiator.model.value === checked) { return; } - instantiator.model.value = checked + instantiator.valueEdited(instantiator.model.property, checked ? "true" : "false") } } } @@ -61,7 +63,7 @@ Loader { if (instantiator.model.value === text) { return; } - instantiator.model.value = text + instantiator.valueEdited(instantiator.model.property, text) } } } @@ -81,7 +83,7 @@ Loader { if (instantiator.model.value === text) { return; } - instantiator.model.value = text + instantiator.valueEdited(instantiator.model.property, text) } } } diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index c8cb9e09f0ec2..d128047cbbb2c 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -19,6 +19,7 @@ Page { required property SharingController sharingController required property Share share property string recipientAdditionError: "" + property string propertyUpdateError: "" title: qsTr("Share details") @@ -116,22 +117,22 @@ Page { visible: permissionPresetSelector.currentIndex === 2 required property var model - Layout.fillWidth: true + Layout.fillWidth: true - Label { - text: model.label - Layout.fillWidth: true - } - Switch { - checked: model.enabled - onCheckedChanged: { - if (model.enabled !== checked) { - root.sharingController.setPermission(root.share, model.className, checked) - } + Label { + text: model.label + Layout.fillWidth: true + } + Switch { + checked: model.enabled + onCheckedChanged: { + if (model.enabled !== checked) { + root.sharingController.setPermission(root.share, model.className, checked) } } } } + } Label { Layout.fillWidth: true @@ -142,28 +143,42 @@ Page { visible: propertyList.count > 0 } - ListView { - id: propertyList + ListView { + id: propertyList Layout.fillWidth: true Layout.preferredHeight: contentHeight interactive: false spacing: Style.standardSpacing - model: PropertyModel { - // TODO: only show properties with prio=1 - share: root.share - } + model: PropertyModel { + // TODO: only show properties with prio=1 + share: root.share + } - delegate: FieldDelegate { + delegate: FieldDelegate { width: propertyList.width - height: item ? item.implicitHeight : 0 + height: item ? item.implicitHeight : 0 + + onValueEdited: (propertyClass, value) => { + root.propertyUpdateError = "" + root.sharingController.setProperty(root.share, propertyClass, value) + } + } + } + + Label { + Layout.fillWidth: true + + text: root.propertyUpdateError + color: Style.wizardErrorText + wrapMode: Text.Wrap + visible: text.length > 0 } } - } } - } + } Connections { target: root.sharingController @@ -179,5 +194,11 @@ Page { root.recipientAdditionError = error } } + + function onPropertyUpdateFailed(share, error) { + if (share === root.share) { + root.propertyUpdateError = error + } + } } } diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index 8c02b207a4030..1fdaa3321760a 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -9,6 +9,8 @@ #include #include +#include + #include "addrecipientjob.h" #include "addsourcejob.h" #include "createsharejob.h" @@ -17,6 +19,7 @@ #include "removerecipientjob.h" #include "setpermissionjob.h" #include "setpermissionpresetjob.h" +#include "setpropertyjob.h" #include "share.h" #include "sharingconstants.h" @@ -235,6 +238,40 @@ void SharingController::setPermissionPreset(Share *share, const QString &permiss job->start(); } +void SharingController::setProperty(Share *share, const QString &propertyClass, const QString &value) +{ + if (!_account) { + qCWarning(lcSharingController) << "attempted to set a share property without an account set"; + return; + } + + if (!containsShare(share)) { + qCWarning(lcSharingController) << "attempted to set a property on a share not owned by this controller"; + return; + } + + if (propertyClass.isEmpty()) { + qCWarning(lcSharingController) << "attempted to set a share property without a property class"; + return; + } + + const auto guardedShare = QPointer{share}; + const auto propertyValue = value.isEmpty() ? std::nullopt : std::optional{value}; + const auto job = new SetPropertyJob{_account, *share, propertyClass, propertyValue}; + connect(job, &SetPropertyJob::shareUpdated, this, [this](QPointer updatedShare) { + if (updatedShare) { + Q_EMIT propertyUpdated(updatedShare); + } + }); + connect(job, &SetPropertyJob::ocsError, this, [this, guardedShare](int, const QString &message) { + Q_EMIT propertyUpdateFailed(guardedShare, message.isEmpty() ? tr("Could not update the sharing setting.") : message); + }); + connect(job, &SetPropertyJob::networkError, this, [this, guardedShare](const QNetworkReply *reply) { + Q_EMIT propertyUpdateFailed(guardedShare, reply ? reply->errorString() : tr("Could not update the sharing setting.")); + }); + job->start(); +} + bool SharingController::containsShare(const Share *share) const { return share && _shares.contains(share); diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index 8ab298b362a47..b01ef55d60177 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -65,6 +65,7 @@ class SharingController : public QObject Q_INVOKABLE void setPermission(Share *share, const QString &permissionClass, bool enabled); Q_INVOKABLE void setPermissionPreset(Share *share, const QString &permissionPreset); + Q_INVOKABLE void setProperty(Share *share, const QString &propertyClass, const QString &value); Q_SIGNALS: void accountChanged(); @@ -82,6 +83,12 @@ class SharingController : public QObject /** @brief Emitted when adding a recipient failed. */ void recipientAdditionFailed(Share *share, const QString &error); + /** @brief Emitted after a share property was updated. */ + void propertyUpdated(Share *share); + + /** @brief Emitted when updating a share property failed. */ + void propertyUpdateFailed(Share *share, const QString &error); + private: AccountPtr _account; QList _shares; From c499587c51fa70adb08d8368e5e5c4cda34a4046 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 18:22:29 +0800 Subject: [PATCH 073/104] feat(sharing): Implement share activation in controller and UI Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareDetailsPage.qml | 53 +++++++++++++++++++++++++++ src/gui/sharing/sharingcontroller.cpp | 37 +++++++++++++++++++ src/gui/sharing/sharingcontroller.h | 9 +++++ 3 files changed, 99 insertions(+) diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index d128047cbbb2c..20e7f6324a148 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -20,6 +20,8 @@ Page { required property Share share property string recipientAdditionError: "" property string propertyUpdateError: "" + property string shareActivationError: "" + property bool activatingShare: false title: qsTr("Share details") @@ -178,6 +180,44 @@ Page { } } + Label { + Layout.fillWidth: true + + text: root.shareActivationError + color: Style.wizardErrorText + wrapMode: Text.Wrap + visible: text.length > 0 + } + + Label { + Layout.fillWidth: true + + text: qsTr("Changes to this share are applied immediately.") + color: palette.placeholderText + wrapMode: Text.Wrap + visible: root.share.state === Share.Active + } + + RowLayout { + Layout.fillWidth: true + visible: root.share.state === Share.Draft + + Item { + Layout.fillWidth: true + } + + WizardButton { + primary: true + text: root.activatingShare ? qsTr("Sending…") : qsTr("Send share") + enabled: !root.activatingShare + + onClicked: { + root.shareActivationError = "" + root.activatingShare = true + root.sharingController.activateShare(root.share) + } + } + } } Connections { @@ -200,5 +240,18 @@ Page { root.propertyUpdateError = error } } + + function onShareActivated(share) { + if (share === root.share) { + root.activatingShare = false + } + } + + function onShareActivationFailed(share, error) { + if (share === root.share) { + root.activatingShare = false + root.shareActivationError = error + } + } } } diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index 1fdaa3321760a..36d5267ac0587 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -20,6 +20,7 @@ #include "setpermissionjob.h" #include "setpermissionpresetjob.h" #include "setpropertyjob.h" +#include "setsharestatejob.h" #include "share.h" #include "sharingconstants.h" @@ -272,6 +273,42 @@ void SharingController::setProperty(Share *share, const QString &propertyClass, job->start(); } +void SharingController::activateShare(Share *share) +{ + if (!_account) { + qCWarning(lcSharingController) << "attempted to activate a share without an account set"; + return; + } + + if (!containsShare(share)) { + qCWarning(lcSharingController) << "attempted to activate a share not owned by this controller"; + return; + } + + if (share->state() != Share::ShareState::Draft) { + qCDebug(lcSharingController) << "ignoring attempt to activate a share that is not a draft"; + return; + } + + const auto guardedShare = QPointer{share}; + const auto job = new SetShareStateJob{_account, *share, Share::ShareState::Active}; + connect(job, &SetShareStateJob::shareUpdated, this, [this, guardedShare](QPointer updatedShare) { + if (updatedShare && updatedShare->state() == Share::ShareState::Active) { + Q_EMIT shareActivated(updatedShare); + return; + } + + Q_EMIT shareActivationFailed(guardedShare, tr("The server did not activate the share.")); + }); + connect(job, &SetShareStateJob::ocsError, this, [this, guardedShare](int, const QString &message) { + Q_EMIT shareActivationFailed(guardedShare, message.isEmpty() ? tr("Could not send the share.") : message); + }); + connect(job, &SetShareStateJob::networkError, this, [this, guardedShare](const QNetworkReply *reply) { + Q_EMIT shareActivationFailed(guardedShare, reply ? reply->errorString() : tr("Could not send the share.")); + }); + job->start(); +} + bool SharingController::containsShare(const Share *share) const { return share && _shares.contains(share); diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index b01ef55d60177..206d113186d38 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -67,6 +67,9 @@ class SharingController : public QObject Q_INVOKABLE void setPermissionPreset(Share *share, const QString &permissionPreset); Q_INVOKABLE void setProperty(Share *share, const QString &propertyClass, const QString &value); + /** @brief Activates a draft share, making it available to its recipients. */ + Q_INVOKABLE void activateShare(Share *share); + Q_SIGNALS: void accountChanged(); void sharesChanged(); @@ -89,6 +92,12 @@ class SharingController : public QObject /** @brief Emitted when updating a share property failed. */ void propertyUpdateFailed(Share *share, const QString &error); + /** @brief Emitted after a draft share was activated. */ + void shareActivated(Share *share); + + /** @brief Emitted when activating a draft share failed. */ + void shareActivationFailed(Share *share, const QString &error); + private: AccountPtr _account; QList _shares; From 63ec17272b746732f74c529cf32dffd09feeb8b3 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 18:24:31 +0800 Subject: [PATCH 074/104] feat(sharing): Fix permissions selection layout Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareDetailsPage.qml | 38 ++++++++++++++++++---------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index 20e7f6324a148..7653da1d72698 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -89,6 +89,23 @@ Page { "" ] model: [qsTr("Can view"), qsTr("Can edit"), qsTr("Custom permissions")] + delegate: ItemDelegate { + id: permissionPresetDelegate + + required property int index + required property string modelData + + width: permissionPresetSelector.width + text: modelData + highlighted: permissionPresetSelector.highlightedIndex === index + + contentItem: Label { + text: permissionPresetDelegate.text + color: permissionPresetDelegate.highlighted ? palette.highlightedText : palette.text + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } currentIndex: { const preset = root.share.permissionPreset if (preset.endsWith("\\ViewSharePermissionPreset")) { @@ -108,33 +125,28 @@ Page { } } - Repeater { + ListView { Layout.fillWidth: true + Layout.preferredHeight: visible ? contentHeight : 0 + interactive: false + visible: permissionPresetSelector.currentIndex === 2 model: PermissionModel { share: root.share } - delegate: RowLayout { - visible: permissionPresetSelector.currentIndex === 2 + delegate: SwitchDelegate { required property var model - Layout.fillWidth: true - - Label { + width: ListView.view.width text: model.label - Layout.fillWidth: true - } - Switch { checked: model.enabled - onCheckedChanged: { - if (model.enabled !== checked) { + + onToggled: { root.sharingController.setPermission(root.share, model.className, checked) } } } - } - } Label { Layout.fillWidth: true From 6bf0769b651bef8c265a548e307cc0a6af4c9ac3 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 18:40:55 +0800 Subject: [PATCH 075/104] feat(sharing): Refine design Signed-off-by: Claudio Cambra --- src/gui/sharing/MainPage.qml | 15 ++++++++-- src/gui/sharing/ShareDetailsPage.qml | 12 ++++---- src/gui/sharing/ShareDialog.qml | 45 ++++++++++++++++++---------- theme/Style/Style.qml | 13 ++++++++ 4 files changed, 61 insertions(+), 24 deletions(-) diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml index bf59d99df0725..3d68710edd809 100644 --- a/src/gui/sharing/MainPage.qml +++ b/src/gui/sharing/MainPage.qml @@ -27,19 +27,30 @@ Page { ColumnLayout { anchors.fill: parent - anchors.margins: Style.standardSpacing + anchors.leftMargin: Style.sharingDialogWindowMargin + Style.standardSpacing + anchors.rightMargin: Style.standardSpacing + anchors.topMargin: Style.standardSpacing + anchors.bottomMargin: Style.standardSpacing RowLayout { Layout.fillWidth: true + Layout.preferredHeight: Style.sharingDialogPaneHeaderHeight Label { Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter text: qsTr("Shares") - font.bold: true + font.pointSize: Style.subheaderFontPtSize + font.weight: Font.DemiBold + color: palette.text } ToolButton { + Layout.preferredWidth: Style.sharingDialogPaneHeaderHeight + Layout.preferredHeight: Style.sharingDialogPaneHeaderHeight + Layout.alignment: Qt.AlignVCenter + text: root.sharingController.creatingShare ? qsTr("Creating share…") : qsTr("Create share") display: AbstractButton.IconOnly icon.source: "image://svgimage-custom-color/add.svg/" + palette.buttonText diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index 7653da1d72698..759877166f74c 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -139,21 +139,21 @@ Page { required property var model width: ListView.view.width - text: model.label - checked: model.enabled + text: model.label + checked: model.enabled onToggled: { - root.sharingController.setPermission(root.share, model.className, checked) - } - } + root.sharingController.setPermission(root.share, model.className, checked) } + } + } Label { Layout.fillWidth: true Layout.topMargin: Style.standardSpacing text: qsTr("Sharing settings") - font.bold: true + font.weight: Font.DemiBold visible: propertyList.count > 0 } diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 828e0fd0a7125..6a02f5e81fbab 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -27,10 +27,10 @@ WizardStyledWindow { property Share selectedShare: null title: qsTr("Share \"%1\"").arg(dialog.shortLocalPath) - width: 720 - height: 500 - minimumWidth: 600 - minimumHeight: Style.wizardStandaloneWindowMinimumHeight + width: Style.sharingDialogWidth + height: Style.sharingDialogHeight + minimumWidth: Style.sharingDialogMinimumWidth + minimumHeight: Style.sharingDialogMinimumHeight function reconcileSelectedShare() { const shares = sharingController.shares @@ -65,36 +65,43 @@ WizardStyledWindow { ColumnLayout { anchors.fill: parent - anchors.leftMargin: Style.wizardWindowMargin - anchors.rightMargin: Style.wizardWindowMargin - anchors.topMargin: Style.wizardWindowTopMargin - anchors.bottomMargin: Style.wizardWindowMargin - spacing: Style.wizardSectionSpacing + anchors.topMargin: Style.standardSpacing + spacing: 0 EnforcedPlainTextLabel { + Layout.leftMargin: Style.sharingDialogWindowMargin + Layout.rightMargin: Style.sharingDialogWindowMargin + Layout.bottomMargin: Style.standardSpacing + text: dialog.title elide: Text.ElideRight - font.pixelSize: Style.wizardHeaderTitleFontPixelSize + font.pointSize: Style.titleFontPtSize font.weight: Font.DemiBold color: palette.text Layout.fillWidth: true } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Style.normalBorderWidth + color: Style.sharingDialogSeparatorColor + } + SplitView { Layout.fillWidth: true Layout.fillHeight: true orientation: Qt.Horizontal handle: Rectangle { - implicitWidth: 1 - color: Style.wizardRowBorder + implicitWidth: Style.normalBorderWidth + color: Style.sharingDialogSeparatorColor } MainPage { id: mainPage - SplitView.minimumWidth: 180 - SplitView.preferredWidth: 240 - SplitView.maximumWidth: 320 + SplitView.minimumWidth: Style.sharingDialogSidebarMinimumWidth + SplitView.preferredWidth: Style.sharingDialogSidebarPreferredWidth + SplitView.maximumWidth: Style.sharingDialogSidebarMaximumWidth sharingController: sharingController fileId: dialog.fileId @@ -111,14 +118,20 @@ WizardStyledWindow { ColumnLayout { anchors.fill: parent anchors.leftMargin: Style.standardSpacing + anchors.rightMargin: Style.sharingDialogWindowMargin + Style.standardSpacing + anchors.topMargin: Style.standardSpacing + anchors.bottomMargin: Style.standardSpacing RowLayout { Layout.fillWidth: true + Layout.preferredHeight: Style.sharingDialogPaneHeaderHeight EnforcedPlainTextLabel { + Layout.alignment: Qt.AlignVCenter + text: qsTr("Share details") elide: Text.ElideRight - font.pixelSize: Style.wizardHeaderTitleFontPixelSize + font.pointSize: Style.subheaderFontPtSize font.weight: Font.DemiBold color: palette.text Layout.fillWidth: true diff --git a/theme/Style/Style.qml b/theme/Style/Style.qml index 09941091252d5..e183460da72f3 100644 --- a/theme/Style/Style.qml +++ b/theme/Style/Style.qml @@ -141,6 +141,19 @@ QtObject { readonly property int wizardHeaderAccountServerFontPixelSize: subLinePixelSize readonly property int wizardStandaloneWindowMinimumWidth: 520 readonly property int wizardStandaloneWindowMinimumHeight: 420 + + // Sharing dialog + readonly property int sharingDialogWidth: 720 + readonly property int sharingDialogHeight: 500 + readonly property int sharingDialogMinimumWidth: 600 + readonly property int sharingDialogMinimumHeight: 420 + readonly property int sharingDialogWindowMargin: 2 * standardSpacing + readonly property int sharingDialogPaneHeaderHeight: iconButtonWidth + readonly property int sharingDialogSidebarMinimumWidth: sharingDialogWidth / 4 + readonly property int sharingDialogSidebarPreferredWidth: sharingDialogWidth / 3 + readonly property int sharingDialogSidebarMaximumWidth: sharingDialogWidth / 2 + readonly property color sharingDialogSeparatorColor: darkMode ? "#3c454c" : "#e1e8ee" + readonly property int activitiesWindowWidth: 680 readonly property int activitiesWindowHeight: 700 readonly property int assistantWindowWidth: 640 From 9db3692de13dd79808468cd2bf5ca104fc296399 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 30 Jul 2026 20:13:09 +0800 Subject: [PATCH 076/104] feat(sharing): Support full property values Signed-off-by: Claudio Cambra --- src/gui/sharing/FieldDelegate.qml | 176 ++++++++++++++++++++++----- src/gui/sharing/ShareDetailsPage.qml | 36 +++++- src/gui/sharing/property.cpp | 49 ++++++++ src/gui/sharing/property.h | 42 ++++++- src/gui/sharing/propertymodel.cpp | 116 ++++++++++-------- src/gui/sharing/propertymodel.h | 39 +++++- 6 files changed, 368 insertions(+), 90 deletions(-) diff --git a/src/gui/sharing/FieldDelegate.qml b/src/gui/sharing/FieldDelegate.qml index 82b6368749bf5..9993641ce80f6 100644 --- a/src/gui/sharing/FieldDelegate.qml +++ b/src/gui/sharing/FieldDelegate.qml @@ -19,81 +19,193 @@ Loader { signal valueEdited(string propertyClass, string value) + function labelText(): string { + return model.required ? qsTr("%1 (required)").arg(model.label) : model.label + } + + function submit(value: string): void { + if (model.value === value) { + return + } + valueEdited(model.property, value) + } + sourceComponent: switch (model.type) { - case PropertyModel.Switch: - return switchComponent; - case PropertyModel.TextField: - return textFieldComponent; - case PropertyModel.TextArea: - return textAreaComponent; + case PropertyModel.Boolean: + return booleanComponent + case PropertyModel.Date: + return dateComponent + case PropertyModel.Enum: + return enumComponent + case PropertyModel.Password: + return passwordComponent + case PropertyModel.String: + return stringComponent default: - return unknownItem; + return unknownComponent + } + + Component { + id: booleanComponent + + SwitchDelegate { + text: instantiator.labelText() + checked: instantiator.model.value === "true" + + onToggled: { + instantiator.submit(checked ? "true" : "false") + } + } } Component { - id: switchComponent - RowLayout { + id: dateComponent + + ColumnLayout { + Label { + text: instantiator.labelText() + } + TextField { + id: dateField + + Layout.fillWidth: true + text: instantiator.model.value ?? "" + placeholderText: instantiator.model.placeholder || qsTr("ISO 8601 date") + inputMethodHints: Qt.ImhDate + + property bool withinMinimum: !instantiator.model.minimum + || !text + || Date.parse(text) > Date.parse(instantiator.model.minimum) + property bool withinMaximum: !instantiator.model.maximum + || !text + || Date.parse(text) < Date.parse(instantiator.model.maximum) + property bool valid: (!instantiator.model.required || text.length > 0) + && (!text || !isNaN(Date.parse(text))) + && withinMinimum + && withinMaximum + + onEditingFinished: { + if (valid) { + instantiator.submit(text) + } + } + } Label { - text: instantiator.model.label Layout.fillWidth: true + visible: dateField.text.length > 0 && !dateField.valid + text: qsTr("Enter a valid date within the allowed range.") + color: Style.wizardErrorText + wrapMode: Text.Wrap } - Switch { - Component.onCompleted: checked = instantiator.model.value ?? false - onCheckedChanged: { - if (instantiator.model.value === checked) { - return; + } + } + + Component { + id: enumComponent + + ColumnLayout { + Label { + text: instantiator.labelText() + } + ComboBox { + id: enumSelector + + Layout.fillWidth: true + model: instantiator.model.validValues + currentIndex: instantiator.model.validValues.indexOf(instantiator.model.value) + delegate: ItemDelegate { + id: enumDelegate + + required property int index + required property string modelData + + width: enumSelector.width + text: modelData + highlighted: enumSelector.highlightedIndex === index + + contentItem: Label { + text: enumDelegate.text + color: enumDelegate.highlighted ? palette.highlightedText : palette.text + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight } - instantiator.valueEdited(instantiator.model.property, checked ? "true" : "false") + } + + onActivated: index => { + instantiator.submit(instantiator.model.validValues[index]) } } } } Component { - id: textFieldComponent + id: passwordComponent + ColumnLayout { Label { - text: instantiator.model.label + text: instantiator.labelText() } TextField { Layout.fillWidth: true + text: instantiator.model.value ?? "" placeholderText: instantiator.model.placeholder - Component.onCompleted: text = instantiator.model.value ?? "" + echoMode: TextInput.Password + onEditingFinished: { - if (instantiator.model.value === text) { - return; + if (!instantiator.model.required || text.length > 0) { + instantiator.submit(text) } - instantiator.valueEdited(instantiator.model.property, text) } } } } Component { - id: textAreaComponent + id: stringComponent + ColumnLayout { Label { - text: instantiator.model.label + text: instantiator.labelText() } - TextArea { + TextField { + id: stringField + Layout.fillWidth: true + text: instantiator.model.value ?? "" placeholderText: instantiator.model.placeholder - Component.onCompleted: text = instantiator.model.value ?? "" + maximumLength: instantiator.model.maximum ?? 32767 + + property bool valid: (!instantiator.model.required || text.length > 0) + && (!instantiator.model.minimum || text.length >= instantiator.model.minimum) + onEditingFinished: { - if (instantiator.model.value === text) { - return; + if (valid) { + instantiator.submit(text) } - instantiator.valueEdited(instantiator.model.property, text) } } + Label { + Layout.fillWidth: true + visible: stringField.text.length > 0 && !stringField.valid + text: qsTr("This value is shorter than the minimum length.") + color: Style.wizardErrorText + wrapMode: Text.Wrap + } } } Component { - id: unknownItem - RowLayout { + id: unknownComponent + + ColumnLayout { Label { - text: "unknown!" + text: instantiator.labelText() + } + Label { + Layout.fillWidth: true + text: qsTr("This setting is not supported by this version of the desktop client.") + color: palette.placeholderText + wrapMode: Text.Wrap } } } diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index 759877166f74c..1939af612b439 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -154,7 +154,7 @@ Page { text: qsTr("Sharing settings") font.weight: Font.DemiBold - visible: propertyList.count > 0 + visible: propertyList.count > 0 || advancedPropertyList.count > 0 } ListView { @@ -166,7 +166,6 @@ Page { spacing: Style.standardSpacing model: PropertyModel { - // TODO: only show properties with prio=1 share: root.share } @@ -181,6 +180,39 @@ Page { } } + Label { + Layout.fillWidth: true + Layout.topMargin: Style.standardSpacing + + text: qsTr("Advanced settings") + font.weight: Font.DemiBold + visible: advancedPropertyList.count > 0 + } + + ListView { + id: advancedPropertyList + + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + interactive: false + spacing: Style.standardSpacing + + model: PropertyModel { + share: root.share + advanced: true + } + + delegate: FieldDelegate { + width: advancedPropertyList.width + height: item ? item.implicitHeight : 0 + + onValueEdited: (propertyClass, value) => { + root.propertyUpdateError = "" + root.sharingController.setProperty(root.share, propertyClass, value) + } + } + } + Label { Layout.fillWidth: true diff --git a/src/gui/sharing/property.cpp b/src/gui/sharing/property.cpp index b4ddbca7f7d84..f503c105cab0b 100644 --- a/src/gui/sharing/property.cpp +++ b/src/gui/sharing/property.cpp @@ -7,8 +7,11 @@ #include "property.h" #include +#include #include +#include + using namespace Qt::StringLiterals; using namespace OCC::Gui::Sharing; @@ -18,9 +21,20 @@ QPointer Property::fromJson(const QJsonObject &json) auto property = QPointer(new Property); property->_className = json.value("class"_L1).toString(); property->_displayName = json.value("display_name"_L1).toString(); + property->_priority = json.value("priority"_L1).toInt(); property->_required = json.value("required"_L1).toBool(); + property->_advanced = json.value("advanced"_L1).toBool(); property->_hint = json.value("hint"_L1).toString(); property->_type = json.value("type"_L1).toString(); + const auto validValues = json.value("valid_values"_L1).toArray(); + property->_validValues.reserve(validValues.size()); + std::ranges::transform(validValues, std::back_inserter(property->_validValues), [](const auto &value) { + return value.toString(); + }); + property->_minDate = json.value("min_date"_L1).toVariant(); + property->_maxDate = json.value("max_date"_L1).toVariant(); + property->_minLength = json.value("min_length"_L1).toVariant(); + property->_maxLength = json.value("max_length"_L1).toVariant(); property->_value = json.value("value"_L1).toVariant(); return property; } @@ -40,16 +54,51 @@ QString Property::displayName() const return _displayName; } +int Property::priority() const +{ + return _priority; +} + bool Property::required() const { return _required; } +bool Property::advanced() const +{ + return _advanced; +} + QString Property::hint() const { return _hint; } +QStringList Property::validValues() const +{ + return _validValues; +} + +QVariant Property::minDate() const +{ + return _minDate; +} + +QVariant Property::maxDate() const +{ + return _maxDate; +} + +QVariant Property::minLength() const +{ + return _minLength; +} + +QVariant Property::maxLength() const +{ + return _maxLength; +} + QString Property::type() const { return _type; diff --git a/src/gui/sharing/property.h b/src/gui/sharing/property.h index aa1c3516e8469..e1e9e51d0573b 100644 --- a/src/gui/sharing/property.h +++ b/src/gui/sharing/property.h @@ -10,29 +10,62 @@ namespace OCC::Gui::Sharing { +/** + * @brief A server-defined setting associated with a unified share. + */ class Property : public QObject { Q_OBJECT Q_PROPERTY(QString className READ className CONSTANT) Q_PROPERTY(QString displayName READ displayName CONSTANT) + Q_PROPERTY(int priority READ priority CONSTANT) Q_PROPERTY(bool required READ required CONSTANT) + Q_PROPERTY(bool advanced READ advanced CONSTANT) Q_PROPERTY(QString hint READ hint CONSTANT) Q_PROPERTY(QString type READ type CONSTANT) + Q_PROPERTY(QStringList validValues READ validValues CONSTANT) + Q_PROPERTY(QVariant minDate READ minDate CONSTANT) + Q_PROPERTY(QVariant maxDate READ maxDate CONSTANT) + Q_PROPERTY(QVariant minLength READ minLength CONSTANT) + Q_PROPERTY(QVariant maxLength READ maxLength CONSTANT) Q_PROPERTY(QVariant value READ value WRITE setValue NOTIFY valueChanged) public: + /** @brief Creates a property from its unified sharing API representation. */ [[nodiscard]] static QPointer fromJson(const QJsonObject &json); + /** @brief Returns the registered server class identifying this property. */ [[nodiscard]] QString className() const; + /** @brief Returns the user-facing property label. */ [[nodiscard]] QString displayName() const; + /** @brief Returns the server-provided ordering priority. */ + [[nodiscard]] int priority() const; + /** @brief Returns whether the share requires a value for this property. */ [[nodiscard]] bool required() const; + /** @brief Returns whether this property belongs in the advanced settings section. */ + [[nodiscard]] bool advanced() const; + /** @brief Returns the server-defined property type. */ [[nodiscard]] QString type() const; + /** @brief Returns the optional user-facing input hint. */ [[nodiscard]] QString hint() const; + /** @brief Returns the allowed values for an enum property. */ + [[nodiscard]] QStringList validValues() const; + /** @brief Returns the optional ISO 8601 lower bound for a date property. */ + [[nodiscard]] QVariant minDate() const; + /** @brief Returns the optional ISO 8601 upper bound for a date property. */ + [[nodiscard]] QVariant maxDate() const; + /** @brief Returns the optional minimum length for a string property. */ + [[nodiscard]] QVariant minLength() const; + /** @brief Returns the optional maximum length for a string property. */ + [[nodiscard]] QVariant maxLength() const; + /** @brief Returns the current server value. */ [[nodiscard]] QVariant value() const; + /** @brief Updates the current value and emits valueChanged when it changes. */ void setValue(const QVariant &value); Q_SIGNALS: + /** @brief Emitted after the current value changes. */ void valueChanged(); private: @@ -40,9 +73,16 @@ class Property : public QObject QString _className; QString _displayName; - bool _required; + int _priority = 0; + bool _required = false; + bool _advanced = false; QString _hint; QString _type; + QStringList _validValues; + QVariant _minDate; + QVariant _maxDate; + QVariant _minLength; + QVariant _maxLength; QVariant _value; }; diff --git a/src/gui/sharing/propertymodel.cpp b/src/gui/sharing/propertymodel.cpp index f4628d095040f..075d1385a1a7b 100644 --- a/src/gui/sharing/propertymodel.cpp +++ b/src/gui/sharing/propertymodel.cpp @@ -5,11 +5,11 @@ #include "propertymodel.h" -#include - #include "share.h" #include "property.h" +#include + using namespace Qt::StringLiterals; using namespace OCC; using namespace OCC::Gui::Sharing; @@ -20,75 +20,55 @@ PropertyModel::PropertyModel(QObject *parent) int PropertyModel::rowCount(const QModelIndex &parent) const { - if (parent.isValid() || !_share) { + if (parent.isValid()) { return 0; } - return _share->properties().size(); + return _properties.size(); } QVariant PropertyModel::data(const QModelIndex &index, int role) const { - if (!_share) { + if (!index.isValid() || index.row() >= _properties.size()) { return {}; } - const auto properties = _share->properties(); - const auto property = properties.at(index.row()); + const auto property = _properties.at(index.row()); switch (role) { case LabelRole: return property->displayName(); case PropertyRole: return property->className(); - case TypeRole: - if (property->className() == "string"_L1) { - return FieldTypes::TextField; - } - // TODO: date etc. - return FieldTypes::TextField; + case TypeRole: { + static const auto fieldTypes = QHash{ + {"boolean"_L1, FieldTypes::Boolean}, + {"date"_L1, FieldTypes::Date}, + {"enum"_L1, FieldTypes::Enum}, + {"password"_L1, FieldTypes::Password}, + {"string"_L1, FieldTypes::String}, + }; + return fieldTypes.value(property->type(), FieldTypes::Unknown); + } case PlaceholderRole: return property->hint(); case ValueRole: - // return _fieldValues.value(feature->type()); return property->value(); + case RequiredRole: + return property->required(); + case AdvancedRole: + return property->advanced(); + case ValidValuesRole: + return property->validValues(); + case MinimumRole: + return property->type() == "date"_L1 ? property->minDate() : property->minLength(); + case MaximumRole: + return property->type() == "date"_L1 ? property->maxDate() : property->maxLength(); default: return {}; } } -bool PropertyModel::setData(const QModelIndex &index, const QVariant &value, int role) -{ - return false; - - qCritical() << "setData called with" << index << value << role; - - if (role != ValueRole) { - return false; - } - - if (index.row() == 0) { - qCritical() << "changing" << "recipients"_L1 << "to" << value; - _fieldValues.insert("recipients"_L1, value); // TODO - } else { - // TODO: cache this after setting accountstate - // const auto sharing = account()->account()->sharing(); - // const auto features = sharing->features().values(); - // const auto feature = features.at(index.row() - 1); // TODO: -1 to adjust the special feature for searching - // qCritical() << "changing" << feature->type() << "to" << value; - // _fieldValues.insert(feature->type(), value); - } - - Q_EMIT dataChanged(index, index, {ValueRole}); - return true; -} - -Qt::ItemFlags PropertyModel::flags(const QModelIndex &index) const -{ - qCritical() << "flags for" << index << QAbstractListModel::flags(index) << Qt::ItemIsEditable << "returning" << (QAbstractListModel::flags(index) | Qt::ItemIsEditable); - return QAbstractListModel::flags(index) | Qt::ItemIsEditable; -} - QHash PropertyModel::roleNames() const { return { @@ -97,18 +77,56 @@ QHash PropertyModel::roleNames() const { TypeRole, "type"_ba}, { PlaceholderRole, "placeholder"_ba}, { ValueRole, "value"_ba}, + { RequiredRole, "required"_ba}, + { AdvancedRole, "advanced"_ba}, + { ValidValuesRole, "validValues"_ba}, + { MinimumRole, "minimum"_ba}, + { MaximumRole, "maximum"_ba}, }; -}; +} void PropertyModel::setShare(Share *share) { AbstractShareModel::setShare(share); + resetProperties(); + if (!_share) { return; } connect(_share, &Share::propertiesChanged, this, [this]() -> void { - beginResetModel(); - endResetModel(); + resetProperties(); }); } + +bool PropertyModel::advanced() const +{ + return _advanced; +} + +void PropertyModel::setAdvanced(bool advanced) +{ + if (_advanced == advanced) { + return; + } + + _advanced = advanced; + resetProperties(); + Q_EMIT advancedChanged(); +} + +void PropertyModel::resetProperties() +{ + beginResetModel(); + _properties.clear(); + if (_share) { + const auto properties = _share->properties(); + std::ranges::copy_if(properties, std::back_inserter(_properties), [this](const auto &property) { + return property->advanced() == _advanced; + }); + std::sort(_properties.begin(), _properties.end(), [](const auto &left, const auto &right) { + return left->priority() > right->priority(); + }); + } + endResetModel(); +} diff --git a/src/gui/sharing/propertymodel.h b/src/gui/sharing/propertymodel.h index 232730e096f36..d66611dd7d7fa 100644 --- a/src/gui/sharing/propertymodel.h +++ b/src/gui/sharing/propertymodel.h @@ -6,14 +6,23 @@ #pragma once #include "abstractsharemodel.h" +#include "property.h" + +#include #include namespace OCC::Gui::Sharing { +/** + * @brief Exposes the normal or advanced properties of one share to QML. + * + * Properties are ordered from highest to lowest server-provided priority. + */ class PropertyModel : public AbstractShareModel { Q_OBJECT QML_ELEMENT + Q_PROPERTY(bool advanced READ advanced WRITE setAdvanced NOTIFY advancedChanged) public: enum Roles { @@ -22,12 +31,20 @@ class PropertyModel : public AbstractShareModel TypeRole, PlaceholderRole, ValueRole, + RequiredRole, + AdvancedRole, + ValidValuesRole, + MinimumRole, + MaximumRole, }; enum FieldTypes { - Switch, - TextField, - TextArea, + Unknown, + Boolean, + Date, + Enum, + Password, + String, }; Q_ENUM(FieldTypes) @@ -35,14 +52,24 @@ class PropertyModel : public AbstractShareModel int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; - Qt::ItemFlags flags(const QModelIndex &index) const override; QHash roleNames() const override; + /** @brief Returns whether the model exposes advanced instead of normal properties. */ + [[nodiscard]] bool advanced() const; + /** @brief Selects whether the model exposes advanced instead of normal properties. */ + void setAdvanced(bool advanced); + /** @brief Sets the share whose properties are exposed. */ void setShare(Share* share) override; +Q_SIGNALS: + /** @brief Emitted when the advanced-property filter changes. */ + void advancedChanged(); + private: - QVariantMap _fieldValues; + void resetProperties(); + + QList> _properties; + bool _advanced = false; }; } From 417db6b4544a53b3355e4d89d4f4edb58497fa05 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 11 Aug 2026 23:51:32 +0800 Subject: [PATCH 077/104] feat(sharing): Handle recipient instance Signed-off-by: Claudio Cambra --- src/gui/sharing/RecipientSearchField.qml | 13 +++++++---- src/gui/sharing/ShareDetailsPage.qml | 9 +++++--- src/gui/sharing/recipient.cpp | 5 +++++ src/gui/sharing/recipient.h | 14 ++++++++++++ src/gui/sharing/recipientmodel.cpp | 5 ++++- src/gui/sharing/recipientmodel.h | 1 + src/gui/sharing/recipientsearchmodel.cpp | 3 +++ src/gui/sharing/recipientsearchmodel.h | 1 + src/gui/sharing/sharingcontroller.cpp | 28 +++++++++++++++++++----- src/gui/sharing/sharingcontroller.h | 25 +++++++++++++++++++-- 10 files changed, 89 insertions(+), 15 deletions(-) diff --git a/src/gui/sharing/RecipientSearchField.qml b/src/gui/sharing/RecipientSearchField.qml index c39218d12fe09..200c224cfa1e2 100644 --- a/src/gui/sharing/RecipientSearchField.qml +++ b/src/gui/sharing/RecipientSearchField.qml @@ -18,7 +18,7 @@ import Style TextField { id: root - signal recipientSelected(string recipientType, string recipientValue) + signal recipientSelected(string recipientType, string recipientValue, string recipientInstance) required property var account // property bool isShareeFetchOngoing: recipientModel.fetchOngoing @@ -193,6 +193,7 @@ TextField { required property string value required property string displayName required property string iconUrl + required property var instance width: recipientListView.contentItem.width @@ -206,7 +207,10 @@ TextField { text: recipientDelegate.displayName } Label { - text: recipientDelegate.type + Layout.fillWidth: true + text: recipientDelegate.instance || "" + color: palette.placeholderText + elide: Text.ElideRight } } @@ -214,8 +218,9 @@ TextField { // hoverEnabled: model.type !== NC.recipient.LookupServerSearchResults function selectSharee() { - console.log(`recipientSelected: ${JSON.stringify([recipientDelegate.type, recipientDelegate.value])})`) - root.recipientSelected(recipientDelegate.type, recipientDelegate.value); + root.recipientSelected(recipientDelegate.type, + recipientDelegate.value, + recipientDelegate.instance || ""); suggestionsPopup.close(); root.clear(); diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index 1939af612b439..3f393da0acc74 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -45,9 +45,12 @@ Page { account: root.sharingController.account - onRecipientSelected: (recipientType, recipientValue) => { - root.recipientAdditionError = "" - root.sharingController.addRecipient(root.share, recipientType, recipientValue) + onRecipientSelected: (recipientType, recipientValue, recipientInstance) => { + root.recipientOperationError = "" + root.sharingController.addRecipient(root.share, + recipientType, + recipientValue, + recipientInstance) } } diff --git a/src/gui/sharing/recipient.cpp b/src/gui/sharing/recipient.cpp index f1ae17881f452..ccb43afb20966 100644 --- a/src/gui/sharing/recipient.cpp +++ b/src/gui/sharing/recipient.cpp @@ -40,3 +40,8 @@ QString Recipient::value() const { return _value; } + +const std::optional &Recipient::instance() const +{ + return _instance; +} diff --git a/src/gui/sharing/recipient.h b/src/gui/sharing/recipient.h index 7eefae5c3cbd9..22917bc10f22d 100644 --- a/src/gui/sharing/recipient.h +++ b/src/gui/sharing/recipient.h @@ -5,10 +5,17 @@ #pragma once +#include #include +#include + +#include namespace OCC::Gui::Sharing { +/** + * @brief A recipient attached to a unified share. + */ class Recipient : public QObject { Q_OBJECT @@ -17,11 +24,17 @@ class Recipient : public QObject Q_PROPERTY(QString value READ value CONSTANT) public: + /** @brief Creates a recipient from its unified sharing API representation. */ [[nodiscard]] static QPointer fromJson(const QJsonObject &json); + /** @brief Returns the registered server class identifying the recipient type. */ [[nodiscard]] QString className() const; + /** @brief Returns the user-facing recipient name. */ [[nodiscard]] QString displayName() const; + /** @brief Returns the recipient identifier understood by its type. */ [[nodiscard]] QString value() const; + /** @brief Returns the recipient's remote instance, or no value for a local recipient. */ + [[nodiscard]] const std::optional &instance() const; private: explicit Recipient(QObject *parent = nullptr); @@ -29,6 +42,7 @@ class Recipient : public QObject QString _className; QString _displayName; QString _value; + std::optional _instance; }; } diff --git a/src/gui/sharing/recipientmodel.cpp b/src/gui/sharing/recipientmodel.cpp index 66c04a750d3b4..a8f0a9f3e4bdb 100644 --- a/src/gui/sharing/recipientmodel.cpp +++ b/src/gui/sharing/recipientmodel.cpp @@ -44,6 +44,8 @@ QVariant RecipientModel::data(const QModelIndex &index, int role) const return recipient->className(); case ValueRole: return recipient->value(); + case InstanceRole: + return recipient->instance() ? QVariant{*recipient->instance()} : QVariant{}; default: return {}; } @@ -55,8 +57,9 @@ QHash RecipientModel::roleNames() const { LabelRole, "label"_ba}, { ClassNameRole, "className"_ba}, { ValueRole, "value"_ba}, + { InstanceRole, "instance"_ba}, }; -}; +} void RecipientModel::setShare(Share *share) { diff --git a/src/gui/sharing/recipientmodel.h b/src/gui/sharing/recipientmodel.h index df73554cd2b2d..67bc3c7fe9b19 100644 --- a/src/gui/sharing/recipientmodel.h +++ b/src/gui/sharing/recipientmodel.h @@ -21,6 +21,7 @@ class RecipientModel : public AbstractShareModel LabelRole = Qt::UserRole, ClassNameRole, ValueRole, + InstanceRole, }; explicit RecipientModel(QObject *parent = nullptr); diff --git a/src/gui/sharing/recipientsearchmodel.cpp b/src/gui/sharing/recipientsearchmodel.cpp index 311ee3824f36b..a2bfa13ddd3fc 100644 --- a/src/gui/sharing/recipientsearchmodel.cpp +++ b/src/gui/sharing/recipientsearchmodel.cpp @@ -51,6 +51,8 @@ QVariant RecipientSearchModel::data(const QModelIndex &index, int role) const return item.toObject().value("display_name"_L1).toString(); case IconRole: return "image://tray-image-provider/%1"_L1.arg(item.toObject().value("icon"_L1).toObject().value("light").toString()); + case InstanceRole: + return item.value("instance"_L1).toVariant(); default: return {}; } @@ -63,6 +65,7 @@ QHash RecipientSearchModel::roleNames() const {ValueRole, "value"_ba}, {DisplayNameRole, "displayName"_ba}, {IconRole, "iconUrl"_ba}, + {InstanceRole, "instance"_ba}, }; }; diff --git a/src/gui/sharing/recipientsearchmodel.h b/src/gui/sharing/recipientsearchmodel.h index b08dbfd3e3b78..0c2d34c628825 100644 --- a/src/gui/sharing/recipientsearchmodel.h +++ b/src/gui/sharing/recipientsearchmodel.h @@ -30,6 +30,7 @@ class RecipientSearchModel : public QAbstractListModel ValueRole, DisplayNameRole, IconRole, + InstanceRole, }; explicit RecipientSearchModel(QObject *parent = nullptr); diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index 36d5267ac0587..974421eb3fada 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -158,7 +158,10 @@ void SharingController::destroyShare(Share *share) job->start(); } -void SharingController::addRecipient(Share *share, const QString &recipientType, const QString &recipientValue) +void SharingController::addRecipient(Share *share, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance) { if (!_account) { qCWarning(lcSharingController) << "attempted to add a new recipient to a share without an account set"; @@ -171,7 +174,7 @@ void SharingController::addRecipient(Share *share, const QString &recipientType, } const auto guardedShare = QPointer{share}; - const auto job = new AddRecipientJob{_account, *share, recipientType, recipientValue}; + const auto job = new AddRecipientJob{_account, *share, recipientType, recipientValue, optionalString(recipientInstance)}; connect(job, &AddRecipientJob::shareUpdated, this, [this](QPointer updatedShare) { if (updatedShare) { Q_EMIT recipientAdded(updatedShare); @@ -186,10 +189,13 @@ void SharingController::addRecipient(Share *share, const QString &recipientType, job->start(); } -void SharingController::removeRecipient(Share *share, const QString &recipientType, const QString &recipientValue) +void SharingController::removeRecipient(Share *share, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance) { if (!_account) { - qCWarning(lcSharingController) << "attempted to remove a new recipient to a share without an account set"; + qCWarning(lcSharingController) << "attempted to remove a recipient from a share without an account set"; return; } @@ -198,7 +204,19 @@ void SharingController::removeRecipient(Share *share, const QString &recipientTy return; } - const auto job = new RemoveRecipientJob{_account, *share, recipientType, recipientValue}; + const auto guardedShare = QPointer{share}; + const auto job = new RemoveRecipientJob{_account, *share, recipientType, recipientValue, optionalString(recipientInstance)}; + connect(job, &RemoveRecipientJob::shareUpdated, this, [this](QPointer updatedShare) { + if (updatedShare) { + Q_EMIT recipientRemoved(updatedShare); + } + }); + connect(job, &RemoveRecipientJob::ocsError, this, [this, guardedShare](int, const QString &message) { + Q_EMIT recipientRemovalFailed(guardedShare, message.isEmpty() ? tr("Could not remove the recipient.") : message); + }); + connect(job, &RemoveRecipientJob::networkError, this, [this, guardedShare](const QNetworkReply *reply) { + Q_EMIT recipientRemovalFailed(guardedShare, reply ? reply->errorString() : tr("Could not remove the recipient.")); + }); job->start(); } diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index 206d113186d38..064e6a9f841fc 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -59,9 +59,30 @@ class SharingController : public QObject * @param fileId Server file ID to attach to the new share */ Q_INVOKABLE void createShare(const QString &fileId); + + /** @brief Permanently removes a share managed by this controller. */ Q_INVOKABLE void destroyShare(Share *share); - Q_INVOKABLE void addRecipient(Share *share, const QString &recipientType, const QString &recipientValue); - Q_INVOKABLE void removeRecipient(Share *share, const QString &recipientType, const QString &recipientValue); + + /** + * @brief Adds a recipient to a share. + * + * @param recipientInstance Remote server identifying a federated recipient, or an empty string for a local recipient + */ + Q_INVOKABLE void addRecipient(Share *share, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance = {}); + + /** + * @brief Removes a recipient from a share. + * + * @param recipientInstance Remote server identifying a federated recipient, or an empty string for a local recipient + */ + Q_INVOKABLE void removeRecipient(Share *share, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance = {}); + Q_INVOKABLE void setPermission(Share *share, const QString &permissionClass, bool enabled); Q_INVOKABLE void setPermissionPreset(Share *share, const QString &permissionPreset); From a047c05e33015a12b45967aae8927e203c66b2de Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 11 Aug 2026 23:54:23 +0800 Subject: [PATCH 078/104] feat(sharing): Handle shareId in search Signed-off-by: Claudio Cambra --- src/gui/sharing/RecipientSearchField.qml | 3 ++- src/gui/sharing/ShareDetailsPage.qml | 1 + src/gui/sharing/recipientsearchmodel.cpp | 33 +++++++++++++++++++++--- src/gui/sharing/recipientsearchmodel.h | 6 +++++ src/gui/sharing/searchrecipientsjob.cpp | 17 +++++++++--- src/gui/sharing/searchrecipientsjob.h | 10 ++++++- 6 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/gui/sharing/RecipientSearchField.qml b/src/gui/sharing/RecipientSearchField.qml index 200c224cfa1e2..9413aa94824b8 100644 --- a/src/gui/sharing/RecipientSearchField.qml +++ b/src/gui/sharing/RecipientSearchField.qml @@ -21,10 +21,11 @@ TextField { signal recipientSelected(string recipientType, string recipientValue, string recipientInstance) required property var account - // property bool isShareeFetchOngoing: recipientModel.fetchOngoing + required property string shareId property RecipientSearchModel recipientModel: RecipientSearchModel { account: root.account query: root.text + shareId: root.shareId } readonly property int horizontalPaddingOffset: Style.trayHorizontalMargin diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index 3f393da0acc74..7931708fe1db3 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -44,6 +44,7 @@ Page { Layout.fillWidth: true account: root.sharingController.account + shareId: root.share.id onRecipientSelected: (recipientType, recipientValue, recipientInstance) => { root.recipientOperationError = "" diff --git a/src/gui/sharing/recipientsearchmodel.cpp b/src/gui/sharing/recipientsearchmodel.cpp index a2bfa13ddd3fc..1813b4e34b178 100644 --- a/src/gui/sharing/recipientsearchmodel.cpp +++ b/src/gui/sharing/recipientsearchmodel.cpp @@ -118,13 +118,40 @@ void RecipientSearchModel::setQuery(const QString &query) _searchTimer.start(); } +QString RecipientSearchModel::shareId() const +{ + return _shareId; +} + +void RecipientSearchModel::setShareId(const QString &shareId) +{ + if (_shareId == shareId) { + return; + } + + _shareId = shareId; + beginResetModel(); + _searchResults = {}; + endResetModel(); + Q_EMIT shareIdChanged(); + if (!_query.isEmpty()) { + _searchTimer.start(); + } +} + void RecipientSearchModel::search() { const auto query = _query; const auto account = _account; - const auto job = new SearchRecipientsJob{account, query, 0, 10}; - connect(job, &SearchRecipientsJob::recipientsFound, this, [this, account, query](const QJsonArray &recipients) { - if (_account != account || _query != query) { + const auto shareId = _shareId; + const auto job = new SearchRecipientsJob{account, + query, + 0, + 10, + {}, + shareId.isEmpty() ? std::nullopt : std::optional{shareId}}; + connect(job, &SearchRecipientsJob::recipientsFound, this, [this, account, query, shareId](const QJsonArray &recipients) { + if (_account != account || _query != query || _shareId != shareId) { return; } diff --git a/src/gui/sharing/recipientsearchmodel.h b/src/gui/sharing/recipientsearchmodel.h index 0c2d34c628825..1635d6b46f5c8 100644 --- a/src/gui/sharing/recipientsearchmodel.h +++ b/src/gui/sharing/recipientsearchmodel.h @@ -23,6 +23,7 @@ class RecipientSearchModel : public QAbstractListModel Q_PROPERTY(AccountPtr account READ account WRITE setAccount NOTIFY accountChanged) Q_PROPERTY(QString query READ query WRITE setQuery NOTIFY queryChanged) + Q_PROPERTY(QString shareId READ shareId WRITE setShareId NOTIFY shareIdChanged) public: enum Roles { @@ -45,14 +46,19 @@ class RecipientSearchModel : public QAbstractListModel [[nodiscard]] QString query() const; void setQuery(const QString &query); + [[nodiscard]] QString shareId() const; + void setShareId(const QString &shareId); + Q_SIGNALS: void accountChanged(); void queryChanged(); + void shareIdChanged(); private: AccountPtr _account = nullptr; QJsonArray _searchResults; QString _query; + QString _shareId; QTimer _searchTimer; void search(); diff --git a/src/gui/sharing/searchrecipientsjob.cpp b/src/gui/sharing/searchrecipientsjob.cpp index 1aae23ca49464..8f531b3eb2b72 100644 --- a/src/gui/sharing/searchrecipientsjob.cpp +++ b/src/gui/sharing/searchrecipientsjob.cpp @@ -20,7 +20,8 @@ constexpr auto requestTimeoutMsec = 10 * 1000; QList> searchRecipientsParameters(const QString &query, qint64 offset, qint64 limit, - const QList &recipientTypeClasses) + const QList &recipientTypeClasses, + const std::optional &shareId) { auto parameters = QList>{ {"query"_L1, query}, @@ -28,17 +29,25 @@ QList> searchRecipientsParameters(const QString &query, {"limit"_L1, QString::number(limit)}, }; for (const auto &recipientTypeClass : recipientTypeClasses) { - parameters.emplaceBack("recipientTypeClasses[]"_L1, recipientTypeClass); + parameters.emplaceBack("filterRecipientTypeClasses[]"_L1, recipientTypeClass); + } + if (shareId) { + parameters.emplaceBack("id"_L1, *shareId); } return parameters; } } -SearchRecipientsJob::SearchRecipientsJob(AccountPtr account, const QString &query, qint64 offset, qint64 limit, const QList &recipientTypeClasses) +SearchRecipientsJob::SearchRecipientsJob(AccountPtr account, + const QString &query, + qint64 offset, + qint64 limit, + const QList &recipientTypeClasses, + const std::optional &shareId) : UnifiedSharingRequest{std::move(account), "/ocs/v2.php/apps/sharing/api/v1/recipients"_L1, "GET"_ba, - {.parameters = searchRecipientsParameters(query, offset, limit, recipientTypeClasses)}} + {.parameters = searchRecipientsParameters(query, offset, limit, recipientTypeClasses, shareId)}} { setTimeout(requestTimeoutMsec); connect(this, &OcsJob::jobFinished, this, [this](const QJsonDocument &json, int) { diff --git a/src/gui/sharing/searchrecipientsjob.h b/src/gui/sharing/searchrecipientsjob.h index 4b9224c1d8f5e..464085e31f545 100644 --- a/src/gui/sharing/searchrecipientsjob.h +++ b/src/gui/sharing/searchrecipientsjob.h @@ -10,6 +10,8 @@ #include #include +#include + namespace OCC::Gui::Sharing { @@ -33,8 +35,14 @@ class SearchRecipientsJob : public UnifiedSharingRequest * @param offset Number of matching entries to skip * @param limit Maximum number of matching entries to return * @param recipientTypeClasses Registered recipient type classes to search, or empty for all + * @param shareId Share whose existing recipients should be excluded, or no value to search without that filter */ - explicit SearchRecipientsJob(AccountPtr account, const QString &query, qint64 offset, qint64 limit, const QList &recipientTypeClasses = {}); + explicit SearchRecipientsJob(AccountPtr account, + const QString &query, + qint64 offset, + qint64 limit, + const QList &recipientTypeClasses = {}, + const std::optional &shareId = std::nullopt); Q_SIGNALS: /** @brief Emitted with the matching recipient descriptions after a successful request. */ From fbbe78d96d75e628ef78da402234f1daa0eeba55 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 11 Aug 2026 23:55:04 +0800 Subject: [PATCH 079/104] instance Signed-off-by: Claudio Cambra --- src/gui/sharing/recipient.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/gui/sharing/recipient.cpp b/src/gui/sharing/recipient.cpp index ccb43afb20966..9f62dc9bb331a 100644 --- a/src/gui/sharing/recipient.cpp +++ b/src/gui/sharing/recipient.cpp @@ -18,6 +18,9 @@ QPointer Recipient::fromJson(const QJsonObject &json) recipient->_className = json.value("class"_L1).toString(); recipient->_displayName = json.value("display_name"_L1).toString(); recipient->_value = json.value("value"_L1).toString(); + if (const auto instance = json.value("instance"_L1); instance.isString()) { + recipient->_instance = instance.toString(); + } return recipient; } From 5cec2e233b2ed6b04ba53bf13d1a56ba917026cb Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 12 Aug 2026 00:16:49 +0800 Subject: [PATCH 080/104] feat(sharing): Handle various icons for recipients Signed-off-by: Claudio Cambra --- src/gui/sharing/CMakeLists.txt | 3 +++ src/gui/sharing/RecipientIcon.qml | 19 +++++++++++++++++++ src/gui/sharing/RecipientSearchField.qml | 8 ++++++-- src/gui/sharing/recipient.cpp | 21 +++++++++++++++++++++ src/gui/sharing/recipient.h | 9 +++++++++ src/gui/sharing/recipienticonutils.cpp | 17 +++++++++++++++++ src/gui/sharing/recipienticonutils.h | 16 ++++++++++++++++ src/gui/sharing/recipientmodel.cpp | 10 ++++++++++ src/gui/sharing/recipientmodel.h | 3 +++ src/gui/sharing/recipientsearchmodel.cpp | 16 ++++++++++++---- src/gui/sharing/recipientsearchmodel.h | 4 +++- 11 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 src/gui/sharing/RecipientIcon.qml create mode 100644 src/gui/sharing/recipienticonutils.cpp create mode 100644 src/gui/sharing/recipienticonutils.h diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index 94947b42c3e72..de3d1329d8da0 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -52,6 +52,8 @@ target_sources(nextcloudGuiSharing propertymodel.cpp recipient.h recipient.cpp + recipienticonutils.h + recipienticonutils.cpp recipientmodel.h recipientmodel.cpp recipientsearchmodel.h @@ -68,6 +70,7 @@ ecm_add_qml_module(nextcloudGuiSharing QML_FILES FieldDelegate.qml MainPage.qml + RecipientIcon.qml RecipientSearchField.qml ShareDetailsPage.qml ShareEntry.qml diff --git a/src/gui/sharing/RecipientIcon.qml b/src/gui/sharing/RecipientIcon.qml new file mode 100644 index 0000000000000..516e584b0802e --- /dev/null +++ b/src/gui/sharing/RecipientIcon.qml @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +pragma Singleton + +import QtQuick +import QtQuick.Controls + +QtObject { + function source(svgUrl: string, light: string, dark: string): string { + if (svgUrl) { + return svgUrl + } + const icon = Application.styleHints.colorScheme === Qt.ColorScheme.Dark ? dark : light + return icon ? `image://tray-image-provider/${icon}` : "" + } +} diff --git a/src/gui/sharing/RecipientSearchField.qml b/src/gui/sharing/RecipientSearchField.qml index 9413aa94824b8..646e54b66f25a 100644 --- a/src/gui/sharing/RecipientSearchField.qml +++ b/src/gui/sharing/RecipientSearchField.qml @@ -193,8 +193,10 @@ TextField { required property string type required property string value required property string displayName - required property string iconUrl required property var instance + required property string iconSvgUrl + required property string iconLight + required property string iconDark width: recipientListView.contentItem.width @@ -202,7 +204,9 @@ TextField { contentItem: RowLayout { Image { - source: recipientDelegate.iconUrl + source: RecipientIcon.source(recipientDelegate.iconSvgUrl, + recipientDelegate.iconLight, + recipientDelegate.iconDark) } Label { text: recipientDelegate.displayName diff --git a/src/gui/sharing/recipient.cpp b/src/gui/sharing/recipient.cpp index 9f62dc9bb331a..f39b28ff5f678 100644 --- a/src/gui/sharing/recipient.cpp +++ b/src/gui/sharing/recipient.cpp @@ -21,6 +21,12 @@ QPointer Recipient::fromJson(const QJsonObject &json) if (const auto instance = json.value("instance"_L1); instance.isString()) { recipient->_instance = instance.toString(); } + + const auto icon = json.value("icon"_L1).toObject(); + recipient->_iconSvg = icon.value("svg"_L1).toString(); + recipient->_iconLight = icon.value("light"_L1).toString(); + recipient->_iconDark = icon.value("dark"_L1).toString(); + return recipient; } @@ -48,3 +54,18 @@ const std::optional &Recipient::instance() const { return _instance; } + +QString Recipient::iconSvg() const +{ + return _iconSvg; +} + +QString Recipient::iconLight() const +{ + return _iconLight; +} + +QString Recipient::iconDark() const +{ + return _iconDark; +} diff --git a/src/gui/sharing/recipient.h b/src/gui/sharing/recipient.h index 22917bc10f22d..ff9e4e1940fd9 100644 --- a/src/gui/sharing/recipient.h +++ b/src/gui/sharing/recipient.h @@ -35,6 +35,12 @@ class Recipient : public QObject [[nodiscard]] QString value() const; /** @brief Returns the recipient's remote instance, or no value for a local recipient. */ [[nodiscard]] const std::optional &instance() const; + /** @brief Returns a themeable SVG icon supplied by the server, if present. */ + [[nodiscard]] QString iconSvg() const; + /** @brief Returns the light-theme icon URL supplied by the server, if present. */ + [[nodiscard]] QString iconLight() const; + /** @brief Returns the dark-theme icon URL supplied by the server, if present. */ + [[nodiscard]] QString iconDark() const; private: explicit Recipient(QObject *parent = nullptr); @@ -43,6 +49,9 @@ class Recipient : public QObject QString _displayName; QString _value; std::optional _instance; + QString _iconSvg; + QString _iconLight; + QString _iconDark; }; } diff --git a/src/gui/sharing/recipienticonutils.cpp b/src/gui/sharing/recipienticonutils.cpp new file mode 100644 index 0000000000000..35a71f59d6306 --- /dev/null +++ b/src/gui/sharing/recipienticonutils.cpp @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "recipienticonutils.h" + +using namespace Qt::StringLiterals; + +QString OCC::Gui::Sharing::RecipientIconUtils::svgDataUrl(const QString &svg) +{ + if (svg.isEmpty()) { + return {}; + } + + return "data:image/svg+xml;base64,%1"_L1.arg(QString::fromLatin1(svg.toUtf8().toBase64())); +} diff --git a/src/gui/sharing/recipienticonutils.h b/src/gui/sharing/recipienticonutils.h new file mode 100644 index 0000000000000..b51d0f63fdb34 --- /dev/null +++ b/src/gui/sharing/recipienticonutils.h @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include + +namespace OCC::Gui::Sharing::RecipientIconUtils +{ + +// Encodes server-provided SVG XML as an image URL consumable by a QML Image. +[[nodiscard]] QString svgDataUrl(const QString &svg); + +} diff --git a/src/gui/sharing/recipientmodel.cpp b/src/gui/sharing/recipientmodel.cpp index a8f0a9f3e4bdb..e7597a60122de 100644 --- a/src/gui/sharing/recipientmodel.cpp +++ b/src/gui/sharing/recipientmodel.cpp @@ -9,6 +9,7 @@ #include "share.h" #include "recipient.h" +#include "recipienticonutils.h" using namespace Qt::StringLiterals; using namespace OCC; @@ -46,6 +47,12 @@ QVariant RecipientModel::data(const QModelIndex &index, int role) const return recipient->value(); case InstanceRole: return recipient->instance() ? QVariant{*recipient->instance()} : QVariant{}; + case IconSvgUrlRole: + return RecipientIconUtils::svgDataUrl(recipient->iconSvg()); + case IconLightRole: + return recipient->iconLight(); + case IconDarkRole: + return recipient->iconDark(); default: return {}; } @@ -58,6 +65,9 @@ QHash RecipientModel::roleNames() const { ClassNameRole, "className"_ba}, { ValueRole, "value"_ba}, { InstanceRole, "instance"_ba}, + { IconSvgUrlRole, "iconSvgUrl"_ba}, + { IconLightRole, "iconLight"_ba}, + { IconDarkRole, "iconDark"_ba}, }; } diff --git a/src/gui/sharing/recipientmodel.h b/src/gui/sharing/recipientmodel.h index 67bc3c7fe9b19..4ef0bf18b6016 100644 --- a/src/gui/sharing/recipientmodel.h +++ b/src/gui/sharing/recipientmodel.h @@ -22,6 +22,9 @@ class RecipientModel : public AbstractShareModel ClassNameRole, ValueRole, InstanceRole, + IconSvgUrlRole, + IconLightRole, + IconDarkRole, }; explicit RecipientModel(QObject *parent = nullptr); diff --git a/src/gui/sharing/recipientsearchmodel.cpp b/src/gui/sharing/recipientsearchmodel.cpp index 1813b4e34b178..faacc22dc1e08 100644 --- a/src/gui/sharing/recipientsearchmodel.cpp +++ b/src/gui/sharing/recipientsearchmodel.cpp @@ -9,6 +9,7 @@ #include #include "searchrecipientsjob.h" +#include "recipienticonutils.h" Q_LOGGING_CATEGORY(lcSharingRecipientShareModel, "nextcloud.gui.sharing.recipientsearchmodel", QtInfoMsg) @@ -41,6 +42,7 @@ int RecipientSearchModel::rowCount(const QModelIndex &parent) const QVariant RecipientSearchModel::data(const QModelIndex &index, int role) const { const auto item = _searchResults.at(index.row()); + const auto icon = item.value("icon"_L1).toObject(); switch (role) { case TypeRole: @@ -48,11 +50,15 @@ QVariant RecipientSearchModel::data(const QModelIndex &index, int role) const case ValueRole: return item.toObject().value("value"_L1).toString(); case DisplayNameRole: - return item.toObject().value("display_name"_L1).toString(); - case IconRole: - return "image://tray-image-provider/%1"_L1.arg(item.toObject().value("icon"_L1).toObject().value("light").toString()); + return item.value("display_name"_L1).toString(); case InstanceRole: return item.value("instance"_L1).toVariant(); + case IconSvgUrlRole: + return RecipientIconUtils::svgDataUrl(icon.value("svg"_L1).toString()); + case IconLightRole: + return icon.value("light"_L1).toString(); + case IconDarkRole: + return icon.value("dark"_L1).toString(); default: return {}; } @@ -64,8 +70,10 @@ QHash RecipientSearchModel::roleNames() const {TypeRole, "type"_ba}, {ValueRole, "value"_ba}, {DisplayNameRole, "displayName"_ba}, - {IconRole, "iconUrl"_ba}, {InstanceRole, "instance"_ba}, + {IconSvgUrlRole, "iconSvgUrl"_ba}, + {IconLightRole, "iconLight"_ba}, + {IconDarkRole, "iconDark"_ba}, }; }; diff --git a/src/gui/sharing/recipientsearchmodel.h b/src/gui/sharing/recipientsearchmodel.h index 1635d6b46f5c8..a4fd4c2a8d0d6 100644 --- a/src/gui/sharing/recipientsearchmodel.h +++ b/src/gui/sharing/recipientsearchmodel.h @@ -30,8 +30,10 @@ class RecipientSearchModel : public QAbstractListModel TypeRole = Qt::UserRole, ValueRole, DisplayNameRole, - IconRole, InstanceRole, + IconSvgUrlRole, + IconLightRole, + IconDarkRole, }; explicit RecipientSearchModel(QObject *parent = nullptr); From 17ada08c6b61d47769be367b3a41a9bd783f2500 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 12 Aug 2026 00:46:19 +0800 Subject: [PATCH 081/104] feat(sharing): Add secret handling Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareDetailsPage.qml | 12 ++++++ src/gui/sharing/recipient.cpp | 24 +++++++++++ src/gui/sharing/recipient.h | 11 ++++++ src/gui/sharing/recipientmodel.cpp | 9 +++++ src/gui/sharing/recipientmodel.h | 3 ++ src/gui/sharing/sharingcontroller.cpp | 57 +++++++++++++++++++++++++++ src/gui/sharing/sharingcontroller.h | 15 +++++++ 7 files changed, 131 insertions(+) diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index 7931708fe1db3..0984754c07cb1 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -283,6 +283,18 @@ Page { } } + function onRecipientSecretUpdated(share) { + if (share === root.share) { + root.recipientOperationError = "" + } + } + + function onRecipientSecretUpdateFailed(share, error) { + if (share === root.share) { + root.recipientOperationError = error + } + } + function onPropertyUpdateFailed(share, error) { if (share === root.share) { root.propertyUpdateError = error diff --git a/src/gui/sharing/recipient.cpp b/src/gui/sharing/recipient.cpp index f39b28ff5f678..a8a479f2d3174 100644 --- a/src/gui/sharing/recipient.cpp +++ b/src/gui/sharing/recipient.cpp @@ -27,6 +27,15 @@ QPointer Recipient::fromJson(const QJsonObject &json) recipient->_iconLight = icon.value("light"_L1).toString(); recipient->_iconDark = icon.value("dark"_L1).toString(); + const auto secret = json.value("secret"_L1).toObject(); + recipient->_secretUpdatable = secret.value("updatable"_L1).toBool(); + if (const auto value = secret.value("value"_L1); value.isString()) { + recipient->_secretValue = value.toString(); + } + if (const auto url = secret.value("url"_L1); url.isString()) { + recipient->_secretUrl = url.toString(); + } + return recipient; } @@ -69,3 +78,18 @@ QString Recipient::iconDark() const { return _iconDark; } + +bool Recipient::secretUpdatable() const +{ + return _secretUpdatable; +} + +const std::optional &Recipient::secretValue() const +{ + return _secretValue; +} + +const std::optional &Recipient::secretUrl() const +{ + return _secretUrl; +} diff --git a/src/gui/sharing/recipient.h b/src/gui/sharing/recipient.h index ff9e4e1940fd9..51d49c8c720ba 100644 --- a/src/gui/sharing/recipient.h +++ b/src/gui/sharing/recipient.h @@ -22,6 +22,7 @@ class Recipient : public QObject Q_PROPERTY(QString className READ className CONSTANT) Q_PROPERTY(QString displayName READ displayName CONSTANT) Q_PROPERTY(QString value READ value CONSTANT) + Q_PROPERTY(bool secretUpdatable READ secretUpdatable CONSTANT) public: /** @brief Creates a recipient from its unified sharing API representation. */ @@ -41,6 +42,13 @@ class Recipient : public QObject [[nodiscard]] QString iconLight() const; /** @brief Returns the dark-theme icon URL supplied by the server, if present. */ [[nodiscard]] QString iconDark() const; + /** @brief Returns whether the server allows this recipient's secret to be replaced. */ + [[nodiscard]] bool secretUpdatable() const; + /** @brief Returns the public secret value, when the recipient type exposes it. */ + [[nodiscard]] const std::optional &secretValue() const; + /** @brief Returns the public URL associated with the recipient secret, when exposed. */ + [[nodiscard]] const std::optional &secretUrl() const; + /** @brief Returns the user-facing name of the user who added the recipient. */ private: explicit Recipient(QObject *parent = nullptr); @@ -52,6 +60,9 @@ class Recipient : public QObject QString _iconSvg; QString _iconLight; QString _iconDark; + bool _secretUpdatable = false; + std::optional _secretValue; + std::optional _secretUrl; }; } diff --git a/src/gui/sharing/recipientmodel.cpp b/src/gui/sharing/recipientmodel.cpp index e7597a60122de..a4d248f2a4366 100644 --- a/src/gui/sharing/recipientmodel.cpp +++ b/src/gui/sharing/recipientmodel.cpp @@ -53,6 +53,12 @@ QVariant RecipientModel::data(const QModelIndex &index, int role) const return recipient->iconLight(); case IconDarkRole: return recipient->iconDark(); + case SecretUpdatableRole: + return recipient->secretUpdatable(); + case SecretValueRole: + return recipient->secretValue() ? QVariant{*recipient->secretValue()} : QVariant{}; + case SecretUrlRole: + return recipient->secretUrl() ? QVariant{*recipient->secretUrl()} : QVariant{}; default: return {}; } @@ -68,6 +74,9 @@ QHash RecipientModel::roleNames() const { IconSvgUrlRole, "iconSvgUrl"_ba}, { IconLightRole, "iconLight"_ba}, { IconDarkRole, "iconDark"_ba}, + { SecretUpdatableRole, "secretUpdatable"_ba}, + { SecretValueRole, "secretValue"_ba}, + { SecretUrlRole, "secretUrl"_ba}, }; } diff --git a/src/gui/sharing/recipientmodel.h b/src/gui/sharing/recipientmodel.h index 4ef0bf18b6016..13d25547ba16e 100644 --- a/src/gui/sharing/recipientmodel.h +++ b/src/gui/sharing/recipientmodel.h @@ -25,6 +25,9 @@ class RecipientModel : public AbstractShareModel IconSvgUrlRole, IconLightRole, IconDarkRole, + SecretUpdatableRole, + SecretValueRole, + SecretUrlRole, }; explicit RecipientModel(QObject *parent = nullptr); diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index 974421eb3fada..c7bb2cf6c646b 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -15,11 +15,13 @@ #include "addsourcejob.h" #include "createsharejob.h" #include "destroysharejob.h" +#include "generatesecretjob.h" #include "getsharesjob.h" #include "removerecipientjob.h" #include "setpermissionjob.h" #include "setpermissionpresetjob.h" #include "setpropertyjob.h" +#include "setrecipientsecretjob.h" #include "setsharestatejob.h" #include "share.h" #include "sharingconstants.h" @@ -31,6 +33,14 @@ using namespace Qt::StringLiterals; using namespace OCC; using namespace OCC::Gui::Sharing; +namespace +{ +std::optional optionalString(const QString &value) +{ + return value.isEmpty() ? std::nullopt : std::optional{value}; +} +} + SharingController::SharingController(QObject *parent) : QObject{parent} { @@ -220,6 +230,53 @@ void SharingController::removeRecipient(Share *share, job->start(); } +void SharingController::updateRecipientSecret(Share *share, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance) +{ + if (!_account) { + qCWarning(lcSharingController) << "attempted to update a recipient secret without an account set"; + return; + } + + if (!containsShare(share)) { + qCWarning(lcSharingController) << "attempted to update a recipient secret on a share not owned by this controller"; + return; + } + + const auto guardedShare = QPointer{share}; + const auto instance = optionalString(recipientInstance); + const auto generateJob = new GenerateSecretJob{_account}; + connect(generateJob, &GenerateSecretJob::secretGenerated, this, [this, guardedShare, recipientType, recipientValue, instance](const QString &secret) { + if (!guardedShare || secret.isEmpty()) { + Q_EMIT recipientSecretUpdateFailed(guardedShare, tr("The server did not generate a valid sharing link.")); + return; + } + + const auto updateJob = new SetRecipientSecretJob{_account, *guardedShare, recipientType, recipientValue, secret, instance}; + connect(updateJob, &SetRecipientSecretJob::shareUpdated, this, [this](QPointer updatedShare) { + if (updatedShare) { + Q_EMIT recipientSecretUpdated(updatedShare); + } + }); + connect(updateJob, &SetRecipientSecretJob::ocsError, this, [this, guardedShare](int, const QString &message) { + Q_EMIT recipientSecretUpdateFailed(guardedShare, message.isEmpty() ? tr("Could not update the sharing link.") : message); + }); + connect(updateJob, &SetRecipientSecretJob::networkError, this, [this, guardedShare](const QNetworkReply *reply) { + Q_EMIT recipientSecretUpdateFailed(guardedShare, reply ? reply->errorString() : tr("Could not update the sharing link.")); + }); + updateJob->start(); + }); + connect(generateJob, &GenerateSecretJob::ocsError, this, [this, guardedShare](int, const QString &message) { + Q_EMIT recipientSecretUpdateFailed(guardedShare, message.isEmpty() ? tr("Could not generate a sharing link.") : message); + }); + connect(generateJob, &GenerateSecretJob::networkError, this, [this, guardedShare](const QNetworkReply *reply) { + Q_EMIT recipientSecretUpdateFailed(guardedShare, reply ? reply->errorString() : tr("Could not generate a sharing link.")); + }); + generateJob->start(); +} + void SharingController::setPermission(Share *share, const QString &permissionClass, bool enabled) { if (!_account) { diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index 064e6a9f841fc..d3fd79f74fb4e 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -83,6 +83,15 @@ class SharingController : public QObject const QString &recipientValue, const QString &recipientInstance = {}); + /** + * @brief Generates and assigns a new secret to a recipient. + * + * @param recipientInstance Remote server identifying a federated recipient, or an empty string for a local recipient + */ + Q_INVOKABLE void updateRecipientSecret(Share *share, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance = {}); Q_INVOKABLE void setPermission(Share *share, const QString &permissionClass, bool enabled); Q_INVOKABLE void setPermissionPreset(Share *share, const QString &permissionPreset); @@ -107,6 +116,12 @@ class SharingController : public QObject /** @brief Emitted when adding a recipient failed. */ void recipientAdditionFailed(Share *share, const QString &error); + /** @brief Emitted after a recipient secret was generated and applied. */ + void recipientSecretUpdated(Share *share); + + /** @brief Emitted when generating or applying a recipient secret failed. */ + void recipientSecretUpdateFailed(Share *share, const QString &error); + /** @brief Emitted after a share property was updated. */ void propertyUpdated(Share *share); From b03b85d3e2272841743249061ba7ac50b6f91e85 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 12 Aug 2026 00:49:41 +0800 Subject: [PATCH 082/104] feat(sharing): Handle initiator display name Signed-off-by: Claudio Cambra --- src/gui/sharing/recipient.cpp | 6 ++++++ src/gui/sharing/recipient.h | 2 ++ src/gui/sharing/recipientmodel.cpp | 3 +++ src/gui/sharing/recipientmodel.h | 1 + 4 files changed, 12 insertions(+) diff --git a/src/gui/sharing/recipient.cpp b/src/gui/sharing/recipient.cpp index a8a479f2d3174..3b03cde4ef5fd 100644 --- a/src/gui/sharing/recipient.cpp +++ b/src/gui/sharing/recipient.cpp @@ -36,6 +36,7 @@ QPointer Recipient::fromJson(const QJsonObject &json) recipient->_secretUrl = url.toString(); } + recipient->_initiatorDisplayName = json.value("initiator"_L1).toObject().value("display_name"_L1).toString(); return recipient; } @@ -93,3 +94,8 @@ const std::optional &Recipient::secretUrl() const { return _secretUrl; } + +QString Recipient::initiatorDisplayName() const +{ + return _initiatorDisplayName; +} diff --git a/src/gui/sharing/recipient.h b/src/gui/sharing/recipient.h index 51d49c8c720ba..dc7b888e01dd2 100644 --- a/src/gui/sharing/recipient.h +++ b/src/gui/sharing/recipient.h @@ -49,6 +49,7 @@ class Recipient : public QObject /** @brief Returns the public URL associated with the recipient secret, when exposed. */ [[nodiscard]] const std::optional &secretUrl() const; /** @brief Returns the user-facing name of the user who added the recipient. */ + [[nodiscard]] QString initiatorDisplayName() const; private: explicit Recipient(QObject *parent = nullptr); @@ -63,6 +64,7 @@ class Recipient : public QObject bool _secretUpdatable = false; std::optional _secretValue; std::optional _secretUrl; + QString _initiatorDisplayName; }; } diff --git a/src/gui/sharing/recipientmodel.cpp b/src/gui/sharing/recipientmodel.cpp index a4d248f2a4366..f190c3795fd87 100644 --- a/src/gui/sharing/recipientmodel.cpp +++ b/src/gui/sharing/recipientmodel.cpp @@ -59,6 +59,8 @@ QVariant RecipientModel::data(const QModelIndex &index, int role) const return recipient->secretValue() ? QVariant{*recipient->secretValue()} : QVariant{}; case SecretUrlRole: return recipient->secretUrl() ? QVariant{*recipient->secretUrl()} : QVariant{}; + case InitiatorDisplayNameRole: + return recipient->initiatorDisplayName(); default: return {}; } @@ -77,6 +79,7 @@ QHash RecipientModel::roleNames() const { SecretUpdatableRole, "secretUpdatable"_ba}, { SecretValueRole, "secretValue"_ba}, { SecretUrlRole, "secretUrl"_ba}, + { InitiatorDisplayNameRole, "initiatorDisplayName"_ba}, }; } diff --git a/src/gui/sharing/recipientmodel.h b/src/gui/sharing/recipientmodel.h index 13d25547ba16e..135801e1210a3 100644 --- a/src/gui/sharing/recipientmodel.h +++ b/src/gui/sharing/recipientmodel.h @@ -28,6 +28,7 @@ class RecipientModel : public AbstractShareModel SecretUpdatableRole, SecretValueRole, SecretUrlRole, + InitiatorDisplayNameRole, }; explicit RecipientModel(QObject *parent = nullptr); From 5cac92ff5dc8c70d26545116378b1962ceef2b20 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 12 Aug 2026 00:50:58 +0800 Subject: [PATCH 083/104] feat(sharing): Fix removal handling Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareDetailsPage.qml | 17 +++++++++++++++-- src/gui/sharing/sharingcontroller.h | 6 ++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index 0984754c07cb1..90798f2bba1ee 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -18,7 +18,7 @@ Page { required property SharingController sharingController required property Share share - property string recipientAdditionError: "" + property string recipientOperationError: "" property string propertyUpdateError: "" property string shareActivationError: "" property bool activatingShare: false @@ -274,12 +274,25 @@ Page { function onRecipientAdded(share) { if (share === root.share) { recipientSearch.clear() + root.recipientOperationError = "" } } function onRecipientAdditionFailed(share, error) { if (share === root.share) { - root.recipientAdditionError = error + root.recipientOperationError = error + } + } + + function onRecipientRemoved(share) { + if (share === root.share) { + root.recipientOperationError = "" + } + } + + function onRecipientRemovalFailed(share, error) { + if (share === root.share) { + root.recipientOperationError = error } } diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index d3fd79f74fb4e..6b2e4486bcc42 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -116,6 +116,12 @@ class SharingController : public QObject /** @brief Emitted when adding a recipient failed. */ void recipientAdditionFailed(Share *share, const QString &error); + /** @brief Emitted after a recipient was removed and the Share was updated. */ + void recipientRemoved(Share *share); + + /** @brief Emitted when removing a recipient failed. */ + void recipientRemovalFailed(Share *share, const QString &error); + /** @brief Emitted after a recipient secret was generated and applied. */ void recipientSecretUpdated(Share *share); From bbbc0ca0b5f8756cc045d296e34e6510b0aa0b5a Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 12 Aug 2026 00:54:09 +0800 Subject: [PATCH 084/104] feat(sharing): Clean up data method index handling and value retrieval Signed-off-by: Claudio Cambra --- src/gui/sharing/recipientmodel.cpp | 5 +---- src/gui/sharing/recipientsearchmodel.cpp | 8 +++++--- src/gui/sharing/sharingcontroller.h | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/gui/sharing/recipientmodel.cpp b/src/gui/sharing/recipientmodel.cpp index f190c3795fd87..42b5a1a46bbb9 100644 --- a/src/gui/sharing/recipientmodel.cpp +++ b/src/gui/sharing/recipientmodel.cpp @@ -25,15 +25,12 @@ int RecipientModel::rowCount(const QModelIndex &parent) const return 0; } - qCritical() << "recipients size:" << _share->recipients().size(); return _share->recipients().size(); } QVariant RecipientModel::data(const QModelIndex &index, int role) const { - if (!_share) { - return {}; - } + Q_ASSERT(checkIndex(index, CheckIndexOption::IndexIsValid | CheckIndexOption::ParentIsInvalid)); const auto recipients = _share->recipients(); const auto recipient = recipients.at(index.row()); diff --git a/src/gui/sharing/recipientsearchmodel.cpp b/src/gui/sharing/recipientsearchmodel.cpp index faacc22dc1e08..10f5f46f9ec95 100644 --- a/src/gui/sharing/recipientsearchmodel.cpp +++ b/src/gui/sharing/recipientsearchmodel.cpp @@ -41,14 +41,16 @@ int RecipientSearchModel::rowCount(const QModelIndex &parent) const QVariant RecipientSearchModel::data(const QModelIndex &index, int role) const { - const auto item = _searchResults.at(index.row()); + Q_ASSERT(checkIndex(index, CheckIndexOption::IndexIsValid | CheckIndexOption::ParentIsInvalid)); + + const auto item = _searchResults.at(index.row()).toObject(); const auto icon = item.value("icon"_L1).toObject(); switch (role) { case TypeRole: - return item.toObject().value("class"_L1).toString(); + return item.value("class"_L1).toString(); case ValueRole: - return item.toObject().value("value"_L1).toString(); + return item.value("value"_L1).toString(); case DisplayNameRole: return item.value("display_name"_L1).toString(); case InstanceRole: diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index 6b2e4486bcc42..219d12e06e62c 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -57,7 +57,7 @@ class SharingController : public QObject * call while creation is in progress is ignored. * * @param fileId Server file ID to attach to the new share - */ + */ Q_INVOKABLE void createShare(const QString &fileId); /** @brief Permanently removes a share managed by this controller. */ From 756a08e156f285406d916ab41a43047743053fad Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 12 Aug 2026 01:05:44 +0800 Subject: [PATCH 085/104] feat(sharing): Fix return for QML of secret types Signed-off-by: Claudio Cambra --- src/gui/sharing/recipientmodel.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gui/sharing/recipientmodel.cpp b/src/gui/sharing/recipientmodel.cpp index 42b5a1a46bbb9..abb0a3ccf7d35 100644 --- a/src/gui/sharing/recipientmodel.cpp +++ b/src/gui/sharing/recipientmodel.cpp @@ -53,9 +53,9 @@ QVariant RecipientModel::data(const QModelIndex &index, int role) const case SecretUpdatableRole: return recipient->secretUpdatable(); case SecretValueRole: - return recipient->secretValue() ? QVariant{*recipient->secretValue()} : QVariant{}; + return recipient->secretValue().value_or(QString{}); case SecretUrlRole: - return recipient->secretUrl() ? QVariant{*recipient->secretUrl()} : QVariant{}; + return recipient->secretUrl().value_or(QString{}); case InitiatorDisplayNameRole: return recipient->initiatorDisplayName(); default: From e380dcdc87d858db7990c042baf9186a08c66899 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 12 Aug 2026 01:06:07 +0800 Subject: [PATCH 086/104] feat(sharing): Improve recipient delegates Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareDetailsPage.qml | 134 ++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 4 deletions(-) diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index 90798f2bba1ee..9912868ea1059 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -25,6 +25,18 @@ Page { title: qsTr("Share details") + function copyToClipboard(value: string): void { + clipboardHelper.text = value + clipboardHelper.selectAll() + clipboardHelper.copy() + clipboardHelper.clear() + } + + TextEdit { + id: clipboardHelper + visible: false + } + ColumnLayout { anchors.fill: parent @@ -58,7 +70,7 @@ Page { Label { Layout.fillWidth: true - text: root.recipientAdditionError + text: root.recipientOperationError color: Style.wizardErrorText wrapMode: Text.Wrap visible: text.length > 0 @@ -74,12 +86,126 @@ Page { share: root.share } - delegate: Label { + delegate: ItemDelegate { + id: recipientDelegate + required property var model width: ListView.view.width - text: model.label - elide: Text.ElideRight + hoverEnabled: true + + contentItem: RowLayout { + spacing: Style.standardSpacing + + Image { + Layout.preferredWidth: Style.activityListButtonIconSize + Layout.preferredHeight: Style.activityListButtonIconSize + + source: RecipientIcon.source(recipientDelegate.model.iconSvgUrl, + recipientDelegate.model.iconLight, + recipientDelegate.model.iconDark) + sourceSize.width: Style.activityListButtonIconSize + sourceSize.height: Style.activityListButtonIconSize + fillMode: Image.PreserveAspectFit + visible: source.toString().length > 0 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Label { + Layout.fillWidth: true + text: recipientDelegate.model.label + elide: Text.ElideRight + } + + Label { + Layout.fillWidth: true + text: { + const details = [] + if (recipientDelegate.model.instance) { + details.push(recipientDelegate.model.instance) + } + if (recipientDelegate.model.initiatorDisplayName) { + details.push(qsTr("Added by %1").arg(recipientDelegate.model.initiatorDisplayName)) + } + return details.join(" · ") + } + color: palette.placeholderText + elide: Text.ElideRight + visible: text.length > 0 + } + } + + Button { + Layout.preferredWidth: Style.activityListButtonWidth + Layout.preferredHeight: Style.activityListButtonHeight + + icon.source: "image://svgimage-custom-color/copy.svg/" + palette.buttonText + icon.width: Style.activityListButtonIconSize + icon.height: Style.activityListButtonIconSize + display: AbstractButton.IconOnly + visible: recipientDelegate.model.secretUrl !== "" + enabled: visible + + Accessible.name: qsTr("Copy recipient link") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + + onClicked: root.copyToClipboard(recipientDelegate.model.secretUrl) + } + + Button { + Layout.preferredWidth: Style.activityListButtonWidth + Layout.preferredHeight: Style.activityListButtonHeight + + icon.source: "image://svgimage-custom-color/change.svg/" + palette.buttonText + icon.width: Style.activityListButtonIconSize + icon.height: Style.activityListButtonIconSize + display: AbstractButton.IconOnly + visible: recipientDelegate.model.secretUpdatable + enabled: visible + + Accessible.name: recipientDelegate.model.secretUrl !== "" + ? qsTr("Regenerate recipient link") + : qsTr("Generate recipient link") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + + onClicked: { + root.recipientOperationError = "" + root.sharingController.updateRecipientSecret( + root.share, + recipientDelegate.model.className, + recipientDelegate.model.value, + recipientDelegate.model.instance || "") + } + } + + Button { + Layout.preferredWidth: Style.activityListButtonWidth + Layout.preferredHeight: Style.activityListButtonHeight + + icon.source: "image://svgimage-custom-color/delete.svg/" + palette.buttonText + icon.width: Style.activityListButtonIconSize + icon.height: Style.activityListButtonIconSize + display: AbstractButton.IconOnly + + Accessible.name: qsTr("Remove recipient") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + + onClicked: { + root.recipientOperationError = "" + root.sharingController.removeRecipient( + root.share, + recipientDelegate.model.className, + recipientDelegate.model.value, + recipientDelegate.model.instance || "") + } + } + } } } From a7f5457c66a1b5dce67b8b48413e2a2f8b6ed16c Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 12 Aug 2026 01:08:28 +0800 Subject: [PATCH 087/104] feat(sharing): Expand testing to include new feature implementations Signed-off-by: Claudio Cambra --- test/testunifiedsharing.cpp | 786 +++++++++++++++++++++++++++++++++++- 1 file changed, 783 insertions(+), 3 deletions(-) diff --git a/test/testunifiedsharing.cpp b/test/testunifiedsharing.cpp index 87b85241ef841..5406b490ee2a1 100644 --- a/test/testunifiedsharing.cpp +++ b/test/testunifiedsharing.cpp @@ -10,6 +10,9 @@ #include "gui/sharing/generatesecretjob.h" #include "gui/sharing/getsharejob.h" #include "gui/sharing/getsharesjob.h" +#include "gui/sharing/property.h" +#include "gui/sharing/propertymodel.h" +#include "gui/sharing/recipientmodel.h" #include "gui/sharing/recipientsearchmodel.h" #include "gui/sharing/removerecipientjob.h" #include "gui/sharing/removesourcejob.h" @@ -41,6 +44,171 @@ class TestUnifiedSharing : public QObject Q_OBJECT private slots: + void recipientsPreserveServerIdentityAndCapabilities() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + const auto share = Share::fromJson( + QJsonDocument{QJsonObject{ + {"ocs"_L1, + QJsonObject{ + {"data"_L1, + QJsonObject{ + {"id"_L1, "share-1"_L1}, + {"recipients"_L1, + QJsonArray{QJsonObject{ + {"class"_L1, "federated-user"_L1}, + {"display_name"_L1, "Alice"_L1}, + {"value"_L1, "alice"_L1}, + {"instance"_L1, "cloud.example.com"_L1}, + {"icon"_L1, + QJsonObject{ + {"svg"_L1, ""_L1}, + {"light"_L1, "https://cloud.example.com/light.svg"_L1}, + {"dark"_L1, "https://cloud.example.com/dark.svg"_L1}, + }}, + {"secret"_L1, + QJsonObject{ + {"updatable"_L1, true}, + {"value"_L1, "public-secret"_L1}, + {"url"_L1, "https://cloud.example.com/s/public-secret"_L1}, + }}, + {"initiator"_L1, QJsonObject{{"display_name"_L1, "Bob"_L1}}}, + }, + QJsonObject{ + {"class"_L1, "user"_L1}, + {"display_name"_L1, "Carol"_L1}, + {"value"_L1, "carol"_L1}, + {"secret"_L1, QJsonObject{{"updatable"_L1, false}}}, + }}}, + }}, + }}, + }}, + fakeFolder.account()); + + QCOMPARE(share->recipients().size(), 2); + const auto recipient = share->recipients().constFirst(); + QCOMPARE(recipient->className(), "federated-user"_L1); + QCOMPARE(recipient->value(), "alice"_L1); + QCOMPARE(recipient->instance(), std::optional{"cloud.example.com"_L1}); + QCOMPARE(recipient->iconSvg(), ""_L1); + QCOMPARE(recipient->iconLight(), "https://cloud.example.com/light.svg"_L1); + QCOMPARE(recipient->iconDark(), "https://cloud.example.com/dark.svg"_L1); + QVERIFY(recipient->secretUpdatable()); + QCOMPARE(recipient->secretValue(), std::optional{"public-secret"_L1}); + QCOMPARE(recipient->secretUrl(), std::optional{"https://cloud.example.com/s/public-secret"_L1}); + QCOMPARE(recipient->initiatorDisplayName(), "Bob"_L1); + + RecipientModel model; + model.setShare(share); + QCOMPARE(model.rowCount(), 2); + const auto index = model.index(0); + QCOMPARE(model.data(index, RecipientModel::InstanceRole).toString(), "cloud.example.com"_L1); + QCOMPARE(model.data(index, RecipientModel::IconSvgUrlRole).toString(), + "data:image/svg+xml;base64,PHN2Zy8+"_L1); + QVERIFY(model.data(index, RecipientModel::SecretUpdatableRole).toBool()); + QCOMPARE(model.data(index, RecipientModel::SecretUrlRole).toString(), "https://cloud.example.com/s/public-secret"_L1); + QCOMPARE(model.data(index, RecipientModel::InitiatorDisplayNameRole).toString(), "Bob"_L1); + + const auto recipientWithoutLinkIndex = model.index(1); + const auto missingSecretUrl = model.data(recipientWithoutLinkIndex, RecipientModel::SecretUrlRole); + QVERIFY(missingSecretUrl.isValid()); + QCOMPARE(missingSecretUrl.toString(), QString{}); + + delete share; + } + + void sharePropertiesPreserveServerMetadataAndUseTypedFields() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + const auto share = Share::fromJson( + QJsonDocument::fromJson(R"json({ + "ocs": { + "data": { + "id": "share-1", + "properties": [{ + "class": "later-string", + "display_name": "Description", + "hint": "Add a description", + "priority": 20, + "required": true, + "advanced": false, + "type": "string", + "min_length": 3, + "max_length": 40, + "value": "Hello" + }, { + "class": "first-enum", + "display_name": "Visibility", + "hint": null, + "priority": 10, + "required": false, + "advanced": true, + "type": "enum", + "valid_values": ["private", "public"], + "value": "private" + }, { + "class": "expiry", + "display_name": "Expiration date", + "hint": null, + "priority": 30, + "required": false, + "advanced": false, + "type": "date", + "min_date": "2026-07-30T00:00:00+00:00", + "max_date": "2026-08-30T00:00:00+00:00", + "value": null + }, { + "class": "protected", + "display_name": "Password", + "hint": null, + "priority": 40, + "required": false, + "advanced": false, + "type": "password", + "value": null + }, { + "class": "notify", + "display_name": "Notify", + "hint": null, + "priority": 50, + "required": false, + "advanced": false, + "type": "boolean", + "value": "true" + }] + } + } + })json"), + fakeFolder.account()); + + PropertyModel model; + model.setShare(share); + + QCOMPARE(model.rowCount(), 4); + const auto stringIndex = model.index(3); + QCOMPARE(model.data(stringIndex, PropertyModel::TypeRole).toInt(), static_cast(PropertyModel::String)); + QCOMPARE(model.data(stringIndex, PropertyModel::RequiredRole).toBool(), true); + QCOMPARE(model.data(stringIndex, PropertyModel::MinimumRole).toInt(), 3); + QCOMPARE(model.data(stringIndex, PropertyModel::MaximumRole).toInt(), 40); + + const auto dateIndex = model.index(2); + QCOMPARE(model.data(dateIndex, PropertyModel::TypeRole).toInt(), static_cast(PropertyModel::Date)); + QCOMPARE(model.data(dateIndex, PropertyModel::MinimumRole).toString(), "2026-07-30T00:00:00+00:00"_L1); + QCOMPARE(model.data(dateIndex, PropertyModel::MaximumRole).toString(), "2026-08-30T00:00:00+00:00"_L1); + QCOMPARE(model.data(model.index(1), PropertyModel::TypeRole).toInt(), static_cast(PropertyModel::Password)); + QCOMPARE(model.data(model.index(0), PropertyModel::TypeRole).toInt(), static_cast(PropertyModel::Boolean)); + + model.setAdvanced(true); + QCOMPARE(model.rowCount(), 1); + const auto enumIndex = model.index(0); + QCOMPARE(model.data(enumIndex, PropertyModel::PropertyRole).toString(), "first-enum"_L1); + QCOMPARE(model.data(enumIndex, PropertyModel::TypeRole).toInt(), static_cast(PropertyModel::Enum)); + QCOMPARE(model.data(enumIndex, PropertyModel::AdvancedRole).toBool(), true); + QCOMPARE(model.data(enumIndex, PropertyModel::ValidValuesRole).toStringList(), QStringList({"private"_L1, "public"_L1})); + + delete share; + } + void requestsAreConfiguredBeforeTheyStart() { FakeFolder fakeFolder{{}, {}, {}, false}; @@ -131,14 +299,20 @@ private slots: } }; - verifyRequest(new SearchRecipientsJob{account, "ali"_L1, 10, 20, {"user-class"_L1, "group-class"_L1}}, + verifyRequest(new SearchRecipientsJob{account, + "ali"_L1, + 10, + 20, + {"user-class"_L1, "group-class"_L1}, + "share-1"_L1}, "GET", "/ocs/v2.php/apps/sharing/api/v1/recipients", {{"query"_L1, "ali"_L1}, {"offset"_L1, "10"_L1}, {"limit"_L1, "20"_L1}, - {"recipientTypeClasses%5B%5D"_L1, "user-class"_L1}, - {"recipientTypeClasses%5B%5D"_L1, "group-class"_L1}}); + {"filterRecipientTypeClasses%5B%5D"_L1, "user-class"_L1}, + {"filterRecipientTypeClasses%5B%5D"_L1, "group-class"_L1}, + {"id"_L1, "share-1"_L1}}); verifyRequest(new GenerateSecretJob{account}, "GET", "/ocs/v2.php/apps/sharing/api/v1/secret"); verifyRequest(new CreateShareJob{account}, "POST", "/ocs/v2.php/apps/sharing/api/v1/share"); verifyRequest(new SetShareStateJob{account, *share, Share::ShareState::Active}, @@ -253,6 +427,74 @@ private slots: QCOMPARE(requestCount, 1); } + void partialShareUpdatesPreserveOmittedFields() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + const auto share = Share::fromJson( + QJsonDocument::fromJson(R"json({ + "ocs": { + "data": { + "id": "share-1", + "state": "draft", + "permission_preset": "view-preset", + "permissions": [{ + "class": "view-permission", + "display_name": "View files", + "enabled": true + }], + "properties": [{ + "class": "note-property", + "display_name": "Note to recipients", + "type": "string", + "value": "Hello" + }], + "recipients": [{ + "class": "user-class", + "display_name": "Alice", + "value": "alice" + }] + } + } + })json"), + fakeFolder.account()); + + share->updateFromJson(QJsonDocument::fromJson(R"json({ + "ocs": { + "data": { + "state": "active", + "recipients": [{ + "class": "user-class", + "display_name": "Bob", + "value": "bob" + }] + } + } + })json")); + + QCOMPARE(share->id(), "share-1"_L1); + QCOMPARE(share->state(), Share::ShareState::Active); + QCOMPARE(share->permissionPreset(), "view-preset"_L1); + QCOMPARE(share->permissions().size(), 1); + QCOMPARE(share->properties().size(), 1); + QCOMPARE(share->properties().constFirst()->displayName(), "Note to recipients"_L1); + QCOMPARE(share->recipients().size(), 1); + QCOMPARE(share->recipients().constFirst()->displayName(), "Bob"_L1); + + share->updateFromJson(QJsonDocument::fromJson(R"json({ + "ocs": { + "data": { + "properties": [] + } + } + })json")); + + QVERIFY(share->properties().isEmpty()); + QCOMPARE(share->permissions().size(), 1); + QCOMPARE(share->recipients().size(), 1); + + delete share; + } + void sharingControllerLoadsAllSharesWithoutCreatingOne() { FakeFolder fakeFolder{{}, {}, {}, false}; @@ -302,6 +544,544 @@ private slots: QCOMPARE(controller.shares().at(1)->id(), "share-2"_L1); } + void sharingControllerCreatesShareAfterAttachingSource() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto requestPaths = QStringList{}; + auto requestVerbs = QList{}; + auto requestBodies = QList{}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *outgoingData) { + const auto path = request.url().path(); + auto verb = request.attribute(QNetworkRequest::CustomVerbAttribute).toByteArray(); + if (verb.isEmpty() && operation == QNetworkAccessManager::PostOperation) { + verb = "POST"; + } + requestPaths.append(path); + requestVerbs.append(verb); + + auto body = QJsonObject{}; + if (outgoingData) { + if (!outgoingData->isOpen()) { + outgoingData->open(QIODevice::ReadOnly); + } + body = QJsonDocument::fromJson(outgoingData->peek(outgoingData->bytesAvailable())).object(); + outgoingData->reset(); + } + requestBodies.append(body); + + const auto creating = path.endsWith("/api/v1/share"_L1); + const auto response = QString{R"json({ + "ocs": { + "meta": { + "status": "ok", + "statuscode": %1, + "message": "OK" + }, + "data": { + "id": "share-1", + "state": "draft" + } + } + })json"} + .arg(creating ? 201 : 200) + .toUtf8(); + return new FakePayloadReply{operation, request, response, this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + QSignalSpy sharesChangedSpy{&controller, &SharingController::sharesChanged}; + QSignalSpy creatingShareChangedSpy{&controller, &SharingController::creatingShareChanged}; + + controller.createShare("42"_L1); + + QVERIFY(controller.creatingShare()); + controller.createShare("42"_L1); + QTRY_COMPARE(controller.shares().size(), 1); + QCOMPARE(sharesChangedSpy.size(), 1); + QCOMPARE(creatingShareChangedSpy.size(), 2); + QVERIFY(!controller.creatingShare()); + QVERIFY(controller.shareCreationError().isEmpty()); + QCOMPARE(controller.shares().constFirst()->id(), "share-1"_L1); + QCOMPARE(requestPaths.size(), 2); + QVERIFY(requestPaths.at(0).endsWith("/api/v1/share"_L1)); + QVERIFY(requestPaths.at(1).endsWith("/api/v1/share/share-1/source"_L1)); + QCOMPARE(requestVerbs, (QList{"POST", "POST"})); + QCOMPARE(requestBodies.at(0), QJsonObject{}); + QCOMPARE(requestBodies.at(1), + (QJsonObject{{"class"_L1, SourceTypeClasses::node}, {"value"_L1, "42"_L1}})); + } + + void sharingControllerReportsAddedRecipientAndUpdatesShare() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto recipientRequestBody = QJsonObject{}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *outgoingData) { + const auto addingRecipient = request.url().path().endsWith("/recipient"_L1); + if (addingRecipient && outgoingData) { + if (!outgoingData->isOpen()) { + outgoingData->open(QIODevice::ReadOnly); + } + recipientRequestBody = QJsonDocument::fromJson(outgoingData->peek(outgoingData->bytesAvailable())).object(); + outgoingData->reset(); + } + + const auto response = addingRecipient + ? QByteArray{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": { + "id": "share-1", + "state": "active", + "recipients": [{ + "class": "user-class", + "display_name": "Alice", + "value": "alice", + "instance": "cloud.example.com" + }] + } + } + })json"} + : QByteArray{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": [{ + "id": "share-1", + "state": "active", + "properties": [{ + "class": "note-property", + "display_name": "Note to recipients", + "type": "string" + }], + "recipients": [] + }] + } + })json"}; + return new FakePayloadReply{operation, request, response, this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + controller.initialize("42"_L1); + QTRY_COMPARE(controller.shares().size(), 1); + + auto addedShare = QPointer{}; + connect(&controller, &SharingController::recipientAdded, this, [&addedShare](Share *share) { + addedShare = share; + }); + controller.addRecipient(controller.shares().constFirst(), "user-class"_L1, "alice"_L1, "cloud.example.com"_L1); + + QTRY_VERIFY(addedShare); + QCOMPARE(addedShare, controller.shares().constFirst()); + QCOMPARE(addedShare->recipients().size(), 1); + QCOMPARE(addedShare->recipients().constFirst()->displayName(), "Alice"_L1); + QCOMPARE(addedShare->recipients().constFirst()->instance(), std::optional{"cloud.example.com"_L1}); + QCOMPARE(addedShare->properties().size(), 1); + QCOMPARE(addedShare->properties().constFirst()->displayName(), "Note to recipients"_L1); + QCOMPARE(recipientRequestBody, + (QJsonObject{{"class"_L1, "user-class"_L1}, + {"value"_L1, "alice"_L1}, + {"instance"_L1, "cloud.example.com"_L1}})); + } + + void sharingControllerRemovesRecipientAndPreservesItsIdentity() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto removalQuery = QList>{}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + const auto removingRecipient = request.url().path().endsWith("/recipient"_L1) + && (operation == QNetworkAccessManager::DeleteOperation + || request.attribute(QNetworkRequest::CustomVerbAttribute).toByteArray() == "DELETE"); + if (removingRecipient) { + removalQuery = QUrlQuery{request.url()}.queryItems(); + removalQuery.removeAll({"format"_L1, "json"_L1}); + std::ranges::sort(removalQuery); + } + + const auto response = removingRecipient + ? QByteArray{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": { + "id": "share-1", + "state": "active", + "recipients": [] + } + } + })json"} + : QByteArray{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": [{ + "id": "share-1", + "state": "active", + "recipients": [{ + "class": "federated-user", + "display_name": "Alice", + "value": "alice", + "instance": "cloud.example.com" + }] + }] + } + })json"}; + return new FakePayloadReply{operation, request, response, this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + controller.initialize("42"_L1); + QTRY_COMPARE(controller.shares().size(), 1); + + QSignalSpy removedSpy{&controller, &SharingController::recipientRemoved}; + controller.removeRecipient(controller.shares().constFirst(), + "federated-user"_L1, + "alice"_L1, + "cloud.example.com"_L1); + + QVERIFY(removedSpy.wait()); + QCOMPARE(controller.shares().constFirst()->recipients().size(), 0); + QCOMPARE(removalQuery, + (QList>{{"class"_L1, "federated-user"_L1}, + {"instance"_L1, "cloud.example.com"_L1}, + {"value"_L1, "alice"_L1}})); + } + + void sharingControllerGeneratesAndAppliesRecipientSecret() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto secretRequestBody = QJsonObject{}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *outgoingData) { + const auto path = request.url().path(); + const auto settingSecret = path.endsWith("/recipient/secret"_L1); + if (settingSecret && outgoingData) { + if (!outgoingData->isOpen()) { + outgoingData->open(QIODevice::ReadOnly); + } + secretRequestBody = QJsonDocument::fromJson(outgoingData->peek(outgoingData->bytesAvailable())).object(); + outgoingData->reset(); + } + + auto data = QJsonValue{QJsonArray{QJsonObject{ + {"id"_L1, "share-1"_L1}, + {"state"_L1, "active"_L1}, + {"recipients"_L1, + QJsonArray{QJsonObject{ + {"class"_L1, "federated-user"_L1}, + {"display_name"_L1, "Alice"_L1}, + {"value"_L1, "alice"_L1}, + {"instance"_L1, "cloud.example.com"_L1}, + {"secret"_L1, QJsonObject{{"updatable"_L1, true}}}, + }}}, + }}}; + if (path.endsWith("/secret"_L1) && !settingSecret) { + data = "generated-secret"_L1; + } else if (settingSecret) { + data = QJsonObject{ + {"id"_L1, "share-1"_L1}, + {"state"_L1, "active"_L1}, + {"recipients"_L1, + QJsonArray{QJsonObject{ + {"class"_L1, "federated-user"_L1}, + {"display_name"_L1, "Alice"_L1}, + {"value"_L1, "alice"_L1}, + {"instance"_L1, "cloud.example.com"_L1}, + {"secret"_L1, + QJsonObject{ + {"updatable"_L1, true}, + {"url"_L1, "https://cloud.example.com/s/generated-secret"_L1}, + }}, + }}}, + }; + } + + const auto response = QJsonDocument{QJsonObject{ + {"ocs"_L1, + QJsonObject{ + {"meta"_L1, + QJsonObject{ + {"status"_L1, "ok"_L1}, + {"statuscode"_L1, 200}, + {"message"_L1, "OK"_L1}, + }}, + {"data"_L1, data}, + }}, + }}.toJson(QJsonDocument::Compact); + return new FakePayloadReply{operation, request, response, this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + controller.initialize("42"_L1); + QTRY_COMPARE(controller.shares().size(), 1); + + QSignalSpy updatedSpy{&controller, &SharingController::recipientSecretUpdated}; + controller.updateRecipientSecret(controller.shares().constFirst(), + "federated-user"_L1, + "alice"_L1, + "cloud.example.com"_L1); + + QVERIFY(updatedSpy.wait()); + QCOMPARE(secretRequestBody, + (QJsonObject{{"class"_L1, "federated-user"_L1}, + {"value"_L1, "alice"_L1}, + {"secret"_L1, "generated-secret"_L1}, + {"instance"_L1, "cloud.example.com"_L1}})); + QCOMPARE(controller.shares().constFirst()->recipients().constFirst()->secretUrl(), + std::optional{"https://cloud.example.com/s/generated-secret"_L1}); + } + + void sharingControllerReportsRecipientOperationFailures() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + const auto loadingShares = request.url().path().endsWith("/shares"_L1); + const auto response = loadingShares + ? QByteArray{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": [{"id": "share-1", "state": "active"}] + } + })json"} + : QByteArray{R"json({ + "ocs": { + "meta": {"status": "failure", "statuscode": 400, "message": "Recipient rejected"}, + "data": {} + } + })json"}; + return new FakePayloadReply{operation, request, response, this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + controller.initialize("42"_L1); + QTRY_COMPARE(controller.shares().size(), 1); + + QSignalSpy removalFailedSpy{&controller, &SharingController::recipientRemovalFailed}; + controller.removeRecipient(controller.shares().constFirst(), "user-class"_L1, "alice"_L1); + QVERIFY(removalFailedSpy.wait()); + QCOMPARE(removalFailedSpy.constFirst().at(1).toString(), "Recipient rejected"_L1); + + QSignalSpy secretFailedSpy{&controller, &SharingController::recipientSecretUpdateFailed}; + controller.updateRecipientSecret(controller.shares().constFirst(), "user-class"_L1, "alice"_L1); + QVERIFY(secretFailedSpy.wait()); + QCOMPARE(secretFailedSpy.constFirst().at(1).toString(), "Recipient rejected"_L1); + } + + void sharingControllerSetsShareProperty() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto propertyRequestBody = QJsonObject{}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *outgoingData) { + const auto settingProperty = request.url().path().endsWith("/property"_L1); + if (settingProperty && outgoingData) { + if (!outgoingData->isOpen()) { + outgoingData->open(QIODevice::ReadOnly); + } + propertyRequestBody = QJsonDocument::fromJson(outgoingData->peek(outgoingData->bytesAvailable())).object(); + outgoingData->reset(); + } + + const auto response = settingProperty + ? QByteArray{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": { + "id": "share-1", + "state": "active", + "properties": [{ + "class": "note-property", + "display_name": "Note to recipients", + "type": "string", + "value": "Updated note" + }] + } + } + })json"} + : QByteArray{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": [{ + "id": "share-1", + "state": "active", + "properties": [{ + "class": "note-property", + "display_name": "Note to recipients", + "type": "string", + "value": "Original note" + }] + }] + } + })json"}; + return new FakePayloadReply{operation, request, response, this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + controller.initialize("42"_L1); + QTRY_COMPARE(controller.shares().size(), 1); + QCOMPARE(controller.shares().constFirst()->properties().constFirst()->value().toString(), "Original note"_L1); + + QSignalSpy propertyUpdatedSpy{&controller, &SharingController::propertyUpdated}; + controller.setProperty(controller.shares().constFirst(), "note-property"_L1, "Updated note"_L1); + + QTRY_COMPARE(propertyUpdatedSpy.size(), 1); + QCOMPARE(controller.shares().constFirst()->properties().constFirst()->value().toString(), "Updated note"_L1); + QCOMPARE(propertyRequestBody, + (QJsonObject{{"class"_L1, "note-property"_L1}, {"value"_L1, "Updated note"_L1}})); + } + + void sharingControllerActivatesDraftShare() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto stateRequestBody = QJsonObject{}; + auto stateRequestVerb = QByteArray{}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *outgoingData) { + const auto settingState = request.url().path().endsWith("/state"_L1); + if (settingState) { + stateRequestVerb = request.attribute(QNetworkRequest::CustomVerbAttribute).toByteArray(); + if (stateRequestVerb.isEmpty() && operation == QNetworkAccessManager::PutOperation) { + stateRequestVerb = "PUT"; + } + if (outgoingData) { + if (!outgoingData->isOpen()) { + outgoingData->open(QIODevice::ReadOnly); + } + stateRequestBody = QJsonDocument::fromJson(outgoingData->peek(outgoingData->bytesAvailable())).object(); + outgoingData->reset(); + } + } + + const auto response = settingState + ? QByteArray{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": {"id": "share-1", "state": "active"} + } + })json"} + : QByteArray{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": [{"id": "share-1", "state": "draft"}] + } + })json"}; + return new FakePayloadReply{operation, request, response, this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + controller.initialize("42"_L1); + QTRY_COMPARE(controller.shares().size(), 1); + const auto share = controller.shares().constFirst(); + QCOMPARE(share->state(), Share::ShareState::Draft); + + QSignalSpy activatedSpy{&controller, &SharingController::shareActivated}; + QSignalSpy activationFailedSpy{&controller, &SharingController::shareActivationFailed}; + controller.activateShare(share); + + QTRY_COMPARE(activatedSpy.size(), 1); + QVERIFY(activationFailedSpy.isEmpty()); + QCOMPARE(share->state(), Share::ShareState::Active); + QCOMPARE(stateRequestVerb, "PUT"); + QCOMPARE(stateRequestBody, (QJsonObject{{"state"_L1, "active"_L1}})); + + controller.activateShare(share); + QTest::qWait(10); + QCOMPARE(activatedSpy.size(), 1); + } + + void sharingControllerReportsShareActivationFailure() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + const auto settingState = request.url().path().endsWith("/state"_L1); + const auto response = settingState + ? QByteArray{R"json({ + "ocs": { + "meta": {"status": "failure", "statuscode": 400, "message": "Share rejected"}, + "data": {} + } + })json"} + : QByteArray{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": [{"id": "share-1", "state": "draft"}] + } + })json"}; + return new FakePayloadReply{operation, request, response, this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + controller.initialize("42"_L1); + QTRY_COMPARE(controller.shares().size(), 1); + const auto share = controller.shares().constFirst(); + + QSignalSpy activatedSpy{&controller, &SharingController::shareActivated}; + QSignalSpy activationFailedSpy{&controller, &SharingController::shareActivationFailed}; + controller.activateShare(share); + + QTRY_COMPARE(activationFailedSpy.size(), 1); + QVERIFY(activatedSpy.isEmpty()); + QCOMPARE(share->state(), Share::ShareState::Draft); + QCOMPARE(activationFailedSpy.constFirst().at(0).value(), share); + QCOMPARE(activationFailedSpy.constFirst().at(1).toString(), "Share rejected"_L1); + } + + void sharingControllerCleansUpShareWhenAttachingSourceFails() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto requestPaths = QStringList{}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + const auto path = request.url().path(); + requestPaths.append(path); + + auto statusCode = 200; + auto message = "OK"_L1; + if (path.endsWith("/api/v1/share"_L1)) { + statusCode = 201; + } else if (path.endsWith("/source"_L1)) { + statusCode = 400; + message = "Source rejected"_L1; + } else if (path.endsWith("/api/v1/share/share-1"_L1)) { + statusCode = 204; + } + + const auto response = QString{R"json({ + "ocs": { + "meta": { + "status": "%1", + "statuscode": %2, + "message": "%3" + }, + "data": { + "id": "share-1", + "state": "draft" + } + } + })json"} + .arg(statusCode >= 400 ? "failure"_L1 : "ok"_L1) + .arg(statusCode) + .arg(message) + .toUtf8(); + return new FakePayloadReply{operation, request, response, this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + QSignalSpy sharesChangedSpy{&controller, &SharingController::sharesChanged}; + + controller.createShare("42"_L1); + + QTRY_COMPARE(requestPaths.size(), 3); + QVERIFY(!controller.creatingShare()); + QCOMPARE(controller.shareCreationError(), "Source rejected"_L1); + QVERIFY(controller.shares().isEmpty()); + QVERIFY(sharesChangedSpy.isEmpty()); + QVERIFY(requestPaths.at(0).endsWith("/api/v1/share"_L1)); + QVERIFY(requestPaths.at(1).endsWith("/api/v1/share/share-1/source"_L1)); + QVERIFY(requestPaths.at(2).endsWith("/api/v1/share/share-1"_L1)); + } + void operationSpecificStatusCodesAreEnforced() { FakeFolder fakeFolder{{}, {}, {}, false}; From 182245aee003350e36424bd9df84fc55d757d8e8 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 12 Aug 2026 16:36:48 +0800 Subject: [PATCH 088/104] feat(sharing): Handle share removal Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareDialog.qml | 53 ++++++++++++++++++ src/gui/sharing/sharingcontroller.cpp | 53 +++++++++++++++++- src/gui/sharing/sharingcontroller.h | 18 ++++++ test/testunifiedsharing.cpp | 79 +++++++++++++++++++++++++++ 4 files changed, 202 insertions(+), 1 deletion(-) diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 6a02f5e81fbab..dfff500b7889a 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -136,6 +136,26 @@ WizardStyledWindow { color: palette.text Layout.fillWidth: true } + + ToolButton { + visible: dialog.selectedShare !== null + enabled: !sharingController.destroyingShare + display: AbstractButton.IconOnly + icon.source: "image://svgimage-custom-color/delete.svg/" + palette.buttonText + Accessible.name: qsTr("Delete share") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + + onClicked: deleteShareConfirmation.open() + } + } + + Label { + Layout.fillWidth: true + text: sharingController.shareDestructionError + color: Style.wizardErrorText + wrapMode: Text.Wrap + visible: text.length > 0 } Item { @@ -173,4 +193,37 @@ WizardStyledWindow { } } + Dialog { + id: deleteShareConfirmation + + anchors.centerIn: parent + modal: true + title: qsTr("Delete share?") + + Label { + width: parent.width + text: qsTr("This removes the share and its access for all recipients.") + wrapMode: Text.Wrap + } + + footer: DialogButtonBox { + Button { + text: qsTr("Delete") + DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole + onClicked: deleteShareConfirmation.accept() + } + + Button { + text: qsTr("Cancel") + DialogButtonBox.buttonRole: DialogButtonBox.RejectRole + onClicked: deleteShareConfirmation.reject() + } + } + + onAccepted: { + sharingController.destroyShare(dialog.selectedShare) + close() + } + } + } diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index c7bb2cf6c646b..9b63f76d190c9 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -81,6 +81,16 @@ QString SharingController::shareCreationError() const return _shareCreationError; } +bool SharingController::destroyingShare() const +{ + return _destroyingShare; +} + +QString SharingController::shareDestructionError() const +{ + return _shareDestructionError; +} + void SharingController::initialize(const QString &fileId) { if (!_account) { @@ -159,12 +169,35 @@ void SharingController::destroyShare(Share *share) return; } + if (_destroyingShare) { + qCDebug(lcSharingController) << "ignoring attempt to destroy a share while another deletion is in progress"; + return; + } + + setShareDestructionError({}); + setDestroyingShare(true); + const auto guardedShare = QPointer{share}; const auto job = new DestroyShareJob{_account, share->id()}; - connect(job, &DestroyShareJob::jobFinished, this, [this, share](const QJsonDocument &, int) { + connect(job, &DestroyShareJob::jobFinished, this, [this, guardedShare](const QJsonDocument &, int) { + if (!guardedShare) { + setDestroyingShare(false); + return; + } + + const auto share = guardedShare.data(); _shares.removeAll(share); + setDestroyingShare(false); share->deleteLater(); Q_EMIT sharesChanged(); }); + connect(job, &DestroyShareJob::ocsError, this, [this](int, const QString &message) { + setShareDestructionError(message.isEmpty() ? tr("Could not delete the share.") : message); + setDestroyingShare(false); + }); + connect(job, &DestroyShareJob::networkError, this, [this](const QNetworkReply *reply) { + setShareDestructionError(reply ? reply->errorString() : tr("Could not delete the share.")); + setDestroyingShare(false); + }); job->start(); } @@ -458,6 +491,24 @@ void SharingController::setShareCreationError(const QString &error) Q_EMIT shareCreationErrorChanged(); } +void SharingController::setDestroyingShare(bool destroyingShare) +{ + if (_destroyingShare == destroyingShare) { + return; + } + _destroyingShare = destroyingShare; + Q_EMIT destroyingShareChanged(); +} + +void SharingController::setShareDestructionError(const QString &error) +{ + if (_shareDestructionError == error) { + return; + } + _shareDestructionError = error; + Q_EMIT shareDestructionErrorChanged(); +} + void SharingController::replaceShares(const QList &shares) { qDeleteAll(_shares); diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index 219d12e06e62c..451da64390f6b 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -26,6 +26,8 @@ class SharingController : public QObject Q_PROPERTY(QList shares READ shares NOTIFY sharesChanged) Q_PROPERTY(bool creatingShare READ creatingShare NOTIFY creatingShareChanged) Q_PROPERTY(QString shareCreationError READ shareCreationError NOTIFY shareCreationErrorChanged) + Q_PROPERTY(bool destroyingShare READ destroyingShare NOTIFY destroyingShareChanged) + Q_PROPERTY(QString shareDestructionError READ shareDestructionError NOTIFY shareDestructionErrorChanged) public: SharingController(QObject *parent = nullptr); @@ -43,6 +45,12 @@ class SharingController : public QObject /** @brief Returns the last share creation error, or an empty string after a new attempt starts. */ [[nodiscard]] QString shareCreationError() const; + /** @brief Returns whether a share is currently being deleted. */ + [[nodiscard]] bool destroyingShare() const; + + /** @brief Returns the last share deletion error, or an empty string after a new attempt starts. */ + [[nodiscard]] QString shareDestructionError() const; + /** * @brief Loads all shares associated with a file without creating a share. * @@ -110,6 +118,12 @@ class SharingController : public QObject /** @brief Emitted when shareCreationError changes. */ void shareCreationErrorChanged(); + /** @brief Emitted when destroyingShare changes. */ + void destroyingShareChanged(); + + /** @brief Emitted when shareDestructionError changes. */ + void shareDestructionErrorChanged(); + /** @brief Emitted after a recipient was added and the Share was updated. */ void recipientAdded(Share *share); @@ -145,6 +159,8 @@ class SharingController : public QObject QList _shares; bool _creatingShare = false; QString _shareCreationError; + bool _destroyingShare = false; + QString _shareDestructionError; [[nodiscard]] bool containsShare(const Share *share) const; void addSourceAfterCreation(QPointer share, const QString &fileId); @@ -152,6 +168,8 @@ class SharingController : public QObject void failShareCreation(const QString &error, QPointer share = {}); void setCreatingShare(bool creatingShare); void setShareCreationError(const QString &error); + void setDestroyingShare(bool destroyingShare); + void setShareDestructionError(const QString &error); void replaceShares(const QList &shares); }; diff --git a/test/testunifiedsharing.cpp b/test/testunifiedsharing.cpp index 5406b490ee2a1..c93b7bf722593 100644 --- a/test/testunifiedsharing.cpp +++ b/test/testunifiedsharing.cpp @@ -544,6 +544,85 @@ private slots: QCOMPARE(controller.shares().at(1)->id(), "share-2"_L1); } + void sharingControllerDestroysShareAndRemovesItFromTheList() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto destroyRequests = 0; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + const auto deleting = request.url().path().endsWith("/api/v1/share/share-1"_L1); + if (deleting) { + ++destroyRequests; + } + + const auto response = deleting + ? QByteArray{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 204, "message": "OK"}, + "data": {} + } + })json"} + : QByteArray{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": [{"id": "share-1", "state": "active"}] + } + })json"}; + return new FakePayloadReply{operation, request, response, this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + controller.initialize("42"_L1); + QTRY_COMPARE(controller.shares().size(), 1); + + QSignalSpy sharesChangedSpy{&controller, &SharingController::sharesChanged}; + const auto share = controller.shares().constFirst(); + controller.destroyShare(share); + controller.destroyShare(share); + + QTRY_VERIFY(controller.shares().isEmpty()); + QCOMPARE(destroyRequests, 1); + QVERIFY(!controller.destroyingShare()); + QVERIFY(controller.shareDestructionError().isEmpty()); + QCOMPARE(sharesChangedSpy.size(), 1); + } + + void sharingControllerReportsShareDestructionFailure() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + const auto deleting = request.url().path().endsWith("/api/v1/share/share-1"_L1); + const auto response = deleting + ? QByteArray{R"json({ + "ocs": { + "meta": {"status": "failure", "statuscode": 403, "message": "Not allowed"}, + "data": {} + } + })json"} + : QByteArray{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": [{"id": "share-1", "state": "active"}] + } + })json"}; + return new FakePayloadReply{operation, request, response, this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + controller.initialize("42"_L1); + QTRY_COMPARE(controller.shares().size(), 1); + + QSignalSpy sharesChangedSpy{&controller, &SharingController::sharesChanged}; + const auto share = controller.shares().constFirst(); + controller.destroyShare(share); + + QTRY_COMPARE(controller.shareDestructionError(), "Not allowed"_L1); + QVERIFY(!controller.destroyingShare()); + QCOMPARE(controller.shares().size(), 1); + QVERIFY(sharesChangedSpy.isEmpty()); + } + void sharingControllerCreatesShareAfterAttachingSource() { FakeFolder fakeFolder{{}, {}, {}, false}; From bc24c52dd8de3b8ac9efcbc210ad347a730ae0e2 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 12 Aug 2026 17:10:58 +0800 Subject: [PATCH 089/104] feat(sharing): Handle failure cases for permissions changes Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareDetailsPage.qml | 18 +++++++ src/gui/sharing/permissionmodel.cpp | 13 ++--- src/gui/sharing/sharingcontroller.cpp | 33 +++++++++++++ src/gui/sharing/sharingcontroller.h | 3 ++ test/testunifiedsharing.cpp | 70 +++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 9 deletions(-) diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index 9912868ea1059..493fc60c407d6 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -19,6 +19,7 @@ Page { required property SharingController sharingController required property Share share property string recipientOperationError: "" + property string permissionUpdateError: "" property string propertyUpdateError: "" property string shareActivationError: "" property bool activatingShare: false @@ -250,6 +251,7 @@ Page { onActivated: function(index) { const preset = presetValues[index] if (preset) { + root.permissionUpdateError = "" root.sharingController.setPermissionPreset(root.share, preset) } } @@ -273,11 +275,21 @@ Page { checked: model.enabled onToggled: { + root.permissionUpdateError = "" root.sharingController.setPermission(root.share, model.className, checked) } } } + Label { + Layout.fillWidth: true + + text: root.permissionUpdateError + color: Style.wizardErrorText + wrapMode: Text.Wrap + visible: text.length > 0 + } + Label { Layout.fillWidth: true Layout.topMargin: Style.standardSpacing @@ -440,6 +452,12 @@ Page { } } + function onPermissionUpdateFailed(share, error) { + if (share === root.share) { + root.permissionUpdateError = error + } + } + function onShareActivated(share) { if (share === root.share) { root.activatingShare = false diff --git a/src/gui/sharing/permissionmodel.cpp b/src/gui/sharing/permissionmodel.cpp index 9ccbac842f520..46b03b4a5a824 100644 --- a/src/gui/sharing/permissionmodel.cpp +++ b/src/gui/sharing/permissionmodel.cpp @@ -24,13 +24,12 @@ int PermissionModel::rowCount(const QModelIndex &parent) const return 0; } - qCritical() << "permissions size:" << _share->permissions().size(); return _share->permissions().size(); } QVariant PermissionModel::data(const QModelIndex &index, int role) const { - if (!_share) { + if (!_share || !checkIndex(index, CheckIndexOption::IndexIsValid)) { return {}; } @@ -53,22 +52,18 @@ QVariant PermissionModel::data(const QModelIndex &index, int role) const bool PermissionModel::setData(const QModelIndex &index, const QVariant &value, int role) { - qCritical() << "setData called with" << index << value << role; - - if (role != EnabledRole) { + Q_UNUSED(value) + if (role != EnabledRole || !checkIndex(index, CheckIndexOption::IndexIsValid)) { return false; } - const auto newValue = value.toBool(); - Q_EMIT dataChanged(index, index, {EnabledRole}); return true; } Qt::ItemFlags PermissionModel::flags(const QModelIndex &index) const { - qCritical() << "flags for" << index << QAbstractListModel::flags(index) << Qt::ItemIsEditable << "returning" << (QAbstractListModel::flags(index) | Qt::ItemIsEditable); - return QAbstractListModel::flags(index) | Qt::ItemIsEditable; + return checkIndex(index, CheckIndexOption::IndexIsValid) ? QAbstractListModel::flags(index) | Qt::ItemIsEditable : Qt::NoItemFlags; } QHash PermissionModel::roleNames() const diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index 9b63f76d190c9..391af60b441e5 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -10,6 +10,7 @@ #include #include +#include #include "addrecipientjob.h" #include "addsourcejob.h" @@ -322,7 +323,23 @@ void SharingController::setPermission(Share *share, const QString &permissionCla return; } + const auto guardedShare = QPointer{share}; + const auto permissionFailureReported = std::make_shared(false); const auto job = new SetPermissionJob{_account, *share, permissionClass, enabled}; + connect(job, &SetPermissionJob::ocsError, this, [this, guardedShare, permissionFailureReported](int, const QString &message) { + if (*permissionFailureReported) { + return; + } + *permissionFailureReported = true; + Q_EMIT permissionUpdateFailed(guardedShare, message.isEmpty() ? tr("Could not update the permissions.") : message); + }); + connect(job, &SetPermissionJob::networkError, this, [this, guardedShare, permissionFailureReported](const QNetworkReply *reply) { + if (*permissionFailureReported) { + return; + } + *permissionFailureReported = true; + Q_EMIT permissionUpdateFailed(guardedShare, reply ? reply->errorString() : tr("Could not update the permissions.")); + }); job->start(); } @@ -343,7 +360,23 @@ void SharingController::setPermissionPreset(Share *share, const QString &permiss return; } + const auto guardedShare = QPointer{share}; + const auto permissionFailureReported = std::make_shared(false); const auto job = new SetPermissionPresetJob{_account, *share, permissionPreset}; + connect(job, &SetPermissionPresetJob::ocsError, this, [this, guardedShare, permissionFailureReported](int, const QString &message) { + if (*permissionFailureReported) { + return; + } + *permissionFailureReported = true; + Q_EMIT permissionUpdateFailed(guardedShare, message.isEmpty() ? tr("Could not update the permissions.") : message); + }); + connect(job, &SetPermissionPresetJob::networkError, this, [this, guardedShare, permissionFailureReported](const QNetworkReply *reply) { + if (*permissionFailureReported) { + return; + } + *permissionFailureReported = true; + Q_EMIT permissionUpdateFailed(guardedShare, reply ? reply->errorString() : tr("Could not update the permissions.")); + }); job->start(); } diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index 451da64390f6b..82c50e021e1f0 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -148,6 +148,9 @@ class SharingController : public QObject /** @brief Emitted when updating a share property failed. */ void propertyUpdateFailed(Share *share, const QString &error); + /** @brief Emitted when updating an individual permission or permission preset failed. */ + void permissionUpdateFailed(Share *share, const QString &error); + /** @brief Emitted after a draft share was activated. */ void shareActivated(Share *share); diff --git a/test/testunifiedsharing.cpp b/test/testunifiedsharing.cpp index c93b7bf722593..42ad863f4e649 100644 --- a/test/testunifiedsharing.cpp +++ b/test/testunifiedsharing.cpp @@ -1009,6 +1009,76 @@ private slots: (QJsonObject{{"class"_L1, "note-property"_L1}, {"value"_L1, "Updated note"_L1}})); } + void sharingControllerReportsPermissionUpdateFailures() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + const auto response = request.url().path().endsWith("/shares"_L1) + ? QByteArray{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": [{"id": "share-1", "state": "active"}] + } + })json"} + : QByteArray{R"json({ + "ocs": { + "meta": {"status": "failure", "statuscode": 400, "message": "Permission rejected"}, + "data": {} + } + })json"}; + return new FakePayloadReply{operation, request, response, this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + controller.initialize("42"_L1); + QTRY_COMPARE(controller.shares().size(), 1); + + QSignalSpy permissionFailedSpy{&controller, &SharingController::permissionUpdateFailed}; + const auto share = controller.shares().constFirst(); + controller.setPermission(share, "permission-class"_L1, true); + QTRY_COMPARE(permissionFailedSpy.size(), 1); + QCOMPARE(permissionFailedSpy.constFirst().at(0).value(), share); + QCOMPARE(permissionFailedSpy.constFirst().at(1).toString(), "Permission rejected"_L1); + + controller.setPermissionPreset(share, "preset-class"_L1); + QTRY_COMPARE(permissionFailedSpy.size(), 2); + QCOMPARE(permissionFailedSpy.constLast().at(0).value(), share); + QCOMPARE(permissionFailedSpy.constLast().at(1).toString(), "Permission rejected"_L1); + } + + void sharingControllerReportsPermissionNetworkFailures() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto requestCount = 0; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + ++requestCount; + if (requestCount == 1) { + return static_cast(new FakePayloadReply{operation, + request, + R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": [{"id": "share-1", "state": "active"}] + } + })json", + this}); + } + return static_cast(new FakeErrorReply{operation, request, this, 500}); + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + controller.initialize("42"_L1); + QTRY_COMPARE(controller.shares().size(), 1); + + QSignalSpy permissionFailedSpy{&controller, &SharingController::permissionUpdateFailed}; + const auto share = controller.shares().constFirst(); + controller.setPermission(share, "permission-class"_L1, true); + QTRY_COMPARE(permissionFailedSpy.size(), 1); + QVERIFY(!permissionFailedSpy.constFirst().at(1).toString().isEmpty()); + } + void sharingControllerActivatesDraftShare() { FakeFolder fakeFolder{{}, {}, {}, false}; From b78cacdfcecfcab7916912fa853475d1aa18d82b Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Wed, 12 Aug 2026 17:24:15 +0800 Subject: [PATCH 090/104] feat(sharing): Clean up permissions model Signed-off-by: Claudio Cambra --- src/gui/sharing/permissionmodel.cpp | 29 ++++++------------- src/gui/sharing/permissionmodel.h | 5 ++-- test/testunifiedsharing.cpp | 44 +++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 23 deletions(-) diff --git a/src/gui/sharing/permissionmodel.cpp b/src/gui/sharing/permissionmodel.cpp index 46b03b4a5a824..e360be2c95207 100644 --- a/src/gui/sharing/permissionmodel.cpp +++ b/src/gui/sharing/permissionmodel.cpp @@ -5,8 +5,6 @@ #include "permissionmodel.h" -#include - #include "share.h" #include "permission.h" @@ -29,11 +27,11 @@ int PermissionModel::rowCount(const QModelIndex &parent) const QVariant PermissionModel::data(const QModelIndex &index, int role) const { - if (!_share || !checkIndex(index, CheckIndexOption::IndexIsValid)) { + if (!_share || !checkIndex(index, CheckIndexOption::IndexIsValid | CheckIndexOption::ParentIsInvalid)) { return {}; } - const auto permissions = _share->permissions(); + const auto &permissions = _share->permissions(); const auto permission = permissions.at(index.row()); switch (role) { @@ -50,22 +48,6 @@ QVariant PermissionModel::data(const QModelIndex &index, int role) const } } -bool PermissionModel::setData(const QModelIndex &index, const QVariant &value, int role) -{ - Q_UNUSED(value) - if (role != EnabledRole || !checkIndex(index, CheckIndexOption::IndexIsValid)) { - return false; - } - - Q_EMIT dataChanged(index, index, {EnabledRole}); - return true; -} - -Qt::ItemFlags PermissionModel::flags(const QModelIndex &index) const -{ - return checkIndex(index, CheckIndexOption::IndexIsValid) ? QAbstractListModel::flags(index) | Qt::ItemIsEditable : Qt::NoItemFlags; -} - QHash PermissionModel::roleNames() const { return { @@ -78,12 +60,17 @@ QHash PermissionModel::roleNames() const void PermissionModel::setShare(Share *share) { + if (_share == share) { + return; + } + + QObject::disconnect(_permissionsChangedConnection); AbstractShareModel::setShare(share); if (!_share) { return; } - connect(_share, &Share::permissionsChanged, this, [this]() -> void { + _permissionsChangedConnection = connect(_share, &Share::permissionsChanged, this, [this]() -> void { beginResetModel(); endResetModel(); }); diff --git a/src/gui/sharing/permissionmodel.h b/src/gui/sharing/permissionmodel.h index 0bfb5bbfd07ff..e133f9223b738 100644 --- a/src/gui/sharing/permissionmodel.h +++ b/src/gui/sharing/permissionmodel.h @@ -28,11 +28,12 @@ class PermissionModel : public AbstractShareModel int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; - Qt::ItemFlags flags(const QModelIndex &index) const override; QHash roleNames() const override; void setShare(Share* share) override; + +private: + QMetaObject::Connection _permissionsChangedConnection; }; } diff --git a/test/testunifiedsharing.cpp b/test/testunifiedsharing.cpp index 42ad863f4e649..a30d4e9b4cdd2 100644 --- a/test/testunifiedsharing.cpp +++ b/test/testunifiedsharing.cpp @@ -12,6 +12,7 @@ #include "gui/sharing/getsharesjob.h" #include "gui/sharing/property.h" #include "gui/sharing/propertymodel.h" +#include "gui/sharing/permissionmodel.h" #include "gui/sharing/recipientmodel.h" #include "gui/sharing/recipientsearchmodel.h" #include "gui/sharing/removerecipientjob.h" @@ -209,6 +210,49 @@ private slots: delete share; } + void permissionModelIsReadOnlyAndTracksOnlyItsCurrentShare() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + const auto shareWithOnePermission = Share::fromJson( + QJsonDocument::fromJson(R"json({ + "ocs": {"data": { + "id": "share-1", + "permissions": [{"class": "view", "display_name": "View files", "enabled": true}] + }} + })json"), + fakeFolder.account()); + const auto shareWithTwoPermissions = Share::fromJson( + QJsonDocument::fromJson(R"json({ + "ocs": {"data": { + "id": "share-2", + "permissions": [ + {"class": "view", "display_name": "View files", "enabled": true}, + {"class": "download", "display_name": "Download files", "enabled": false} + ] + }} + })json"), + fakeFolder.account()); + + PermissionModel model; + model.setShare(shareWithOnePermission); + QCOMPARE(model.rowCount(), 1); + const auto index = model.index(0); + QCOMPARE(model.data(index, PermissionModel::LabelRole).toString(), "View files"_L1); + QVERIFY(!(model.flags(index) & Qt::ItemIsEditable)); + QVERIFY(!model.data(QModelIndex{}, PermissionModel::LabelRole).isValid()); + QCOMPARE(model.rowCount(model.index(0, 0)), 0); + + model.setShare(shareWithTwoPermissions); + QCOMPARE(model.rowCount(), 2); + shareWithOnePermission->updateFromJson(QJsonDocument::fromJson(R"json({ + "ocs": {"data": {"permissions": []}} + })json")); + QCOMPARE(model.rowCount(), 2); + + delete shareWithOnePermission; + delete shareWithTwoPermissions; + } + void requestsAreConfiguredBeforeTheyStart() { FakeFolder fakeFolder{{}, {}, {}, false}; From b87e7fc90885a5db2e96646826ff6831a40f23e6 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 13 Aug 2026 11:47:49 +0800 Subject: [PATCH 091/104] feat(sharing): Match server layout Signed-off-by: Claudio Cambra --- src/gui/sharing/CMakeLists.txt | 5 +- src/gui/sharing/MainPage.qml | 119 ----- src/gui/sharing/ShareDetailsPage.qml | 552 +++++++++------------- src/gui/sharing/ShareDialog.qml | 381 +++++++++++---- src/gui/sharing/ShareEntry.qml | 42 -- src/gui/sharing/ShareRow.qml | 75 +++ src/gui/sharing/recipient.cpp | 10 + src/gui/sharing/recipient.h | 4 + src/gui/sharing/unifiedsharelistmodel.cpp | 126 +++++ src/gui/sharing/unifiedsharelistmodel.h | 69 +++ test/testunifiedsharing.cpp | 296 ++++++------ theme/Style/Style.qml | 1 + 12 files changed, 972 insertions(+), 708 deletions(-) delete mode 100644 src/gui/sharing/MainPage.qml delete mode 100644 src/gui/sharing/ShareEntry.qml create mode 100644 src/gui/sharing/ShareRow.qml create mode 100644 src/gui/sharing/unifiedsharelistmodel.cpp create mode 100644 src/gui/sharing/unifiedsharelistmodel.h diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index de3d1329d8da0..d29f7853f7d12 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -62,6 +62,8 @@ target_sources(nextcloudGuiSharing share.cpp sharingcontroller.h sharingcontroller.cpp + unifiedsharelistmodel.h + unifiedsharelistmodel.cpp ) ecm_add_qml_module(nextcloudGuiSharing @@ -69,11 +71,10 @@ ecm_add_qml_module(nextcloudGuiSharing GENERATE_PLUGIN_SOURCE QML_FILES FieldDelegate.qml - MainPage.qml RecipientIcon.qml RecipientSearchField.qml ShareDetailsPage.qml - ShareEntry.qml + ShareRow.qml ShareDialog.qml ) diff --git a/src/gui/sharing/MainPage.qml b/src/gui/sharing/MainPage.qml deleted file mode 100644 index 3d68710edd809..0000000000000 --- a/src/gui/sharing/MainPage.qml +++ /dev/null @@ -1,119 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import QtQuick.Controls - -import com.nextcloud.desktopclient -import Style - -Page { - id: root - - property string localPath: "" - property string shortLocalPath: "" - property string fileId: "" - property Share selectedShare: null - required property SharingController sharingController - - signal shareSelected(Share share) - - title: qsTr("Share \"%1\"").arg(root.shortLocalPath) - - ColumnLayout { - anchors.fill: parent - anchors.leftMargin: Style.sharingDialogWindowMargin + Style.standardSpacing - anchors.rightMargin: Style.standardSpacing - anchors.topMargin: Style.standardSpacing - anchors.bottomMargin: Style.standardSpacing - - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: Style.sharingDialogPaneHeaderHeight - - Label { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - - text: qsTr("Shares") - font.pointSize: Style.subheaderFontPtSize - font.weight: Font.DemiBold - color: palette.text - } - - ToolButton { - Layout.preferredWidth: Style.sharingDialogPaneHeaderHeight - Layout.preferredHeight: Style.sharingDialogPaneHeaderHeight - Layout.alignment: Qt.AlignVCenter - - text: root.sharingController.creatingShare ? qsTr("Creating share…") : qsTr("Create share") - display: AbstractButton.IconOnly - icon.source: "image://svgimage-custom-color/add.svg/" + palette.buttonText - enabled: !root.sharingController.creatingShare && root.fileId.length > 0 - - ToolTip.visible: hovered - ToolTip.text: text - - onClicked: root.sharingController.createShare(root.fileId) - } - } - - Label { - Layout.fillWidth: true - Layout.fillHeight: true - - text: qsTr("This item has not been shared yet.") - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - wrapMode: Text.Wrap - - visible: root.sharingController.shares.length === 0 - } - - ListView { - id: sharesList - - Layout.fillWidth: true - Layout.fillHeight: true - - clip: true - spacing: Style.smallSpacing - model: root.sharingController.shares - visible: count > 0 - currentIndex: { - for (let row = 0; row < count; ++row) { - if (root.sharingController.shares[row] === root.selectedShare) { - return row - } - } - return -1 - } - - delegate: ShareEntry { - required property int index - required property Share modelData - - width: sharesList.width - share: modelData - highlighted: ListView.isCurrentItem - - onClicked: root.shareSelected(share) - } - } - - Label { - Layout.fillWidth: true - - text: root.sharingController.shareCreationError - color: Style.wizardErrorText - wrapMode: Text.Wrap - visible: text.length > 0 - } - - } -} diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index 493fc60c407d6..e176b32963847 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -11,9 +11,8 @@ import QtQuick.Controls import com.nextcloud.desktopclient import Style -import "qrc:/qml/src/gui/wizard/qml" -Page { +ColumnLayout { id: root required property SharingController sharingController @@ -21,10 +20,8 @@ Page { property string recipientOperationError: "" property string permissionUpdateError: "" property string propertyUpdateError: "" - property string shareActivationError: "" - property bool activatingShare: false - title: qsTr("Share details") + spacing: Style.standardSpacing function copyToClipboard(value: string): void { clipboardHelper.text = value @@ -38,374 +35,298 @@ Page { visible: false } - ColumnLayout { - anchors.fill: parent + RecipientSearchField { + id: recipientSearch + Layout.fillWidth: true - ScrollView { - Layout.fillHeight: true - Layout.fillWidth: true + account: root.sharingController.account + shareId: root.share.id - contentWidth: availableWidth - rightPadding: ScrollBar.vertical.visible ? ScrollBar.vertical.width + Style.standardSpacing : 0 + onRecipientSelected: (recipientType, recipientValue, recipientInstance) => { + root.recipientOperationError = "" + root.sharingController.addRecipient(root.share, recipientType, recipientValue, recipientInstance) + } + } - ColumnLayout { - width: parent.width - spacing: Style.standardSpacing + Label { + Layout.fillWidth: true - RecipientSearchField { - id: recipientSearch - Layout.fillWidth: true - - account: root.sharingController.account - shareId: root.share.id + text: root.recipientOperationError + color: Style.wizardErrorText + wrapMode: Text.Wrap + visible: text.length > 0 + } - onRecipientSelected: (recipientType, recipientValue, recipientInstance) => { - root.recipientOperationError = "" - root.sharingController.addRecipient(root.share, - recipientType, - recipientValue, - recipientInstance) - } - } + ListView { + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + interactive: false + spacing: Style.extraSmallSpacing + model: RecipientModel { + share: root.share + } - Label { - Layout.fillWidth: true + delegate: ItemDelegate { + id: recipientDelegate - text: root.recipientOperationError - color: Style.wizardErrorText - wrapMode: Text.Wrap - visible: text.length > 0 - } + required property var model - ListView { - Layout.fillWidth: true - Layout.preferredHeight: contentHeight - interactive: false - spacing: Style.extraSmallSpacing + width: ListView.view.width + hoverEnabled: true - model: RecipientModel { - share: root.share - } - - delegate: ItemDelegate { - id: recipientDelegate + contentItem: RowLayout { + spacing: Style.standardSpacing - required property var model + Image { + Layout.preferredWidth: Style.activityListButtonIconSize + Layout.preferredHeight: Style.activityListButtonIconSize - width: ListView.view.width - hoverEnabled: true + source: RecipientIcon.source(recipientDelegate.model.iconSvgUrl, recipientDelegate.model.iconLight, recipientDelegate.model.iconDark) + sourceSize.width: Style.activityListButtonIconSize + sourceSize.height: Style.activityListButtonIconSize + fillMode: Image.PreserveAspectFit + visible: source.toString().length > 0 + } - contentItem: RowLayout { - spacing: Style.standardSpacing + ColumnLayout { + Layout.fillWidth: true + spacing: 0 - Image { - Layout.preferredWidth: Style.activityListButtonIconSize - Layout.preferredHeight: Style.activityListButtonIconSize + Label { + Layout.fillWidth: true + text: recipientDelegate.model.label + elide: Text.ElideRight + } - source: RecipientIcon.source(recipientDelegate.model.iconSvgUrl, - recipientDelegate.model.iconLight, - recipientDelegate.model.iconDark) - sourceSize.width: Style.activityListButtonIconSize - sourceSize.height: Style.activityListButtonIconSize - fillMode: Image.PreserveAspectFit - visible: source.toString().length > 0 + Label { + Layout.fillWidth: true + text: { + const details = [] + if (recipientDelegate.model.instance) { + details.push(recipientDelegate.model.instance) } - - ColumnLayout { - Layout.fillWidth: true - spacing: 0 - - Label { - Layout.fillWidth: true - text: recipientDelegate.model.label - elide: Text.ElideRight - } - - Label { - Layout.fillWidth: true - text: { - const details = [] - if (recipientDelegate.model.instance) { - details.push(recipientDelegate.model.instance) - } - if (recipientDelegate.model.initiatorDisplayName) { - details.push(qsTr("Added by %1").arg(recipientDelegate.model.initiatorDisplayName)) - } - return details.join(" · ") - } - color: palette.placeholderText - elide: Text.ElideRight - visible: text.length > 0 - } + if (recipientDelegate.model.initiatorDisplayName) { + details.push(qsTr("Added by %1").arg(recipientDelegate.model.initiatorDisplayName)) } + return details.join(" · ") + } + color: palette.placeholderText + elide: Text.ElideRight + visible: text.length > 0 + } + } - Button { - Layout.preferredWidth: Style.activityListButtonWidth - Layout.preferredHeight: Style.activityListButtonHeight - - icon.source: "image://svgimage-custom-color/copy.svg/" + palette.buttonText - icon.width: Style.activityListButtonIconSize - icon.height: Style.activityListButtonIconSize - display: AbstractButton.IconOnly - visible: recipientDelegate.model.secretUrl !== "" - enabled: visible - - Accessible.name: qsTr("Copy recipient link") - ToolTip.visible: hovered - ToolTip.text: Accessible.name + Button { + Layout.preferredWidth: Style.activityListButtonWidth + Layout.preferredHeight: Style.activityListButtonHeight - onClicked: root.copyToClipboard(recipientDelegate.model.secretUrl) - } + icon.source: "image://svgimage-custom-color/copy.svg/" + palette.buttonText + icon.width: Style.activityListButtonIconSize + icon.height: Style.activityListButtonIconSize + display: AbstractButton.IconOnly + visible: recipientDelegate.model.secretUrl !== "" + enabled: visible - Button { - Layout.preferredWidth: Style.activityListButtonWidth - Layout.preferredHeight: Style.activityListButtonHeight - - icon.source: "image://svgimage-custom-color/change.svg/" + palette.buttonText - icon.width: Style.activityListButtonIconSize - icon.height: Style.activityListButtonIconSize - display: AbstractButton.IconOnly - visible: recipientDelegate.model.secretUpdatable - enabled: visible - - Accessible.name: recipientDelegate.model.secretUrl !== "" - ? qsTr("Regenerate recipient link") - : qsTr("Generate recipient link") - ToolTip.visible: hovered - ToolTip.text: Accessible.name - - onClicked: { - root.recipientOperationError = "" - root.sharingController.updateRecipientSecret( - root.share, - recipientDelegate.model.className, - recipientDelegate.model.value, - recipientDelegate.model.instance || "") - } - } + Accessible.name: qsTr("Copy recipient link") + ToolTip.visible: hovered + ToolTip.text: Accessible.name - Button { - Layout.preferredWidth: Style.activityListButtonWidth - Layout.preferredHeight: Style.activityListButtonHeight - - icon.source: "image://svgimage-custom-color/delete.svg/" + palette.buttonText - icon.width: Style.activityListButtonIconSize - icon.height: Style.activityListButtonIconSize - display: AbstractButton.IconOnly - - Accessible.name: qsTr("Remove recipient") - ToolTip.visible: hovered - ToolTip.text: Accessible.name - - onClicked: { - root.recipientOperationError = "" - root.sharingController.removeRecipient( - root.share, - recipientDelegate.model.className, - recipientDelegate.model.value, - recipientDelegate.model.instance || "") - } - } - } - } + onClicked: root.copyToClipboard(recipientDelegate.model.secretUrl) } - ComboBox { - id: permissionPresetSelector - Layout.fillWidth: true + Button { + Layout.preferredWidth: Style.activityListButtonWidth + Layout.preferredHeight: Style.activityListButtonHeight - readonly property var presetValues: [ - "OC\\Core\\Sharing\\Permission\\ViewSharePermissionPreset", - "OC\\Core\\Sharing\\Permission\\EditSharePermissionPreset", - "" - ] - model: [qsTr("Can view"), qsTr("Can edit"), qsTr("Custom permissions")] - delegate: ItemDelegate { - id: permissionPresetDelegate - - required property int index - required property string modelData - - width: permissionPresetSelector.width - text: modelData - highlighted: permissionPresetSelector.highlightedIndex === index - - contentItem: Label { - text: permissionPresetDelegate.text - color: permissionPresetDelegate.highlighted ? palette.highlightedText : palette.text - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - } - currentIndex: { - const preset = root.share.permissionPreset - if (preset.endsWith("\\ViewSharePermissionPreset")) { - return 0 - } - if (preset.endsWith("\\EditSharePermissionPreset")) { - return 1 - } - return 2 - } + icon.source: "image://svgimage-custom-color/change.svg/" + palette.buttonText + icon.width: Style.activityListButtonIconSize + icon.height: Style.activityListButtonIconSize + display: AbstractButton.IconOnly + visible: recipientDelegate.model.secretUpdatable + enabled: visible - onActivated: function(index) { - const preset = presetValues[index] - if (preset) { - root.permissionUpdateError = "" - root.sharingController.setPermissionPreset(root.share, preset) - } + Accessible.name: recipientDelegate.model.secretUrl !== "" ? qsTr("Regenerate recipient link") : qsTr("Generate recipient link") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + + onClicked: { + root.recipientOperationError = "" + root.sharingController.updateRecipientSecret(root.share, recipientDelegate.model.className, recipientDelegate.model.value, recipientDelegate.model.instance || "") } } - ListView { - Layout.fillWidth: true - Layout.preferredHeight: visible ? contentHeight : 0 - - interactive: false - visible: permissionPresetSelector.currentIndex === 2 - model: PermissionModel { - share: root.share - } + Button { + Layout.preferredWidth: Style.activityListButtonWidth + Layout.preferredHeight: Style.activityListButtonHeight - delegate: SwitchDelegate { - required property var model + icon.source: "image://svgimage-custom-color/delete.svg/" + palette.buttonText + icon.width: Style.activityListButtonIconSize + icon.height: Style.activityListButtonIconSize + display: AbstractButton.IconOnly - width: ListView.view.width - text: model.label - checked: model.enabled + Accessible.name: qsTr("Remove recipient") + ToolTip.visible: hovered + ToolTip.text: Accessible.name - onToggled: { - root.permissionUpdateError = "" - root.sharingController.setPermission(root.share, model.className, checked) - } + onClicked: { + root.recipientOperationError = "" + root.sharingController.removeRecipient(root.share, recipientDelegate.model.className, recipientDelegate.model.value, recipientDelegate.model.instance || "") } } + } + } + } - Label { - Layout.fillWidth: true + ComboBox { + id: permissionPresetSelector + Layout.fillWidth: true - text: root.permissionUpdateError - color: Style.wizardErrorText - wrapMode: Text.Wrap - visible: text.length > 0 - } + readonly property var presetValues: ["OC\\Core\\Sharing\\Permission\\ViewSharePermissionPreset", "OC\\Core\\Sharing\\Permission\\EditSharePermissionPreset", ""] + model: [qsTr("Can view"), qsTr("Can edit"), qsTr("Custom permissions")] + delegate: ItemDelegate { + id: permissionPresetDelegate - Label { - Layout.fillWidth: true - Layout.topMargin: Style.standardSpacing + required property int index + required property string modelData - text: qsTr("Sharing settings") - font.weight: Font.DemiBold - visible: propertyList.count > 0 || advancedPropertyList.count > 0 - } + width: permissionPresetSelector.width + text: modelData + highlighted: permissionPresetSelector.highlightedIndex === index - ListView { - id: propertyList + contentItem: Label { + text: permissionPresetDelegate.text + color: permissionPresetDelegate.highlighted ? palette.highlightedText : palette.text + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + currentIndex: { + const preset = root.share.permissionPreset + if (preset.endsWith("\\ViewSharePermissionPreset")) { + return 0 + } + if (preset.endsWith("\\EditSharePermissionPreset")) { + return 1 + } + return 2 + } - Layout.fillWidth: true - Layout.preferredHeight: contentHeight - interactive: false - spacing: Style.standardSpacing + onActivated: function (index) { + const preset = presetValues[index] + if (preset) { + root.permissionUpdateError = "" + root.sharingController.setPermissionPreset(root.share, preset) + } + } + } - model: PropertyModel { - share: root.share - } + ListView { + Layout.fillWidth: true + Layout.preferredHeight: visible ? contentHeight : 0 - delegate: FieldDelegate { - width: propertyList.width - height: item ? item.implicitHeight : 0 + interactive: false + visible: permissionPresetSelector.currentIndex === 2 + model: PermissionModel { + share: root.share + } - onValueEdited: (propertyClass, value) => { - root.propertyUpdateError = "" - root.sharingController.setProperty(root.share, propertyClass, value) - } - } - } + delegate: SwitchDelegate { + required property var model - Label { - Layout.fillWidth: true - Layout.topMargin: Style.standardSpacing + width: ListView.view.width + text: model.label + checked: model.enabled - text: qsTr("Advanced settings") - font.weight: Font.DemiBold - visible: advancedPropertyList.count > 0 - } + onToggled: { + root.permissionUpdateError = "" + root.sharingController.setPermission(root.share, model.className, checked) + } + } + } - ListView { - id: advancedPropertyList + Label { + Layout.fillWidth: true - Layout.fillWidth: true - Layout.preferredHeight: contentHeight - interactive: false - spacing: Style.standardSpacing + text: root.permissionUpdateError + color: Style.wizardErrorText + wrapMode: Text.Wrap + visible: text.length > 0 + } - model: PropertyModel { - share: root.share - advanced: true - } + Label { + Layout.fillWidth: true + Layout.topMargin: Style.standardSpacing - delegate: FieldDelegate { - width: advancedPropertyList.width - height: item ? item.implicitHeight : 0 + text: qsTr("Sharing settings") + font.weight: Font.DemiBold + visible: propertyList.count > 0 || advancedPropertyList.count > 0 + } - onValueEdited: (propertyClass, value) => { - root.propertyUpdateError = "" - root.sharingController.setProperty(root.share, propertyClass, value) - } - } - } + ListView { + id: propertyList - Label { - Layout.fillWidth: true + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + interactive: false + spacing: Style.standardSpacing - text: root.propertyUpdateError - color: Style.wizardErrorText - wrapMode: Text.Wrap - visible: text.length > 0 - } - } + model: PropertyModel { + share: root.share } - Label { - Layout.fillWidth: true + delegate: FieldDelegate { + width: propertyList.width + height: item ? item.implicitHeight : 0 - text: root.shareActivationError - color: Style.wizardErrorText - wrapMode: Text.Wrap - visible: text.length > 0 + onValueEdited: (propertyClass, value) => { + root.propertyUpdateError = "" + root.sharingController.setProperty(root.share, propertyClass, value) + } } + } - Label { - Layout.fillWidth: true + Label { + Layout.fillWidth: true + Layout.topMargin: Style.standardSpacing - text: qsTr("Changes to this share are applied immediately.") - color: palette.placeholderText - wrapMode: Text.Wrap - visible: root.share.state === Share.Active - } + text: qsTr("Advanced settings") + font.weight: Font.DemiBold + visible: advancedPropertyList.count > 0 + } - RowLayout { - Layout.fillWidth: true - visible: root.share.state === Share.Draft + ListView { + id: advancedPropertyList - Item { - Layout.fillWidth: true - } + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + interactive: false + spacing: Style.standardSpacing - WizardButton { - primary: true - text: root.activatingShare ? qsTr("Sending…") : qsTr("Send share") - enabled: !root.activatingShare + model: PropertyModel { + share: root.share + advanced: true + } - onClicked: { - root.shareActivationError = "" - root.activatingShare = true - root.sharingController.activateShare(root.share) - } + delegate: FieldDelegate { + width: advancedPropertyList.width + height: item ? item.implicitHeight : 0 + + onValueEdited: (propertyClass, value) => { + root.propertyUpdateError = "" + root.sharingController.setProperty(root.share, propertyClass, value) } } } + Label { + Layout.fillWidth: true + + text: root.propertyUpdateError + color: Style.wizardErrorText + wrapMode: Text.Wrap + visible: text.length > 0 + } + Connections { target: root.sharingController @@ -457,18 +378,5 @@ Page { root.permissionUpdateError = error } } - - function onShareActivated(share) { - if (share === root.share) { - root.activatingShare = false - } - } - - function onShareActivationFailed(share, error) { - if (share === root.share) { - root.activatingShare = false - root.shareActivationError = error - } - } } } diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index dfff500b7889a..9c200c23f7106 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -14,36 +14,70 @@ import com.nextcloud.desktopclient import Style import "qrc:/qml/src/gui" import "qrc:/qml/src/gui/tray" -import "qrc:/qml/src/gui/wizard/qml" WizardStyledWindow { id: dialog visible: true - property var account + required property var account property string localPath: "" property string shortLocalPath: dialog.localPath.split("/").reverse()[0] property string fileId: "" property Share selectedShare: null + property Share sharePendingDeletion: null + property bool selectShareAfterCreation: false + property bool activatingShare: false + property string shareActivationError: "" - title: qsTr("Share \"%1\"").arg(dialog.shortLocalPath) + property FileDetails fileDetails: FileDetails { + localPath: dialog.localPath + } + + title: qsTr("Share \"%1\"").arg(dialog.fileDetails.name || dialog.shortLocalPath || qsTr("File")) width: Style.sharingDialogWidth height: Style.sharingDialogHeight minimumWidth: Style.sharingDialogMinimumWidth minimumHeight: Style.sharingDialogMinimumHeight + function currentShares() { + return sharingController ? Array.from(sharingController.shares || []) : [] + } + function reconcileSelectedShare() { - const shares = sharingController.shares + const shares = dialog.currentShares() if (dialog.selectedShare && shares.indexOf(dialog.selectedShare) !== -1) { return } - dialog.selectedShare = shares.length > 0 ? shares[0] : null + dialog.selectedShare = null + } + + function shareTitle(share): string { + if (!share || !share.recipients) { + return qsTr("Share settings") + } + + const names = [] + for (const recipient of Array.from(share.recipients)) { + if (recipient && recipient.displayName) { + names.push(recipient.displayName) + } + } + return names.length > 0 ? qsTr("Share with %1").arg(names.join(", ")) : qsTr("New share") + } + + onSelectedShareChanged: { + dialog.activatingShare = false + dialog.shareActivationError = "" } SharingController { id: sharingController - account: dialog.account + } + + UnifiedShareListModel { + id: shareListModel + sharingController: sharingController } Shortcut { @@ -52,6 +86,7 @@ WizardStyledWindow { } Component.onCompleted: { + sharingController.account = dialog.account sharingController.initialize(dialog.fileId) } @@ -59,7 +94,32 @@ WizardStyledWindow { target: sharingController function onSharesChanged() { - dialog.reconcileSelectedShare() + if (dialog.selectShareAfterCreation) { + const shares = dialog.currentShares() + dialog.selectedShare = shares.length > 0 ? shares[shares.length - 1] : null + dialog.selectShareAfterCreation = false + } else { + dialog.reconcileSelectedShare() + } + } + + function onShareCreationErrorChanged() { + if (sharingController.shareCreationError.length > 0) { + dialog.selectShareAfterCreation = false + } + } + + function onShareActivated(share) { + if (share === dialog.selectedShare) { + dialog.activatingShare = false + } + } + + function onShareActivationFailed(share, error) { + if (share === dialog.selectedShare) { + dialog.activatingShare = false + dialog.shareActivationError = error + } } } @@ -68,17 +128,46 @@ WizardStyledWindow { anchors.topMargin: Style.standardSpacing spacing: 0 - EnforcedPlainTextLabel { + ColumnLayout { Layout.leftMargin: Style.sharingDialogWindowMargin Layout.rightMargin: Style.sharingDialogWindowMargin Layout.bottomMargin: Style.standardSpacing + spacing: Style.smallSpacing - text: dialog.title - elide: Text.ElideRight - font.pointSize: Style.titleFontPtSize - font.weight: Font.DemiBold - color: palette.text Layout.fillWidth: true + + EnforcedPlainTextLabel { + Layout.fillWidth: true + + text: dialog.fileDetails.name || dialog.shortLocalPath || qsTr("File") + elide: Text.ElideRight + font.pointSize: Style.titleFontPtSize + font.weight: Font.DemiBold + color: palette.text + } + + EnforcedPlainTextLabel { + Layout.fillWidth: true + + text: { + const details = [] + if (dialog.fileDetails.sizeString) { + details.push(dialog.fileDetails.sizeString) + } + if (dialog.fileDetails.lastChangedString) { + details.push(dialog.fileDetails.lastChangedString) + } + const owner = dialog.account ? (dialog.account.davDisplayName || dialog.account.davUser) : "" + if (owner) { + details.push(owner) + } + return details.join(" · ") + } + color: Style.wizardSecondaryText + elide: Text.ElideRight + font.pointSize: Style.defaultFontPtSize + visible: text.length > 0 + } } Rectangle { @@ -87,109 +176,220 @@ WizardStyledWindow { color: Style.sharingDialogSeparatorColor } - SplitView { + RowLayout { Layout.fillWidth: true - Layout.fillHeight: true - orientation: Qt.Horizontal - handle: Rectangle { - implicitWidth: Style.normalBorderWidth - color: Style.sharingDialogSeparatorColor - } - - MainPage { - id: mainPage - - SplitView.minimumWidth: Style.sharingDialogSidebarMinimumWidth - SplitView.preferredWidth: Style.sharingDialogSidebarPreferredWidth - SplitView.maximumWidth: Style.sharingDialogSidebarMaximumWidth + Layout.leftMargin: Style.sharingDialogWindowMargin + Layout.rightMargin: Style.sharingDialogWindowMargin + Layout.topMargin: Style.standardSpacing + Layout.preferredHeight: Style.sharingDialogPaneHeaderHeight - sharingController: sharingController - fileId: dialog.fileId - selectedShare: dialog.selectedShare + EnforcedPlainTextLabel { + Layout.fillWidth: true - onShareSelected: share => { - dialog.selectedShare = share - } + text: dialog.selectedShare ? dialog.shareTitle(dialog.selectedShare) : qsTr("Sharing") + font.pointSize: Style.subheaderFontPtSize + font.weight: Font.DemiBold } + } - Page { - SplitView.fillWidth: true + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: dialog.selectedShare ? 1 : 0 + + Item { + ListView { + id: shareListView - ColumnLayout { anchors.fill: parent - anchors.leftMargin: Style.standardSpacing - anchors.rightMargin: Style.sharingDialogWindowMargin + Style.standardSpacing - anchors.topMargin: Style.standardSpacing + anchors.leftMargin: Style.sharingDialogWindowMargin + anchors.rightMargin: Style.sharingDialogWindowMargin anchors.bottomMargin: Style.standardSpacing + clip: true + spacing: Style.extraSmallSpacing + model: shareListModel - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: Style.sharingDialogPaneHeaderHeight + header: ColumnLayout { + width: shareListView.width + spacing: Style.standardSpacing - EnforcedPlainTextLabel { - Layout.alignment: Qt.AlignVCenter + ItemDelegate { + Layout.fillWidth: true + text: sharingController.creatingShare ? qsTr("Creating share…") : qsTr("Create new share") + enabled: !sharingController.creatingShare && dialog.fileId.length > 0 + icon.source: "image://svgimage-custom-color/add.svg/" + palette.buttonText + + onClicked: { + dialog.selectShareAfterCreation = true + sharingController.createShare(dialog.fileId) + } + } - text: qsTr("Share details") - elide: Text.ElideRight - font.pointSize: Style.subheaderFontPtSize - font.weight: Font.DemiBold - color: palette.text + Label { Layout.fillWidth: true + text: sharingController.shareCreationError + color: Style.wizardErrorText + wrapMode: Text.Wrap + visible: text.length > 0 } - ToolButton { - visible: dialog.selectedShare !== null - enabled: !sharingController.destroyingShare - display: AbstractButton.IconOnly - icon.source: "image://svgimage-custom-color/delete.svg/" + palette.buttonText - Accessible.name: qsTr("Delete share") - ToolTip.visible: hovered - ToolTip.text: Accessible.name + Label { + Layout.fillWidth: true + text: sharingController.shareDestructionError + color: Style.wizardErrorText + wrapMode: Text.Wrap + visible: text.length > 0 + } - onClicked: deleteShareConfirmation.open() + Label { + Layout.fillWidth: true + text: qsTr("This item has not been shared yet.") + color: palette.placeholderText + wrapMode: Text.Wrap + visible: shareListView.count === 0 } } - Label { - Layout.fillWidth: true - text: sharingController.shareDestructionError - color: Style.wizardErrorText - wrapMode: Text.Wrap - visible: text.length > 0 + section.property: "section" + section.criteria: ViewSection.FullString + section.delegate: Label { + required property string section + + width: shareListView.width + topPadding: Style.standardSpacing + bottomPadding: Style.extraSmallSpacing + text: section === "external" ? qsTr("External shares") : qsTr("Internal shares") + font.weight: Font.DemiBold } - Item { - Layout.fillWidth: true - Layout.fillHeight: true + delegate: ShareRow { + width: ListView.view.width + onConfigureRequested: dialog.selectedShare = share + } + + ScrollBar.vertical: ScrollBar { + parent: shareListView + anchors.top: shareListView.top + anchors.right: shareListView.right + anchors.bottom: shareListView.bottom + policy: ScrollBar.AsNeeded + } + } + } - Loader { - anchors.fill: parent + ScrollView { + id: shareDetailsScrollView - active: dialog.selectedShare !== null - sourceComponent: detailsComponent - } + contentWidth: availableWidth + clip: true - Label { - anchors.fill: parent + ColumnLayout { + width: shareDetailsScrollView.availableWidth - text: qsTr("Select a share to view its details.") - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - wrapMode: Text.Wrap - visible: dialog.selectedShare === null + Loader { + id: shareDetailsLoader + + Layout.fillWidth: true + Layout.leftMargin: Style.sharingDialogWindowMargin + Layout.rightMargin: Style.sharingDialogWindowMargin + Layout.bottomMargin: Style.standardSpacing + Layout.preferredHeight: active && item ? item.implicitHeight : 0 + active: dialog.selectedShare !== null + visible: active + + sourceComponent: ShareDetailsPage { + sharingController: sharingController + share: dialog.selectedShare } } } + + ScrollBar.horizontal: ScrollBar { + policy: ScrollBar.AlwaysOff + } + + ScrollBar.vertical: ScrollBar { + parent: shareDetailsScrollView + anchors.top: shareDetailsScrollView.top + anchors.right: shareDetailsScrollView.right + anchors.bottom: shareDetailsScrollView.bottom + policy: ScrollBar.AsNeeded + } } } - } - Component { - id: detailsComponent + ColumnLayout { + Layout.fillWidth: true + spacing: Style.smallSpacing + visible: dialog.selectedShare !== null + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Style.normalBorderWidth + color: Style.sharingDialogSeparatorColor + } + + Label { + Layout.fillWidth: true + Layout.leftMargin: Style.sharingDialogWindowMargin + Layout.rightMargin: Style.sharingDialogWindowMargin + + text: dialog.shareActivationError + color: Style.wizardErrorText + wrapMode: Text.Wrap + visible: text.length > 0 + } + + Label { + Layout.fillWidth: true + Layout.leftMargin: Style.sharingDialogWindowMargin + Layout.rightMargin: Style.sharingDialogWindowMargin + + text: qsTr("Changes to this share are applied immediately.") + color: palette.placeholderText + wrapMode: Text.Wrap + visible: dialog.selectedShare && dialog.selectedShare.state === Share.Active + } + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: Style.sharingDialogWindowMargin + Layout.rightMargin: Style.sharingDialogWindowMargin + Layout.bottomMargin: Style.standardSpacing + + Button { + text: qsTr("Delete share") + enabled: !sharingController.destroyingShare + flat: true + icon.source: "image://svgimage-custom-color/delete.svg/" + palette.buttonText + onClicked: { + dialog.sharePendingDeletion = dialog.selectedShare + deleteShareConfirmation.open() + } + } - ShareDetailsPage { - sharingController: sharingController - share: dialog.selectedShare + Item { + Layout.fillWidth: true + } + + Button { + text: qsTr("Close") + onClicked: dialog.selectedShare = null + } + + Button { + text: dialog.activatingShare ? qsTr("Sending…") : qsTr("Send share") + enabled: !dialog.activatingShare + highlighted: true + visible: dialog.selectedShare && dialog.selectedShare.state === Share.Draft + + onClicked: { + dialog.shareActivationError = "" + dialog.activatingShare = true + sharingController.activateShare(dialog.selectedShare) + } + } + } } } @@ -221,9 +421,16 @@ WizardStyledWindow { } onAccepted: { - sharingController.destroyShare(dialog.selectedShare) + if (dialog.sharePendingDeletion) { + if (dialog.selectedShare === dialog.sharePendingDeletion) { + dialog.selectedShare = null + } + sharingController.destroyShare(dialog.sharePendingDeletion) + dialog.sharePendingDeletion = null + } close() } - } + onRejected: dialog.sharePendingDeletion = null + } } diff --git a/src/gui/sharing/ShareEntry.qml b/src/gui/sharing/ShareEntry.qml deleted file mode 100644 index 3917b68710943..0000000000000 --- a/src/gui/sharing/ShareEntry.qml +++ /dev/null @@ -1,42 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Controls - -import com.nextcloud.desktopclient -import Style - -ItemDelegate { - id: root - - required property Share share - - function recipientNames(): string { - const names = [] - for (const recipient of root.share.recipients) { - if (recipient.displayName) { - names.push(recipient.displayName) - } - } - return names.join(", ") - } - - text: { - const recipients = root.recipientNames() - return recipients.length > 0 - ? qsTr("Shared with %1").arg(recipients) - : qsTr("New share") - } - - contentItem: Label { - text: root.text - color: root.highlighted ? root.palette.highlightedText : root.palette.text - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } -} diff --git a/src/gui/sharing/ShareRow.qml b/src/gui/sharing/ShareRow.qml new file mode 100644 index 0000000000000..9641de880d081 --- /dev/null +++ b/src/gui/sharing/ShareRow.qml @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +import com.nextcloud.desktopclient +import Style + +ItemDelegate { + id: root + + required property Share share + signal configureRequested + + implicitHeight: contentItem.implicitHeight + topPadding + bottomPadding + hoverEnabled: true + + contentItem: RowLayout { + spacing: Style.smallSpacing + + Image { + Layout.preferredWidth: Style.activityListButtonIconSize + Layout.preferredHeight: Style.activityListButtonIconSize + source: "image://svgimage-custom-color/share.svg/" + palette.buttonText + sourceSize: Qt.size(Style.activityListButtonIconSize, Style.activityListButtonIconSize) + fillMode: Image.PreserveAspectFit + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Label { + Layout.fillWidth: true + text: { + const names = [] + if (root.share && root.share.recipients) { + for (const recipient of Array.from(root.share.recipients)) { + if (recipient && recipient.displayName) { + names.push(recipient.displayName) + } + } + } + return names.length > 0 ? names.join(", ") : qsTr("New share") + } + color: palette.text + elide: Text.ElideRight + } + + Label { + Layout.fillWidth: true + text: root.share && root.share.recipients && root.share.recipients.length > 0 ? qsTr("%1 recipient(s)").arg(root.share.recipients.length) : qsTr("Not shared yet") + color: palette.placeholderText + elide: Text.ElideRight + visible: text.length > 0 + } + } + + ToolButton { + Layout.preferredWidth: Style.activityListButtonWidth + Layout.preferredHeight: Style.activityListButtonHeight + display: AbstractButton.IconOnly + icon.source: "image://svgimage-custom-color/more.svg/" + palette.buttonText + Accessible.name: qsTr("Configure share") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + + onClicked: root.configureRequested() + } + } +} diff --git a/src/gui/sharing/recipient.cpp b/src/gui/sharing/recipient.cpp index 3b03cde4ef5fd..47ba8feee9b26 100644 --- a/src/gui/sharing/recipient.cpp +++ b/src/gui/sharing/recipient.cpp @@ -65,6 +65,11 @@ const std::optional &Recipient::instance() const return _instance; } +QString Recipient::instanceString() const +{ + return _instance.value_or(QString{}); +} + QString Recipient::iconSvg() const { return _iconSvg; @@ -95,6 +100,11 @@ const std::optional &Recipient::secretUrl() const return _secretUrl; } +QString Recipient::secretUrlString() const +{ + return _secretUrl.value_or(QString{}); +} + QString Recipient::initiatorDisplayName() const { return _initiatorDisplayName; diff --git a/src/gui/sharing/recipient.h b/src/gui/sharing/recipient.h index dc7b888e01dd2..9f9c7e895655c 100644 --- a/src/gui/sharing/recipient.h +++ b/src/gui/sharing/recipient.h @@ -22,7 +22,9 @@ class Recipient : public QObject Q_PROPERTY(QString className READ className CONSTANT) Q_PROPERTY(QString displayName READ displayName CONSTANT) Q_PROPERTY(QString value READ value CONSTANT) + Q_PROPERTY(QString instance READ instanceString CONSTANT) Q_PROPERTY(bool secretUpdatable READ secretUpdatable CONSTANT) + Q_PROPERTY(QString secretUrl READ secretUrlString CONSTANT) public: /** @brief Creates a recipient from its unified sharing API representation. */ @@ -36,6 +38,7 @@ class Recipient : public QObject [[nodiscard]] QString value() const; /** @brief Returns the recipient's remote instance, or no value for a local recipient. */ [[nodiscard]] const std::optional &instance() const; + [[nodiscard]] QString instanceString() const; /** @brief Returns a themeable SVG icon supplied by the server, if present. */ [[nodiscard]] QString iconSvg() const; /** @brief Returns the light-theme icon URL supplied by the server, if present. */ @@ -48,6 +51,7 @@ class Recipient : public QObject [[nodiscard]] const std::optional &secretValue() const; /** @brief Returns the public URL associated with the recipient secret, when exposed. */ [[nodiscard]] const std::optional &secretUrl() const; + [[nodiscard]] QString secretUrlString() const; /** @brief Returns the user-facing name of the user who added the recipient. */ [[nodiscard]] QString initiatorDisplayName() const; diff --git a/src/gui/sharing/unifiedsharelistmodel.cpp b/src/gui/sharing/unifiedsharelistmodel.cpp new file mode 100644 index 0000000000000..e875bd6a60fac --- /dev/null +++ b/src/gui/sharing/unifiedsharelistmodel.cpp @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "unifiedsharelistmodel.h" + +#include "recipient.h" +#include "share.h" +#include "sharingcontroller.h" + +#include +#include + +using namespace Qt::StringLiterals; +using namespace OCC::Gui::Sharing; + +namespace +{ +constexpr auto internalSection = "internal"_L1; +constexpr auto externalSection = "external"_L1; +} + +UnifiedShareListModel::UnifiedShareListModel(QObject *parent) + : QAbstractListModel{parent} +{ +} + +SharingController *UnifiedShareListModel::sharingController() const +{ + return _sharingController; +} + +void UnifiedShareListModel::setSharingController(SharingController *sharingController) +{ + if (_sharingController == sharingController) { + return; + } + + if (_sharingController) { + disconnect(_sharingController, nullptr, this, nullptr); + } + + _sharingController = sharingController; + if (_sharingController) { + connect(_sharingController, &SharingController::sharesChanged, this, &UnifiedShareListModel::rebuild); + connect(_sharingController, &QObject::destroyed, this, [this] { + _sharingController = nullptr; + rebuild(); + }); + } + + rebuild(); + Q_EMIT sharingControllerChanged(); +} + +int UnifiedShareListModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : _shares.size(); +} + +QVariant UnifiedShareListModel::data(const QModelIndex &index, int role) const +{ + if (!checkIndex(index, CheckIndexOption::IndexIsValid | CheckIndexOption::ParentIsInvalid)) { + return {}; + } + + const auto share = _shares.at(index.row()); + switch (role) { + case ShareRole: + return QVariant::fromValue(share); + case SectionRole: + return isExternalShare(share) ? externalSection : internalSection; + default: + return {}; + } +} + +QHash UnifiedShareListModel::roleNames() const +{ + return { + {ShareRole, "share"}, + {SectionRole, "section"}, + }; +} + +void UnifiedShareListModel::rebuild() +{ + beginResetModel(); + + for (const auto &connection : std::as_const(_shareConnections)) { + disconnect(connection); + } + _shareConnections.clear(); + + _shares = _sharingController ? _sharingController->shares() : QList{}; + std::stable_sort(_shares.begin(), _shares.end(), [](const Share *left, const Share *right) { + return isExternalShare(left) < isExternalShare(right); + }); + + _shareConnections.reserve(_shares.size()); + for (const auto share : std::as_const(_shares)) { + if (share) { + _shareConnections.append(connect(share, &Share::recipientsChanged, this, &UnifiedShareListModel::rebuild)); + } + } + + endResetModel(); +} + +bool UnifiedShareListModel::isExternalShare(const Share *share) +{ + if (!share) { + return false; + } + + return std::ranges::any_of(share->recipients(), [](const QPointer &recipient) { + if (!recipient) { + return false; + } + + const auto className = recipient->className().toLower(); + return recipient->instance().has_value() || recipient->secretUrl().has_value() || className.contains("federated"_L1) || className.contains("remote"_L1) + || className.contains("email"_L1) || className.contains("link"_L1); + }); +} diff --git a/src/gui/sharing/unifiedsharelistmodel.h b/src/gui/sharing/unifiedsharelistmodel.h new file mode 100644 index 0000000000000..b036742dd97a8 --- /dev/null +++ b/src/gui/sharing/unifiedsharelistmodel.h @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include +#include + +#include + +namespace OCC::Gui::Sharing +{ + +class Share; +class SharingController; + +/** + * @brief Exposes unified shares in the order needed by the categorized sharing list. + */ +class UnifiedShareListModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(SharingController *sharingController READ sharingController WRITE setSharingController NOTIFY sharingControllerChanged) + +public: + /** @brief Roles exposed to the sharing list delegates. */ + enum Role { + ShareRole = Qt::UserRole + 1, + SectionRole, + }; + Q_ENUM(Role) + + /** @brief Creates an empty model. */ + explicit UnifiedShareListModel(QObject *parent = nullptr); + + /** @brief Returns the controller that supplies the shares. */ + [[nodiscard]] SharingController *sharingController() const; + + /** @brief Sets the controller whose shares are exposed by this model. */ + void setSharingController(SharingController *sharingController); + + /** @brief Returns the number of shares in the root list. */ + [[nodiscard]] int rowCount(const QModelIndex &parent = {}) const override; + + /** @brief Returns the share or section value for a model index. */ + [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; + + /** @brief Returns the QML role names exposed by the model. */ + [[nodiscard]] QHash roleNames() const override; + +Q_SIGNALS: + /** @brief Emitted when the source controller changes. */ + void sharingControllerChanged(); + +private: + QPointer _sharingController; + QList _shares; + QList _shareConnections; + + void rebuild(); + [[nodiscard]] static bool isExternalShare(const Share *share); +}; + +} diff --git a/test/testunifiedsharing.cpp b/test/testunifiedsharing.cpp index a30d4e9b4cdd2..ade06a3180310 100644 --- a/test/testunifiedsharing.cpp +++ b/test/testunifiedsharing.cpp @@ -10,9 +10,9 @@ #include "gui/sharing/generatesecretjob.h" #include "gui/sharing/getsharejob.h" #include "gui/sharing/getsharesjob.h" +#include "gui/sharing/permissionmodel.h" #include "gui/sharing/property.h" #include "gui/sharing/propertymodel.h" -#include "gui/sharing/permissionmodel.h" #include "gui/sharing/recipientmodel.h" #include "gui/sharing/recipientsearchmodel.h" #include "gui/sharing/removerecipientjob.h" @@ -26,6 +26,7 @@ #include "gui/sharing/share.h" #include "gui/sharing/sharingconstants.h" #include "gui/sharing/sharingcontroller.h" +#include "gui/sharing/unifiedsharelistmodel.h" #include "gui/sharing/unifiedsharingrequest.h" #include "gui/sharing/updatesharejob.h" #include "syncenginetestutils.h" @@ -48,55 +49,56 @@ private slots: void recipientsPreserveServerIdentityAndCapabilities() { FakeFolder fakeFolder{{}, {}, {}, false}; - const auto share = Share::fromJson( - QJsonDocument{QJsonObject{ - {"ocs"_L1, - QJsonObject{ - {"data"_L1, - QJsonObject{ - {"id"_L1, "share-1"_L1}, - {"recipients"_L1, - QJsonArray{QJsonObject{ - {"class"_L1, "federated-user"_L1}, - {"display_name"_L1, "Alice"_L1}, - {"value"_L1, "alice"_L1}, - {"instance"_L1, "cloud.example.com"_L1}, - {"icon"_L1, - QJsonObject{ - {"svg"_L1, ""_L1}, - {"light"_L1, "https://cloud.example.com/light.svg"_L1}, - {"dark"_L1, "https://cloud.example.com/dark.svg"_L1}, - }}, - {"secret"_L1, - QJsonObject{ - {"updatable"_L1, true}, - {"value"_L1, "public-secret"_L1}, - {"url"_L1, "https://cloud.example.com/s/public-secret"_L1}, - }}, - {"initiator"_L1, QJsonObject{{"display_name"_L1, "Bob"_L1}}}, - }, - QJsonObject{ - {"class"_L1, "user"_L1}, - {"display_name"_L1, "Carol"_L1}, - {"value"_L1, "carol"_L1}, - {"secret"_L1, QJsonObject{{"updatable"_L1, false}}}, - }}}, - }}, - }}, - }}, - fakeFolder.account()); + const auto share = Share::fromJson(QJsonDocument{QJsonObject{ + {"ocs"_L1, + QJsonObject{ + {"data"_L1, + QJsonObject{ + {"id"_L1, "share-1"_L1}, + {"recipients"_L1, + QJsonArray{QJsonObject{ + {"class"_L1, "federated-user"_L1}, + {"display_name"_L1, "Alice"_L1}, + {"value"_L1, "alice"_L1}, + {"instance"_L1, "cloud.example.com"_L1}, + {"icon"_L1, + QJsonObject{ + {"svg"_L1, ""_L1}, + {"light"_L1, "https://cloud.example.com/light.svg"_L1}, + {"dark"_L1, "https://cloud.example.com/dark.svg"_L1}, + }}, + {"secret"_L1, + QJsonObject{ + {"updatable"_L1, true}, + {"value"_L1, "public-secret"_L1}, + {"url"_L1, "https://cloud.example.com/s/public-secret"_L1}, + }}, + {"initiator"_L1, QJsonObject{{"display_name"_L1, "Bob"_L1}}}, + }, + QJsonObject{ + {"class"_L1, "user"_L1}, + {"display_name"_L1, "Carol"_L1}, + {"value"_L1, "carol"_L1}, + {"secret"_L1, QJsonObject{{"updatable"_L1, false}}}, + }}}, + }}, + }}, + }}, + fakeFolder.account()); QCOMPARE(share->recipients().size(), 2); const auto recipient = share->recipients().constFirst(); QCOMPARE(recipient->className(), "federated-user"_L1); QCOMPARE(recipient->value(), "alice"_L1); QCOMPARE(recipient->instance(), std::optional{"cloud.example.com"_L1}); + QCOMPARE(recipient->instanceString(), "cloud.example.com"_L1); QCOMPARE(recipient->iconSvg(), ""_L1); QCOMPARE(recipient->iconLight(), "https://cloud.example.com/light.svg"_L1); QCOMPARE(recipient->iconDark(), "https://cloud.example.com/dark.svg"_L1); QVERIFY(recipient->secretUpdatable()); QCOMPARE(recipient->secretValue(), std::optional{"public-secret"_L1}); QCOMPARE(recipient->secretUrl(), std::optional{"https://cloud.example.com/s/public-secret"_L1}); + QCOMPARE(recipient->secretUrlString(), "https://cloud.example.com/s/public-secret"_L1); QCOMPARE(recipient->initiatorDisplayName(), "Bob"_L1); RecipientModel model; @@ -104,8 +106,7 @@ private slots: QCOMPARE(model.rowCount(), 2); const auto index = model.index(0); QCOMPARE(model.data(index, RecipientModel::InstanceRole).toString(), "cloud.example.com"_L1); - QCOMPARE(model.data(index, RecipientModel::IconSvgUrlRole).toString(), - "data:image/svg+xml;base64,PHN2Zy8+"_L1); + QCOMPARE(model.data(index, RecipientModel::IconSvgUrlRole).toString(), "data:image/svg+xml;base64,PHN2Zy8+"_L1); QVERIFY(model.data(index, RecipientModel::SecretUpdatableRole).toBool()); QCOMPARE(model.data(index, RecipientModel::SecretUrlRole).toString(), "https://cloud.example.com/s/public-secret"_L1); QCOMPARE(model.data(index, RecipientModel::InitiatorDisplayNameRole).toString(), "Bob"_L1); @@ -121,8 +122,7 @@ private slots: void sharePropertiesPreserveServerMetadataAndUseTypedFields() { FakeFolder fakeFolder{{}, {}, {}, false}; - const auto share = Share::fromJson( - QJsonDocument::fromJson(R"json({ + const auto share = Share::fromJson(QJsonDocument::fromJson(R"json({ "ocs": { "data": { "id": "share-1", @@ -180,7 +180,7 @@ private slots: } } })json"), - fakeFolder.account()); + fakeFolder.account()); PropertyModel model; model.setShare(share); @@ -213,16 +213,14 @@ private slots: void permissionModelIsReadOnlyAndTracksOnlyItsCurrentShare() { FakeFolder fakeFolder{{}, {}, {}, false}; - const auto shareWithOnePermission = Share::fromJson( - QJsonDocument::fromJson(R"json({ + const auto shareWithOnePermission = Share::fromJson(QJsonDocument::fromJson(R"json({ "ocs": {"data": { "id": "share-1", "permissions": [{"class": "view", "display_name": "View files", "enabled": true}] }} })json"), - fakeFolder.account()); - const auto shareWithTwoPermissions = Share::fromJson( - QJsonDocument::fromJson(R"json({ + fakeFolder.account()); + const auto shareWithTwoPermissions = Share::fromJson(QJsonDocument::fromJson(R"json({ "ocs": {"data": { "id": "share-2", "permissions": [ @@ -231,7 +229,7 @@ private slots: ] }} })json"), - fakeFolder.account()); + fakeFolder.account()); PermissionModel model; model.setShare(shareWithOnePermission); @@ -343,12 +341,7 @@ private slots: } }; - verifyRequest(new SearchRecipientsJob{account, - "ali"_L1, - 10, - 20, - {"user-class"_L1, "group-class"_L1}, - "share-1"_L1}, + verifyRequest(new SearchRecipientsJob{account, "ali"_L1, 10, 20, {"user-class"_L1, "group-class"_L1}, "share-1"_L1}, "GET", "/ocs/v2.php/apps/sharing/api/v1/recipients", {{"query"_L1, "ali"_L1}, @@ -403,10 +396,7 @@ private slots: {}, {{"permissionPresetClass"_L1, "preset-class"_L1}}); verifyRequest(new DestroyShareJob{account, "share-1"_L1}, "DELETE", "/ocs/v2.php/apps/sharing/api/v1/share/share-1"); - verifyRequest(new GetShareJob{account, - "share-1"_L1, - "secret"_L1, - QJsonObject{{"argument-class"_L1, QJsonObject{{"key"_L1, "value"_L1}}}}}, + verifyRequest(new GetShareJob{account, "share-1"_L1, "secret"_L1, QJsonObject{{"argument-class"_L1, QJsonObject{{"key"_L1, "value"_L1}}}}}, "POST", "/ocs/v2.php/apps/sharing/api/v1/share/share-1", {}, @@ -421,20 +411,12 @@ private slots: "/ocs/v2.php/apps/sharing/api/v1/share/share-1/recipient", {}, {{"class"_L1, "recipient-class"_L1}, {"value"_L1, "alice"_L1}}); - verifyRequest(new GetShareJob{account, "share-1"_L1}, - "POST", - "/ocs/v2.php/apps/sharing/api/v1/share/share-1"); - verifyRequest(new UnifiedSharingRequest{account, - "/ocs/v2.php/apps/sharing/api/v1/share/share-1"_L1, - "POST"_ba, - {.body = QJsonObject{}}}, + verifyRequest(new GetShareJob{account, "share-1"_L1}, "POST", "/ocs/v2.php/apps/sharing/api/v1/share/share-1"); + verifyRequest(new UnifiedSharingRequest{account, "/ocs/v2.php/apps/sharing/api/v1/share/share-1"_L1, "POST"_ba, {.body = QJsonObject{}}}, "POST", "/ocs/v2.php/apps/sharing/api/v1/share/share-1"); QCOMPARE(requestContentType, "application/json"); - verifyRequest(new GetSharesJob{account}, - "GET", - "/ocs/v2.php/apps/sharing/api/v1/shares", - {{"limit"_L1, "100"_L1}}); + verifyRequest(new GetSharesJob{account}, "GET", "/ocs/v2.php/apps/sharing/api/v1/shares", {{"limit"_L1, "100"_L1}}); QCOMPARE(requestCount, 19); delete share; @@ -474,8 +456,7 @@ private slots: void partialShareUpdatesPreserveOmittedFields() { FakeFolder fakeFolder{{}, {}, {}, false}; - const auto share = Share::fromJson( - QJsonDocument::fromJson(R"json({ + const auto share = Share::fromJson(QJsonDocument::fromJson(R"json({ "ocs": { "data": { "id": "share-1", @@ -500,7 +481,7 @@ private slots: } } })json"), - fakeFolder.account()); + fakeFolder.account()); share->updateFromJson(QJsonDocument::fromJson(R"json({ "ocs": { @@ -588,6 +569,71 @@ private slots: QCOMPARE(controller.shares().at(1)->id(), "share-2"_L1); } + void unifiedShareListModelGroupsSharesInOneSectionedList() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + return new FakePayloadReply{operation, + request, + R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": [{ + "id": "external-share", + "state": "active", + "recipients": [{ + "class": "email", + "display_name": "alice@example.com", + "value": "alice@example.com" + }] + }, { + "id": "internal-share", + "state": "active", + "recipients": [{ + "class": "user", + "display_name": "Bob", + "value": "bob" + }] + }] + } + })json", + this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + + UnifiedShareListModel model; + model.setSharingController(&controller); + QSignalSpy modelResetSpy{&model, &QAbstractItemModel::modelReset}; + + controller.initialize("42"_L1); + + QTRY_COMPARE(model.rowCount(), 2); + QCOMPARE(model.data(model.index(0), UnifiedShareListModel::ShareRole).value()->id(), "internal-share"_L1); + QCOMPARE(model.data(model.index(0), UnifiedShareListModel::SectionRole).toString(), "internal"_L1); + QCOMPARE(model.data(model.index(1), UnifiedShareListModel::ShareRole).value()->id(), "external-share"_L1); + QCOMPARE(model.data(model.index(1), UnifiedShareListModel::SectionRole).toString(), "external"_L1); + QCOMPARE(model.rowCount(model.index(0)), 0); + QVERIFY(!model.data({}, UnifiedShareListModel::ShareRole).isValid()); + + const auto internalShare = model.data(model.index(0), UnifiedShareListModel::ShareRole).value(); + internalShare->updateFromJson(QJsonDocument::fromJson(R"json({ + "ocs": {"data": {"recipients": [{ + "class": "email", + "display_name": "bob@example.com", + "value": "bob@example.com" + }]}} + })json")); + + QTRY_COMPARE(modelResetSpy.size(), 2); + QCOMPARE(model.data(model.index(0), UnifiedShareListModel::SectionRole).toString(), "external"_L1); + QCOMPARE(model.data(model.index(1), UnifiedShareListModel::SectionRole).toString(), "external"_L1); + + model.setSharingController(nullptr); + QCOMPARE(model.rowCount(), 0); + } + void sharingControllerDestroysShareAndRemovesItFromTheList() { FakeFolder fakeFolder{{}, {}, {}, false}; @@ -598,14 +644,13 @@ private slots: ++destroyRequests; } - const auto response = deleting - ? QByteArray{R"json({ + const auto response = deleting ? QByteArray{R"json({ "ocs": { "meta": {"status": "ok", "statuscode": 204, "message": "OK"}, "data": {} } })json"} - : QByteArray{R"json({ + : QByteArray{R"json({ "ocs": { "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, "data": [{"id": "share-1", "state": "active"}] @@ -636,14 +681,13 @@ private slots: FakeFolder fakeFolder{{}, {}, {}, false}; fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { const auto deleting = request.url().path().endsWith("/api/v1/share/share-1"_L1); - const auto response = deleting - ? QByteArray{R"json({ + const auto response = deleting ? QByteArray{R"json({ "ocs": { "meta": {"status": "failure", "statuscode": 403, "message": "Not allowed"}, "data": {} } })json"} - : QByteArray{R"json({ + : QByteArray{R"json({ "ocs": { "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, "data": [{"id": "share-1", "state": "active"}] @@ -731,8 +775,7 @@ private slots: QVERIFY(requestPaths.at(1).endsWith("/api/v1/share/share-1/source"_L1)); QCOMPARE(requestVerbs, (QList{"POST", "POST"})); QCOMPARE(requestBodies.at(0), QJsonObject{}); - QCOMPARE(requestBodies.at(1), - (QJsonObject{{"class"_L1, SourceTypeClasses::node}, {"value"_L1, "42"_L1}})); + QCOMPARE(requestBodies.at(1), (QJsonObject{{"class"_L1, SourceTypeClasses::node}, {"value"_L1, "42"_L1}})); } void sharingControllerReportsAddedRecipientAndUpdatesShare() @@ -749,8 +792,7 @@ private slots: outgoingData->reset(); } - const auto response = addingRecipient - ? QByteArray{R"json({ + const auto response = addingRecipient ? QByteArray{R"json({ "ocs": { "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, "data": { @@ -765,7 +807,7 @@ private slots: } } })json"} - : QByteArray{R"json({ + : QByteArray{R"json({ "ocs": { "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, "data": [{ @@ -801,10 +843,7 @@ private slots: QCOMPARE(addedShare->recipients().constFirst()->instance(), std::optional{"cloud.example.com"_L1}); QCOMPARE(addedShare->properties().size(), 1); QCOMPARE(addedShare->properties().constFirst()->displayName(), "Note to recipients"_L1); - QCOMPARE(recipientRequestBody, - (QJsonObject{{"class"_L1, "user-class"_L1}, - {"value"_L1, "alice"_L1}, - {"instance"_L1, "cloud.example.com"_L1}})); + QCOMPARE(recipientRequestBody, (QJsonObject{{"class"_L1, "user-class"_L1}, {"value"_L1, "alice"_L1}, {"instance"_L1, "cloud.example.com"_L1}})); } void sharingControllerRemovesRecipientAndPreservesItsIdentity() @@ -813,16 +852,14 @@ private slots: auto removalQuery = QList>{}; fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { const auto removingRecipient = request.url().path().endsWith("/recipient"_L1) - && (operation == QNetworkAccessManager::DeleteOperation - || request.attribute(QNetworkRequest::CustomVerbAttribute).toByteArray() == "DELETE"); + && (operation == QNetworkAccessManager::DeleteOperation || request.attribute(QNetworkRequest::CustomVerbAttribute).toByteArray() == "DELETE"); if (removingRecipient) { removalQuery = QUrlQuery{request.url()}.queryItems(); removalQuery.removeAll({"format"_L1, "json"_L1}); std::ranges::sort(removalQuery); } - const auto response = removingRecipient - ? QByteArray{R"json({ + const auto response = removingRecipient ? QByteArray{R"json({ "ocs": { "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, "data": { @@ -832,7 +869,7 @@ private slots: } } })json"} - : QByteArray{R"json({ + : QByteArray{R"json({ "ocs": { "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, "data": [{ @@ -856,17 +893,12 @@ private slots: QTRY_COMPARE(controller.shares().size(), 1); QSignalSpy removedSpy{&controller, &SharingController::recipientRemoved}; - controller.removeRecipient(controller.shares().constFirst(), - "federated-user"_L1, - "alice"_L1, - "cloud.example.com"_L1); + controller.removeRecipient(controller.shares().constFirst(), "federated-user"_L1, "alice"_L1, "cloud.example.com"_L1); QVERIFY(removedSpy.wait()); QCOMPARE(controller.shares().constFirst()->recipients().size(), 0); QCOMPARE(removalQuery, - (QList>{{"class"_L1, "federated-user"_L1}, - {"instance"_L1, "cloud.example.com"_L1}, - {"value"_L1, "alice"_L1}})); + (QList>{{"class"_L1, "federated-user"_L1}, {"instance"_L1, "cloud.example.com"_L1}, {"value"_L1, "alice"_L1}})); } void sharingControllerGeneratesAndAppliesRecipientSecret() @@ -917,18 +949,19 @@ private slots: }; } - const auto response = QJsonDocument{QJsonObject{ - {"ocs"_L1, - QJsonObject{ - {"meta"_L1, - QJsonObject{ - {"status"_L1, "ok"_L1}, - {"statuscode"_L1, 200}, - {"message"_L1, "OK"_L1}, - }}, - {"data"_L1, data}, - }}, - }}.toJson(QJsonDocument::Compact); + const auto response = QJsonDocument{ + QJsonObject{ + {"ocs"_L1, + QJsonObject{ + {"meta"_L1, + QJsonObject{ + {"status"_L1, "ok"_L1}, + {"statuscode"_L1, 200}, + {"message"_L1, "OK"_L1}, + }}, + {"data"_L1, data}, + }}, + }}.toJson(QJsonDocument::Compact); return new FakePayloadReply{operation, request, response, this}; }); @@ -938,10 +971,7 @@ private slots: QTRY_COMPARE(controller.shares().size(), 1); QSignalSpy updatedSpy{&controller, &SharingController::recipientSecretUpdated}; - controller.updateRecipientSecret(controller.shares().constFirst(), - "federated-user"_L1, - "alice"_L1, - "cloud.example.com"_L1); + controller.updateRecipientSecret(controller.shares().constFirst(), "federated-user"_L1, "alice"_L1, "cloud.example.com"_L1); QVERIFY(updatedSpy.wait()); QCOMPARE(secretRequestBody, @@ -958,14 +988,13 @@ private slots: FakeFolder fakeFolder{{}, {}, {}, false}; fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { const auto loadingShares = request.url().path().endsWith("/shares"_L1); - const auto response = loadingShares - ? QByteArray{R"json({ + const auto response = loadingShares ? QByteArray{R"json({ "ocs": { "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, "data": [{"id": "share-1", "state": "active"}] } })json"} - : QByteArray{R"json({ + : QByteArray{R"json({ "ocs": { "meta": {"status": "failure", "statuscode": 400, "message": "Recipient rejected"}, "data": {} @@ -1004,8 +1033,7 @@ private slots: outgoingData->reset(); } - const auto response = settingProperty - ? QByteArray{R"json({ + const auto response = settingProperty ? QByteArray{R"json({ "ocs": { "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, "data": { @@ -1020,7 +1048,7 @@ private slots: } } })json"} - : QByteArray{R"json({ + : QByteArray{R"json({ "ocs": { "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, "data": [{ @@ -1049,22 +1077,20 @@ private slots: QTRY_COMPARE(propertyUpdatedSpy.size(), 1); QCOMPARE(controller.shares().constFirst()->properties().constFirst()->value().toString(), "Updated note"_L1); - QCOMPARE(propertyRequestBody, - (QJsonObject{{"class"_L1, "note-property"_L1}, {"value"_L1, "Updated note"_L1}})); + QCOMPARE(propertyRequestBody, (QJsonObject{{"class"_L1, "note-property"_L1}, {"value"_L1, "Updated note"_L1}})); } void sharingControllerReportsPermissionUpdateFailures() { FakeFolder fakeFolder{{}, {}, {}, false}; fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { - const auto response = request.url().path().endsWith("/shares"_L1) - ? QByteArray{R"json({ + const auto response = request.url().path().endsWith("/shares"_L1) ? QByteArray{R"json({ "ocs": { "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, "data": [{"id": "share-1", "state": "active"}] } })json"} - : QByteArray{R"json({ + : QByteArray{R"json({ "ocs": { "meta": {"status": "failure", "statuscode": 400, "message": "Permission rejected"}, "data": {} @@ -1099,14 +1125,14 @@ private slots: ++requestCount; if (requestCount == 1) { return static_cast(new FakePayloadReply{operation, - request, - R"json({ + request, + R"json({ "ocs": { "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, "data": [{"id": "share-1", "state": "active"}] } })json", - this}); + this}); } return static_cast(new FakeErrorReply{operation, request, this, 500}); }); @@ -1144,14 +1170,13 @@ private slots: } } - const auto response = settingState - ? QByteArray{R"json({ + const auto response = settingState ? QByteArray{R"json({ "ocs": { "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, "data": {"id": "share-1", "state": "active"} } })json"} - : QByteArray{R"json({ + : QByteArray{R"json({ "ocs": { "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, "data": [{"id": "share-1", "state": "draft"}] @@ -1187,14 +1212,13 @@ private slots: FakeFolder fakeFolder{{}, {}, {}, false}; fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { const auto settingState = request.url().path().endsWith("/state"_L1); - const auto response = settingState - ? QByteArray{R"json({ + const auto response = settingState ? QByteArray{R"json({ "ocs": { "meta": {"status": "failure", "statuscode": 400, "message": "Share rejected"}, "data": {} } })json"} - : QByteArray{R"json({ + : QByteArray{R"json({ "ocs": { "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, "data": [{"id": "share-1", "state": "draft"}] diff --git a/theme/Style/Style.qml b/theme/Style/Style.qml index e183460da72f3..438aaec853ad8 100644 --- a/theme/Style/Style.qml +++ b/theme/Style/Style.qml @@ -149,6 +149,7 @@ QtObject { readonly property int sharingDialogMinimumHeight: 420 readonly property int sharingDialogWindowMargin: 2 * standardSpacing readonly property int sharingDialogPaneHeaderHeight: iconButtonWidth + readonly property int sharingDialogShareListMaximumHeight: 3 * sharingDialogPaneHeaderHeight readonly property int sharingDialogSidebarMinimumWidth: sharingDialogWidth / 4 readonly property int sharingDialogSidebarPreferredWidth: sharingDialogWidth / 3 readonly property int sharingDialogSidebarMaximumWidth: sharingDialogWidth / 2 From d5139cb94140a751bc060abb5a76e4abf0b4b09e Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 13 Aug 2026 23:03:54 +0800 Subject: [PATCH 092/104] feat(sharing): Add wizard item delegate Signed-off-by: Claudio Cambra --- resources.qrc | 1 + src/gui/wizard/qml/WizardItemDelegate.qml | 31 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 src/gui/wizard/qml/WizardItemDelegate.qml diff --git a/resources.qrc b/resources.qrc index cb4cc6218d7ff..857e2ddb25405 100644 --- a/resources.qrc +++ b/resources.qrc @@ -72,6 +72,7 @@ src/gui/wizard/qml/WizardButton.qml src/gui/wizard/qml/WizardComboBox.qml src/gui/wizard/qml/WizardDialogFrame.qml + src/gui/wizard/qml/WizardItemDelegate.qml src/gui/wizard/qml/WizardTextField.qml src/gui/macOS/ui/FileProviderFileDelegate.qml src/gui/integration/FileActionsWindow.qml diff --git a/src/gui/wizard/qml/WizardItemDelegate.qml b/src/gui/wizard/qml/WizardItemDelegate.qml new file mode 100644 index 0000000000000..11ebe2c2254d0 --- /dev/null +++ b/src/gui/wizard/qml/WizardItemDelegate.qml @@ -0,0 +1,31 @@ +/* + * 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.ItemDelegate { + id: root + + padding: Style.wizardSectionSpacing + hoverEnabled: true + + background: Rectangle { + radius: Style.mediumRoundedButtonRadius + border.width: Style.normalBorderWidth + border.color: !root.enabled ? Style.wizardRowDisabledBorder : root.highlighted ? Style.wizardSelectedBorder : Style.wizardRowBorder + color: { + if (!root.enabled) { + return Style.wizardRowDisabledBackground + } + if (root.highlighted || root.down) { + return Style.wizardSelectedBackground + } + return root.hovered ? Style.wizardSecondaryButtonBackground : Style.wizardRowBackground + } + } +} From 3c598d26163febb2ceb731e6c0656bef6fd4d76c Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Thu, 13 Aug 2026 23:04:14 +0800 Subject: [PATCH 093/104] feat(sharing): Use wizard styling in unified sharing components Signed-off-by: Claudio Cambra --- src/gui/sharing/FieldDelegate.qml | 72 +++----- src/gui/sharing/RecipientSearchField.qml | 105 ++++++------ src/gui/sharing/ShareDetailsPage.qml | 99 +++++------ src/gui/sharing/ShareDialog.qml | 203 +++++++++++------------ src/gui/sharing/ShareRow.qml | 22 +-- 5 files changed, 224 insertions(+), 277 deletions(-) diff --git a/src/gui/sharing/FieldDelegate.qml b/src/gui/sharing/FieldDelegate.qml index 9993641ce80f6..3db7c76c662eb 100644 --- a/src/gui/sharing/FieldDelegate.qml +++ b/src/gui/sharing/FieldDelegate.qml @@ -12,6 +12,7 @@ import QtQuick.Controls import com.nextcloud.desktopclient import Style +import "qrc:/qml/src/gui/wizard/qml" Loader { id: instantiator @@ -31,18 +32,18 @@ Loader { } sourceComponent: switch (model.type) { - case PropertyModel.Boolean: - return booleanComponent - case PropertyModel.Date: - return dateComponent - case PropertyModel.Enum: - return enumComponent - case PropertyModel.Password: - return passwordComponent - case PropertyModel.String: - return stringComponent - default: - return unknownComponent + case PropertyModel.Boolean: + return booleanComponent + case PropertyModel.Date: + return dateComponent + case PropertyModel.Enum: + return enumComponent + case PropertyModel.Password: + return passwordComponent + case PropertyModel.String: + return stringComponent + default: + return unknownComponent } Component { @@ -65,7 +66,7 @@ Loader { Label { text: instantiator.labelText() } - TextField { + WizardTextField { id: dateField Layout.fillWidth: true @@ -73,16 +74,9 @@ Loader { placeholderText: instantiator.model.placeholder || qsTr("ISO 8601 date") inputMethodHints: Qt.ImhDate - property bool withinMinimum: !instantiator.model.minimum - || !text - || Date.parse(text) > Date.parse(instantiator.model.minimum) - property bool withinMaximum: !instantiator.model.maximum - || !text - || Date.parse(text) < Date.parse(instantiator.model.maximum) - property bool valid: (!instantiator.model.required || text.length > 0) - && (!text || !isNaN(Date.parse(text))) - && withinMinimum - && withinMaximum + property bool withinMinimum: !instantiator.model.minimum || !text || Date.parse(text) > Date.parse(instantiator.model.minimum) + property bool withinMaximum: !instantiator.model.maximum || !text || Date.parse(text) < Date.parse(instantiator.model.maximum) + property bool valid: (!instantiator.model.required || text.length > 0) && (!text || !isNaN(Date.parse(text))) && withinMinimum && withinMaximum onEditingFinished: { if (valid) { @@ -107,29 +101,16 @@ Loader { Label { text: instantiator.labelText() } - ComboBox { + WizardComboBox { id: enumSelector Layout.fillWidth: true - model: instantiator.model.validValues + model: instantiator.model.validValues.map(value => ({ + "name": value, + "isSelected": value === instantiator.model.value + })) + textRole: "name" currentIndex: instantiator.model.validValues.indexOf(instantiator.model.value) - delegate: ItemDelegate { - id: enumDelegate - - required property int index - required property string modelData - - width: enumSelector.width - text: modelData - highlighted: enumSelector.highlightedIndex === index - - contentItem: Label { - text: enumDelegate.text - color: enumDelegate.highlighted ? palette.highlightedText : palette.text - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - } onActivated: index => { instantiator.submit(instantiator.model.validValues[index]) @@ -145,7 +126,7 @@ Loader { Label { text: instantiator.labelText() } - TextField { + WizardTextField { Layout.fillWidth: true text: instantiator.model.value ?? "" placeholderText: instantiator.model.placeholder @@ -167,7 +148,7 @@ Loader { Label { text: instantiator.labelText() } - TextField { + WizardTextField { id: stringField Layout.fillWidth: true @@ -175,8 +156,7 @@ Loader { placeholderText: instantiator.model.placeholder maximumLength: instantiator.model.maximum ?? 32767 - property bool valid: (!instantiator.model.required || text.length > 0) - && (!instantiator.model.minimum || text.length >= instantiator.model.minimum) + property bool valid: (!instantiator.model.required || text.length > 0) && (!instantiator.model.minimum || text.length >= instantiator.model.minimum) onEditingFinished: { if (valid) { diff --git a/src/gui/sharing/RecipientSearchField.qml b/src/gui/sharing/RecipientSearchField.qml index 646e54b66f25a..449d1912a9e4a 100644 --- a/src/gui/sharing/RecipientSearchField.qml +++ b/src/gui/sharing/RecipientSearchField.qml @@ -11,11 +11,12 @@ import QtQuick.Controls import com.nextcloud.desktopclient import com.nextcloud.desktopclient as NC import Style +import "qrc:/qml/src/gui/wizard/qml" // Based on the old `ShareeSearchField` component from filedetails. // While Qt 6.10+ has a `SearchField` type, it's still lacking some features // such as a placeholder text. -TextField { +WizardTextField { id: root signal recipientSelected(string recipientType, string recipientValue, string recipientInstance) @@ -32,48 +33,43 @@ TextField { readonly property double iconsScaleFactor: 0.6 function triggerSuggestionsVisibility() { - recipientListView.count > 0 ? suggestionsPopup.open() : suggestionsPopup.close(); + recipientListView.count > 0 ? suggestionsPopup.open() : suggestionsPopup.close() } placeholderText: enabled ? qsTr("Search for recipients") : qsTr("Sharing is not available for this folder") verticalAlignment: Qt.AlignVCenter - implicitHeight: Math.max(Style.talkReplyTextFieldPreferredHeight, contentHeight) - onActiveFocusChanged: triggerSuggestionsVisibility() onTextChanged: triggerSuggestionsVisibility() Keys.onPressed: { - if(suggestionsPopup.visible) { - switch(event.key) { + if (suggestionsPopup.visible) { + switch (event.key) { case Qt.Key_Escape: - suggestionsPopup.close(); - recipientListView.currentIndex = -1; - event.accepted = true; - break; - + suggestionsPopup.close() + recipientListView.currentIndex = -1 + event.accepted = true + break case Qt.Key_Up: - recipientListView.decrementCurrentIndex(); - event.accepted = true; - break; - + recipientListView.decrementCurrentIndex() + event.accepted = true + break case Qt.Key_Down: - recipientListView.incrementCurrentIndex(); - event.accepted = true; - break; - + recipientListView.incrementCurrentIndex() + event.accepted = true + break case Qt.Key_Enter: case Qt.Key_Return: - if(recipientListView.currentIndex > -1) { - recipientListView.itemAtIndex(recipientListView.currentIndex).selectItem(); - event.accepted = true; - break; + if (recipientListView.currentIndex > -1) { + recipientListView.itemAtIndex(recipientListView.currentIndex).selectItem() + event.accepted = true + break } } } else { - switch(event.key) { + switch (event.key) { case Qt.Key_Down: - triggerSuggestionsVisibility(); - event.accepted = true; - break; + triggerSuggestionsVisibility() + event.accepted = true + break } } } @@ -103,7 +99,7 @@ TextField { visible: !root.recipientModel.fetchOngoing } -/* + /* NCBusyIndicator { id: busyIndicator @@ -168,14 +164,9 @@ TextField { ListView { id: recipientListView - spacing: 0 + spacing: Style.extraSmallSpacing currentIndex: -1 interactive: true - - highlight: Rectangle { - anchors.fill: recipientListView.currentItem - color: palette.highlight - } highlightFollowsCurrentItem: true highlightMoveDuration: 0 highlightResizeDuration: 0 @@ -186,7 +177,7 @@ TextField { onCountChanged: root.triggerSuggestionsVisibility() model: root.recipientModel - delegate: ItemDelegate { + delegate: WizardItemDelegate { id: recipientDelegate required property int index @@ -198,23 +189,27 @@ TextField { required property string iconLight required property string iconDark - width: recipientListView.contentItem.width - - text: displayName + width: ListView.view.width + highlighted: ListView.isCurrentItem contentItem: RowLayout { + spacing: Style.standardSpacing + Image { - source: RecipientIcon.source(recipientDelegate.iconSvgUrl, - recipientDelegate.iconLight, - recipientDelegate.iconDark) + Layout.preferredWidth: Style.activityListButtonIconSize + Layout.preferredHeight: Style.activityListButtonIconSize + source: RecipientIcon.source(recipientDelegate.iconSvgUrl, recipientDelegate.iconLight, recipientDelegate.iconDark) + sourceSize: Qt.size(Style.activityListButtonIconSize, Style.activityListButtonIconSize) + fillMode: Image.PreserveAspectFit } Label { text: recipientDelegate.displayName + color: Style.wizardPrimaryText } Label { Layout.fillWidth: true text: recipientDelegate.instance || "" - color: palette.placeholderText + color: Style.wizardSecondaryText elide: Text.ElideRight } } @@ -223,12 +218,10 @@ TextField { // hoverEnabled: model.type !== NC.recipient.LookupServerSearchResults function selectSharee() { - root.recipientSelected(recipientDelegate.type, - recipientDelegate.value, - recipientDelegate.instance || ""); - suggestionsPopup.close(); + root.recipientSelected(recipientDelegate.type, recipientDelegate.value, recipientDelegate.instance || "") + suggestionsPopup.close() - root.clear(); + root.clear() } function selectItem() { @@ -236,24 +229,24 @@ TextField { // recipientListView.currentIndex = -1 // root.recipientModel.searchGlobally() // } else { - selectSharee() - // } + selectSharee() + // } } onHoveredChanged: if (hovered) { // When we set the currentIndex the list view will scroll... // unless we tamper with the preferred highlight points to stop this. - const savedPreferredHighlightBegin = recipientListView.preferredHighlightBegin; - const savedPreferredHighlightEnd = recipientListView.preferredHighlightEnd; + const savedPreferredHighlightBegin = recipientListView.preferredHighlightBegin + const savedPreferredHighlightEnd = recipientListView.preferredHighlightEnd // Set overkill values to make sure no scroll happens when we hover with mouse - recipientListView.preferredHighlightBegin = -suggestionsScrollView.height; - recipientListView.preferredHighlightEnd = suggestionsScrollView.height * 2; + recipientListView.preferredHighlightBegin = -suggestionsScrollView.height + recipientListView.preferredHighlightEnd = suggestionsScrollView.height * 2 - recipientListView.currentIndex = index + recipientListView.currentIndex = index; // Reset original values so keyboard navigation makes list view scroll - recipientListView.preferredHighlightBegin = savedPreferredHighlightBegin; - recipientListView.preferredHighlightEnd = savedPreferredHighlightEnd; + recipientListView.preferredHighlightBegin = savedPreferredHighlightBegin + recipientListView.preferredHighlightEnd = savedPreferredHighlightEnd } onClicked: selectItem() } diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index e176b32963847..46a514a3d6188 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -11,6 +11,8 @@ import QtQuick.Controls import com.nextcloud.desktopclient import Style +import "qrc:/qml/src/gui" +import "qrc:/qml/src/gui/wizard/qml" ColumnLayout { id: root @@ -48,12 +50,10 @@ ColumnLayout { } } - Label { + ErrorBox { Layout.fillWidth: true text: root.recipientOperationError - color: Style.wizardErrorText - wrapMode: Text.Wrap visible: text.length > 0 } @@ -66,7 +66,7 @@ ColumnLayout { share: root.share } - delegate: ItemDelegate { + delegate: WizardItemDelegate { id: recipientDelegate required property var model @@ -95,6 +95,7 @@ ColumnLayout { Label { Layout.fillWidth: true text: recipientDelegate.model.label + color: Style.wizardPrimaryText elide: Text.ElideRight } @@ -110,20 +111,18 @@ ColumnLayout { } return details.join(" · ") } - color: palette.placeholderText + color: Style.wizardSecondaryText elide: Text.ElideRight visible: text.length > 0 } } - Button { - Layout.preferredWidth: Style.activityListButtonWidth - Layout.preferredHeight: Style.activityListButtonHeight - - icon.source: "image://svgimage-custom-color/copy.svg/" + palette.buttonText - icon.width: Style.activityListButtonIconSize - icon.height: Style.activityListButtonIconSize - display: AbstractButton.IconOnly + WizardButton { + Layout.preferredWidth: implicitHeight + leftPadding: 0 + rightPadding: 0 + text: "" + iconSource: "image://svgimage-custom-color/copy.svg/" + palette.buttonText visible: recipientDelegate.model.secretUrl !== "" enabled: visible @@ -134,14 +133,12 @@ ColumnLayout { onClicked: root.copyToClipboard(recipientDelegate.model.secretUrl) } - Button { - Layout.preferredWidth: Style.activityListButtonWidth - Layout.preferredHeight: Style.activityListButtonHeight - - icon.source: "image://svgimage-custom-color/change.svg/" + palette.buttonText - icon.width: Style.activityListButtonIconSize - icon.height: Style.activityListButtonIconSize - display: AbstractButton.IconOnly + WizardButton { + Layout.preferredWidth: implicitHeight + leftPadding: 0 + rightPadding: 0 + text: "" + iconSource: "image://svgimage-custom-color/change.svg/" + palette.buttonText visible: recipientDelegate.model.secretUpdatable enabled: visible @@ -155,14 +152,12 @@ ColumnLayout { } } - Button { - Layout.preferredWidth: Style.activityListButtonWidth - Layout.preferredHeight: Style.activityListButtonHeight - - icon.source: "image://svgimage-custom-color/delete.svg/" + palette.buttonText - icon.width: Style.activityListButtonIconSize - icon.height: Style.activityListButtonIconSize - display: AbstractButton.IconOnly + WizardButton { + Layout.preferredWidth: implicitHeight + leftPadding: 0 + rightPadding: 0 + text: "" + iconSource: "image://svgimage-custom-color/delete.svg/" + palette.buttonText Accessible.name: qsTr("Remove recipient") ToolTip.visible: hovered @@ -177,30 +172,12 @@ ColumnLayout { } } - ComboBox { + WizardComboBox { id: permissionPresetSelector Layout.fillWidth: true readonly property var presetValues: ["OC\\Core\\Sharing\\Permission\\ViewSharePermissionPreset", "OC\\Core\\Sharing\\Permission\\EditSharePermissionPreset", ""] - model: [qsTr("Can view"), qsTr("Can edit"), qsTr("Custom permissions")] - delegate: ItemDelegate { - id: permissionPresetDelegate - - required property int index - required property string modelData - - width: permissionPresetSelector.width - text: modelData - highlighted: permissionPresetSelector.highlightedIndex === index - - contentItem: Label { - text: permissionPresetDelegate.text - color: permissionPresetDelegate.highlighted ? palette.highlightedText : palette.text - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - } - currentIndex: { + readonly property int selectedPresetIndex: { const preset = root.share.permissionPreset if (preset.endsWith("\\ViewSharePermissionPreset")) { return 0 @@ -210,6 +187,22 @@ ColumnLayout { } return 2 } + model: [ + { + "name": qsTr("Can view"), + "isSelected": selectedPresetIndex === 0 + }, + { + "name": qsTr("Can edit"), + "isSelected": selectedPresetIndex === 1 + }, + { + "name": qsTr("Custom permissions"), + "isSelected": selectedPresetIndex === 2 + } + ] + textRole: "name" + currentIndex: selectedPresetIndex onActivated: function (index) { const preset = presetValues[index] @@ -244,12 +237,10 @@ ColumnLayout { } } - Label { + ErrorBox { Layout.fillWidth: true text: root.permissionUpdateError - color: Style.wizardErrorText - wrapMode: Text.Wrap visible: text.length > 0 } @@ -318,12 +309,10 @@ ColumnLayout { } } - Label { + ErrorBox { Layout.fillWidth: true text: root.propertyUpdateError - color: Style.wizardErrorText - wrapMode: Text.Wrap visible: text.length > 0 } diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 9c200c23f7106..32be755d29f00 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -14,6 +14,7 @@ import com.nextcloud.desktopclient import Style import "qrc:/qml/src/gui" import "qrc:/qml/src/gui/tray" +import "qrc:/qml/src/gui/wizard/qml" WizardStyledWindow { id: dialog @@ -213,11 +214,12 @@ WizardStyledWindow { width: shareListView.width spacing: Style.standardSpacing - ItemDelegate { - Layout.fillWidth: true + WizardButton { + Layout.alignment: Qt.AlignLeft text: sharingController.creatingShare ? qsTr("Creating share…") : qsTr("Create new share") enabled: !sharingController.creatingShare && dialog.fileId.length > 0 - icon.source: "image://svgimage-custom-color/add.svg/" + palette.buttonText + iconSource: "image://svgimage-custom-color/add.svg/" + palette.buttonText + iconBeforeText: true onClicked: { dialog.selectShareAfterCreation = true @@ -225,19 +227,15 @@ WizardStyledWindow { } } - Label { + ErrorBox { Layout.fillWidth: true text: sharingController.shareCreationError - color: Style.wizardErrorText - wrapMode: Text.Wrap visible: text.length > 0 } - Label { + ErrorBox { Layout.fillWidth: true text: sharingController.shareDestructionError - color: Style.wizardErrorText - wrapMode: Text.Wrap visible: text.length > 0 } @@ -277,118 +275,104 @@ WizardStyledWindow { } } - ScrollView { - id: shareDetailsScrollView - - contentWidth: availableWidth - clip: true + WizardDialogFrame { + id: shareDetailsFrame ColumnLayout { - width: shareDetailsScrollView.availableWidth + anchors.fill: parent + spacing: Style.wizardSectionSpacing - Loader { - id: shareDetailsLoader + ScrollView { + id: shareDetailsScrollView Layout.fillWidth: true - Layout.leftMargin: Style.sharingDialogWindowMargin - Layout.rightMargin: Style.sharingDialogWindowMargin - Layout.bottomMargin: Style.standardSpacing - Layout.preferredHeight: active && item ? item.implicitHeight : 0 - active: dialog.selectedShare !== null - visible: active - - sourceComponent: ShareDetailsPage { - sharingController: sharingController - share: dialog.selectedShare + Layout.fillHeight: true + contentWidth: availableWidth + clip: true + + ColumnLayout { + width: shareDetailsScrollView.availableWidth + + Loader { + id: shareDetailsLoader + + Layout.fillWidth: true + Layout.leftMargin: shareDetailsFrame.windowMargin + Layout.rightMargin: shareDetailsFrame.windowMargin + Layout.preferredHeight: active && item ? item.implicitHeight : 0 + active: dialog.selectedShare !== null + visible: active + + sourceComponent: ShareDetailsPage { + sharingController: sharingController + share: dialog.selectedShare + } + } } - } - } - - ScrollBar.horizontal: ScrollBar { - policy: ScrollBar.AlwaysOff - } - - ScrollBar.vertical: ScrollBar { - parent: shareDetailsScrollView - anchors.top: shareDetailsScrollView.top - anchors.right: shareDetailsScrollView.right - anchors.bottom: shareDetailsScrollView.bottom - policy: ScrollBar.AsNeeded - } - } - } - - ColumnLayout { - Layout.fillWidth: true - spacing: Style.smallSpacing - visible: dialog.selectedShare !== null - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: Style.normalBorderWidth - color: Style.sharingDialogSeparatorColor - } - Label { - Layout.fillWidth: true - Layout.leftMargin: Style.sharingDialogWindowMargin - Layout.rightMargin: Style.sharingDialogWindowMargin - - text: dialog.shareActivationError - color: Style.wizardErrorText - wrapMode: Text.Wrap - visible: text.length > 0 - } - - Label { - Layout.fillWidth: true - Layout.leftMargin: Style.sharingDialogWindowMargin - Layout.rightMargin: Style.sharingDialogWindowMargin - - text: qsTr("Changes to this share are applied immediately.") - color: palette.placeholderText - wrapMode: Text.Wrap - visible: dialog.selectedShare && dialog.selectedShare.state === Share.Active - } + ScrollBar.horizontal: ScrollBar { + policy: ScrollBar.AlwaysOff + } - RowLayout { - Layout.fillWidth: true - Layout.leftMargin: Style.sharingDialogWindowMargin - Layout.rightMargin: Style.sharingDialogWindowMargin - Layout.bottomMargin: Style.standardSpacing - - Button { - text: qsTr("Delete share") - enabled: !sharingController.destroyingShare - flat: true - icon.source: "image://svgimage-custom-color/delete.svg/" + palette.buttonText - onClicked: { - dialog.sharePendingDeletion = dialog.selectedShare - deleteShareConfirmation.open() + ScrollBar.vertical: ScrollBar { + parent: shareDetailsScrollView + anchors.top: shareDetailsScrollView.top + anchors.right: shareDetailsScrollView.right + anchors.bottom: shareDetailsScrollView.bottom + policy: ScrollBar.AsNeeded + } } - } - Item { - Layout.fillWidth: true - } + ErrorBox { + Layout.fillWidth: true + Layout.leftMargin: shareDetailsFrame.windowMargin + Layout.rightMargin: shareDetailsFrame.windowMargin + text: dialog.shareActivationError + visible: text.length > 0 + } - Button { - text: qsTr("Close") - onClicked: dialog.selectedShare = null + Label { + Layout.fillWidth: true + Layout.leftMargin: shareDetailsFrame.windowMargin + Layout.rightMargin: shareDetailsFrame.windowMargin + text: qsTr("Changes to this share are applied immediately.") + color: Style.wizardSecondaryText + wrapMode: Text.Wrap + visible: dialog.selectedShare && dialog.selectedShare.state === Share.Active + } } - Button { - text: dialog.activatingShare ? qsTr("Sending…") : qsTr("Send share") - enabled: !dialog.activatingShare - highlighted: true - visible: dialog.selectedShare && dialog.selectedShare.state === Share.Draft - - onClicked: { - dialog.shareActivationError = "" - dialog.activatingShare = true - sharingController.activateShare(dialog.selectedShare) + footer: [ + WizardButton { + text: qsTr("Delete share") + enabled: !sharingController.destroyingShare + iconSource: "image://svgimage-custom-color/delete.svg/" + palette.buttonText + iconBeforeText: true + onClicked: { + dialog.sharePendingDeletion = dialog.selectedShare + deleteShareConfirmation.open() + } + }, + Item { + Layout.fillWidth: true + }, + WizardButton { + text: qsTr("Close") + onClicked: dialog.selectedShare = null + }, + WizardButton { + primary: true + text: dialog.activatingShare ? qsTr("Sending…") : qsTr("Send share") + enabled: !dialog.activatingShare + visible: dialog.selectedShare && dialog.selectedShare.state === Share.Draft + + onClicked: { + dialog.shareActivationError = "" + dialog.activatingShare = true + sharingController.activateShare(dialog.selectedShare) + } } - } + ] } } } @@ -407,13 +391,14 @@ WizardStyledWindow { } footer: DialogButtonBox { - Button { + WizardButton { + primary: true text: qsTr("Delete") DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole onClicked: deleteShareConfirmation.accept() } - Button { + WizardButton { text: qsTr("Cancel") DialogButtonBox.buttonRole: DialogButtonBox.RejectRole onClicked: deleteShareConfirmation.reject() diff --git a/src/gui/sharing/ShareRow.qml b/src/gui/sharing/ShareRow.qml index 9641de880d081..f44f960d03122 100644 --- a/src/gui/sharing/ShareRow.qml +++ b/src/gui/sharing/ShareRow.qml @@ -9,18 +9,17 @@ import QtQuick.Controls import com.nextcloud.desktopclient import Style +import "qrc:/qml/src/gui/wizard/qml" -ItemDelegate { +WizardItemDelegate { id: root required property Share share signal configureRequested implicitHeight: contentItem.implicitHeight + topPadding + bottomPadding - hoverEnabled: true - contentItem: RowLayout { - spacing: Style.smallSpacing + spacing: Style.standardSpacing Image { Layout.preferredWidth: Style.activityListButtonIconSize @@ -47,24 +46,25 @@ ItemDelegate { } return names.length > 0 ? names.join(", ") : qsTr("New share") } - color: palette.text + color: Style.wizardPrimaryText elide: Text.ElideRight } Label { Layout.fillWidth: true text: root.share && root.share.recipients && root.share.recipients.length > 0 ? qsTr("%1 recipient(s)").arg(root.share.recipients.length) : qsTr("Not shared yet") - color: palette.placeholderText + color: Style.wizardSecondaryText elide: Text.ElideRight visible: text.length > 0 } } - ToolButton { - Layout.preferredWidth: Style.activityListButtonWidth - Layout.preferredHeight: Style.activityListButtonHeight - display: AbstractButton.IconOnly - icon.source: "image://svgimage-custom-color/more.svg/" + palette.buttonText + WizardButton { + Layout.preferredWidth: implicitHeight + leftPadding: 0 + rightPadding: 0 + text: "" + iconSource: "image://svgimage-custom-color/more.svg/" + palette.buttonText Accessible.name: qsTr("Configure share") ToolTip.visible: hovered ToolTip.text: Accessible.name From b52c88da72c737e5b7fc99393f8bbe3a241c694b Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Fri, 14 Aug 2026 10:53:34 +0800 Subject: [PATCH 094/104] feat(sharing): Hide titlebar for activities window Signed-off-by: Claudio Cambra --- src/gui/systray.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index a0618bef58b77..235daece31bc2 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -899,6 +899,10 @@ void Systray::createFileDetailsDialog(const QString &localPath, const QString &f _fileDetailDialogs.append(dialog); +#if defined(Q_OS_MACOS) + configureMacOSExpandedQuickWindow(dialog); +#endif + dialog->show(); dialog->raise(); dialog->requestActivate(); From 364d93e0787e1de2ab88336031a917d9e3d19b3c Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Fri, 14 Aug 2026 14:45:23 +0800 Subject: [PATCH 095/104] feat(sharing): Fix recipient icon type Signed-off-by: Claudio Cambra --- src/gui/sharing/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index d29f7853f7d12..3f0152d7dad35 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -3,6 +3,8 @@ add_library(nextcloudGuiSharing STATIC) +set_source_files_properties(RecipientIcon.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE) + target_sources(nextcloudGuiSharing PRIVATE abstractsharemodel.h From eb24b00039bfc78b060774f471eaf00f68fb59c9 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Fri, 14 Aug 2026 14:46:43 +0800 Subject: [PATCH 096/104] feat(sharing): Fix loading indication on recipients search field Signed-off-by: Claudio Cambra --- src/gui/sharing/RecipientSearchField.qml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/gui/sharing/RecipientSearchField.qml b/src/gui/sharing/RecipientSearchField.qml index 449d1912a9e4a..d895a33961e30 100644 --- a/src/gui/sharing/RecipientSearchField.qml +++ b/src/gui/sharing/RecipientSearchField.qml @@ -99,8 +99,7 @@ WizardTextField { visible: !root.recipientModel.fetchOngoing } - /* - NCBusyIndicator { + Image { id: busyIndicator anchors { @@ -110,11 +109,21 @@ WizardTextField { } width: height - color: palette.placeholderText + source: "image://svgimage-custom-color/change.svg/" + palette.placeholderText + sourceSize: Qt.size(parent.height * root.iconsScaleFactor, parent.height * root.iconsScaleFactor) + fillMode: Image.PreserveAspectFit visible: root.recipientModel.fetchOngoing - running: visible + + RotationAnimator { + target: busyIndicator + running: busyIndicator.visible + from: 0 + to: 360 + loops: Animation.Infinite + duration: Style.shortAnimationDuration * 15 + } } - */ + Image { id: clearTextButton From de6abb7b074631ea3da0defdefe5216c71f22061 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Fri, 14 Aug 2026 14:57:18 +0800 Subject: [PATCH 097/104] feat(sharing): Match server share creation flow Signed-off-by: Claudio Cambra --- src/gui/sharing/FieldDelegate.qml | 44 +++- src/gui/sharing/ShareDetailsPage.qml | 31 ++- src/gui/sharing/ShareDialog.qml | 89 +++++--- src/gui/sharing/recipientsearchmodel.cpp | 30 +++ src/gui/sharing/recipientsearchmodel.h | 7 + src/gui/sharing/sharingcontroller.cpp | 143 ++++++++++++- src/gui/sharing/sharingcontroller.h | 43 +++- src/gui/sharing/unifiedsharelistmodel.cpp | 10 +- test/testunifiedsharing.cpp | 236 +++++++++++++++++++++- 9 files changed, 566 insertions(+), 67 deletions(-) diff --git a/src/gui/sharing/FieldDelegate.qml b/src/gui/sharing/FieldDelegate.qml index 3db7c76c662eb..110018293226a 100644 --- a/src/gui/sharing/FieldDelegate.qml +++ b/src/gui/sharing/FieldDelegate.qml @@ -31,6 +31,12 @@ Loader { valueEdited(model.property, value) } + function commit(): void { + if (item && item.commit) { + item.commit() + } + } + sourceComponent: switch (model.type) { case PropertyModel.Boolean: return booleanComponent @@ -63,6 +69,14 @@ Loader { id: dateComponent ColumnLayout { + id: dateColumn + + function commit(): void { + if (dateField.valid) { + instantiator.submit(dateField.text) + } + } + Label { text: instantiator.labelText() } @@ -79,9 +93,7 @@ Loader { property bool valid: (!instantiator.model.required || text.length > 0) && (!text || !isNaN(Date.parse(text))) && withinMinimum && withinMaximum onEditingFinished: { - if (valid) { - instantiator.submit(text) - } + dateColumn.commit() } } Label { @@ -123,19 +135,27 @@ Loader { id: passwordComponent ColumnLayout { + id: passwordColumn + + function commit(): void { + if (!instantiator.model.required || passwordField.text.length > 0) { + instantiator.submit(passwordField.text) + } + } + Label { text: instantiator.labelText() } WizardTextField { + id: passwordField + Layout.fillWidth: true text: instantiator.model.value ?? "" placeholderText: instantiator.model.placeholder echoMode: TextInput.Password onEditingFinished: { - if (!instantiator.model.required || text.length > 0) { - instantiator.submit(text) - } + passwordColumn.commit() } } } @@ -145,6 +165,14 @@ Loader { id: stringComponent ColumnLayout { + id: stringColumn + + function commit(): void { + if (stringField.valid) { + instantiator.submit(stringField.text) + } + } + Label { text: instantiator.labelText() } @@ -159,9 +187,7 @@ Loader { property bool valid: (!instantiator.model.required || text.length > 0) && (!instantiator.model.minimum || text.length >= instantiator.model.minimum) onEditingFinished: { - if (valid) { - instantiator.submit(text) - } + stringColumn.commit() } } Label { diff --git a/src/gui/sharing/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml index 46a514a3d6188..c532e142eec08 100644 --- a/src/gui/sharing/ShareDetailsPage.qml +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -22,6 +22,9 @@ ColumnLayout { property string recipientOperationError: "" property string permissionUpdateError: "" property string propertyUpdateError: "" + readonly property bool shareIsActive: share.state === Share.Active + + signal commitRequested spacing: Style.standardSpacing @@ -32,6 +35,10 @@ ColumnLayout { clipboardHelper.clear() } + function commitPendingChanges(): void { + commitRequested() + } + TextEdit { id: clipboardHelper visible: false @@ -43,6 +50,7 @@ ColumnLayout { account: root.sharingController.account shareId: root.share.id + visible: root.shareIsActive onRecipientSelected: (recipientType, recipientValue, recipientInstance) => { root.recipientOperationError = "" @@ -123,7 +131,7 @@ ColumnLayout { rightPadding: 0 text: "" iconSource: "image://svgimage-custom-color/copy.svg/" + palette.buttonText - visible: recipientDelegate.model.secretUrl !== "" + visible: root.shareIsActive && recipientDelegate.model.secretUrl !== "" enabled: visible Accessible.name: qsTr("Copy recipient link") @@ -139,7 +147,7 @@ ColumnLayout { rightPadding: 0 text: "" iconSource: "image://svgimage-custom-color/change.svg/" + palette.buttonText - visible: recipientDelegate.model.secretUpdatable + visible: root.shareIsActive && recipientDelegate.model.secretUpdatable enabled: visible Accessible.name: recipientDelegate.model.secretUrl !== "" ? qsTr("Regenerate recipient link") : qsTr("Generate recipient link") @@ -158,6 +166,7 @@ ColumnLayout { rightPadding: 0 text: "" iconSource: "image://svgimage-custom-color/delete.svg/" + palette.buttonText + visible: root.shareIsActive Accessible.name: qsTr("Remove recipient") ToolTip.visible: hovered @@ -266,6 +275,8 @@ ColumnLayout { } delegate: FieldDelegate { + id: propertyDelegate + width: propertyList.width height: item ? item.implicitHeight : 0 @@ -273,6 +284,13 @@ ColumnLayout { root.propertyUpdateError = "" root.sharingController.setProperty(root.share, propertyClass, value) } + + Connections { + target: root + function onCommitRequested() { + propertyDelegate.commit() + } + } } } @@ -299,6 +317,8 @@ ColumnLayout { } delegate: FieldDelegate { + id: advancedPropertyDelegate + width: advancedPropertyList.width height: item ? item.implicitHeight : 0 @@ -306,6 +326,13 @@ ColumnLayout { root.propertyUpdateError = "" root.sharingController.setProperty(root.share, propertyClass, value) } + + Connections { + target: root + function onCommitRequested() { + advancedPropertyDelegate.commit() + } + } } } diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 32be755d29f00..cda5dbd6c1f96 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -26,10 +26,11 @@ WizardStyledWindow { property string fileId: "" property Share selectedShare: null property Share sharePendingDeletion: null - property bool selectShareAfterCreation: false property bool activatingShare: false property string shareActivationError: "" + signal clearNewShareRecipientSearch + property FileDetails fileDetails: FileDetails { localPath: dialog.localPath } @@ -95,24 +96,18 @@ WizardStyledWindow { target: sharingController function onSharesChanged() { - if (dialog.selectShareAfterCreation) { - const shares = dialog.currentShares() - dialog.selectedShare = shares.length > 0 ? shares[shares.length - 1] : null - dialog.selectShareAfterCreation = false - } else { - dialog.reconcileSelectedShare() - } + dialog.reconcileSelectedShare() } - function onShareCreationErrorChanged() { - if (sharingController.shareCreationError.length > 0) { - dialog.selectShareAfterCreation = false - } + function onShareCreated(share) { + dialog.clearNewShareRecipientSearch() + dialog.selectedShare = share } function onShareActivated(share) { if (share === dialog.selectedShare) { dialog.activatingShare = false + dialog.selectedShare = null } } @@ -175,22 +170,19 @@ WizardStyledWindow { Layout.fillWidth: true Layout.preferredHeight: Style.normalBorderWidth color: Style.sharingDialogSeparatorColor - } + } - RowLayout { + EnforcedPlainTextLabel { Layout.fillWidth: true Layout.leftMargin: Style.sharingDialogWindowMargin Layout.rightMargin: Style.sharingDialogWindowMargin Layout.topMargin: Style.standardSpacing Layout.preferredHeight: Style.sharingDialogPaneHeaderHeight - EnforcedPlainTextLabel { - Layout.fillWidth: true - - text: dialog.selectedShare ? dialog.shareTitle(dialog.selectedShare) : qsTr("Sharing") - font.pointSize: Style.subheaderFontPtSize - font.weight: Font.DemiBold - } + text: dialog.shareTitle(dialog.selectedShare) + font.pointSize: Style.subheaderFontPtSize + font.weight: Font.DemiBold + visible: dialog.selectedShare } StackLayout { @@ -214,19 +206,34 @@ WizardStyledWindow { width: shareListView.width spacing: Style.standardSpacing - WizardButton { - Layout.alignment: Qt.AlignLeft - text: sharingController.creatingShare ? qsTr("Creating share…") : qsTr("Create new share") + RecipientSearchField { + id: newShareRecipientSearch + + Layout.fillWidth: true enabled: !sharingController.creatingShare && dialog.fileId.length > 0 - iconSource: "image://svgimage-custom-color/add.svg/" + palette.buttonText - iconBeforeText: true + account: dialog.account + shareId: "" + + onRecipientSelected: (recipientType, recipientValue, recipientInstance) => { + sharingController.createShareForRecipient(dialog.fileId, recipientType, recipientValue, recipientInstance) + } - onClicked: { - dialog.selectShareAfterCreation = true - sharingController.createShare(dialog.fileId) + Connections { + target: dialog + + function onClearNewShareRecipientSearch() { + newShareRecipientSearch.clear() + } } } + Label { + Layout.fillWidth: true + text: qsTr("Creating share…") + color: Style.wizardSecondaryText + visible: sharingController.creatingShare + } + ErrorBox { Layout.fillWidth: true text: sharingController.shareCreationError @@ -331,6 +338,14 @@ WizardStyledWindow { visible: text.length > 0 } + ErrorBox { + Layout.fillWidth: true + Layout.leftMargin: shareDetailsFrame.windowMargin + Layout.rightMargin: shareDetailsFrame.windowMargin + text: sharingController.shareDestructionError + visible: text.length > 0 + } + Label { Layout.fillWidth: true Layout.leftMargin: shareDetailsFrame.windowMargin @@ -346,6 +361,7 @@ WizardStyledWindow { WizardButton { text: qsTr("Delete share") enabled: !sharingController.destroyingShare + visible: dialog.selectedShare && dialog.selectedShare.state === Share.Active iconSource: "image://svgimage-custom-color/delete.svg/" + palette.buttonText iconBeforeText: true onClicked: { @@ -358,16 +374,27 @@ WizardStyledWindow { }, WizardButton { text: qsTr("Close") + visible: dialog.selectedShare && dialog.selectedShare.state === Share.Active onClicked: dialog.selectedShare = null }, + WizardButton { + text: sharingController.destroyingShare ? qsTr("Cancelling…") : qsTr("Cancel") + enabled: !sharingController.destroyingShare && !dialog.activatingShare + visible: dialog.selectedShare && dialog.selectedShare.state === Share.Draft + + onClicked: sharingController.destroyShare(dialog.selectedShare) + }, WizardButton { primary: true - text: dialog.activatingShare ? qsTr("Sending…") : qsTr("Send share") - enabled: !dialog.activatingShare + text: dialog.activatingShare ? qsTr("Saving…") : qsTr("Save") + enabled: !dialog.activatingShare && !sharingController.destroyingShare && dialog.selectedShare && dialog.selectedShare.recipients.length > 0 visible: dialog.selectedShare && dialog.selectedShare.state === Share.Draft onClicked: { dialog.shareActivationError = "" + if (shareDetailsLoader.item) { + shareDetailsLoader.item.commitPendingChanges() + } dialog.activatingShare = true sharingController.activateShare(dialog.selectedShare) } diff --git a/src/gui/sharing/recipientsearchmodel.cpp b/src/gui/sharing/recipientsearchmodel.cpp index 10f5f46f9ec95..3050302808e21 100644 --- a/src/gui/sharing/recipientsearchmodel.cpp +++ b/src/gui/sharing/recipientsearchmodel.cpp @@ -92,6 +92,7 @@ void RecipientSearchModel::setAccount(AccountPtr account) beginResetModel(); _searchTimer.stop(); + setFetchOngoing(false); _account = account; _searchResults = {}; Q_EMIT accountChanged(); @@ -119,6 +120,7 @@ void RecipientSearchModel::setQuery(const QString &query) if (_query.isEmpty()) { _searchTimer.stop(); + setFetchOngoing(false); beginResetModel(); _searchResults = {}; endResetModel(); @@ -140,6 +142,7 @@ void RecipientSearchModel::setShareId(const QString &shareId) } _shareId = shareId; + setFetchOngoing(false); beginResetModel(); _searchResults = {}; endResetModel(); @@ -149,11 +152,17 @@ void RecipientSearchModel::setShareId(const QString &shareId) } } +bool RecipientSearchModel::fetchOngoing() const +{ + return _fetchOngoing; +} + void RecipientSearchModel::search() { const auto query = _query; const auto account = _account; const auto shareId = _shareId; + setFetchOngoing(true); const auto job = new SearchRecipientsJob{account, query, 0, @@ -168,6 +177,27 @@ void RecipientSearchModel::search() beginResetModel(); _searchResults = recipients; endResetModel(); + setFetchOngoing(false); + }); + connect(job, &SearchRecipientsJob::ocsError, this, [this, account, query, shareId] { + if (_account == account && _query == query && _shareId == shareId) { + setFetchOngoing(false); + } + }); + connect(job, &SearchRecipientsJob::networkError, this, [this, account, query, shareId] { + if (_account == account && _query == query && _shareId == shareId) { + setFetchOngoing(false); + } }); job->start(); } + +void RecipientSearchModel::setFetchOngoing(bool fetchOngoing) +{ + if (_fetchOngoing == fetchOngoing) { + return; + } + + _fetchOngoing = fetchOngoing; + Q_EMIT fetchOngoingChanged(); +} diff --git a/src/gui/sharing/recipientsearchmodel.h b/src/gui/sharing/recipientsearchmodel.h index a4fd4c2a8d0d6..48686556ec0b5 100644 --- a/src/gui/sharing/recipientsearchmodel.h +++ b/src/gui/sharing/recipientsearchmodel.h @@ -24,6 +24,7 @@ class RecipientSearchModel : public QAbstractListModel Q_PROPERTY(AccountPtr account READ account WRITE setAccount NOTIFY accountChanged) Q_PROPERTY(QString query READ query WRITE setQuery NOTIFY queryChanged) Q_PROPERTY(QString shareId READ shareId WRITE setShareId NOTIFY shareIdChanged) + Q_PROPERTY(bool fetchOngoing READ fetchOngoing NOTIFY fetchOngoingChanged) public: enum Roles { @@ -51,10 +52,14 @@ class RecipientSearchModel : public QAbstractListModel [[nodiscard]] QString shareId() const; void setShareId(const QString &shareId); + /** @brief Returns whether the current query is waiting for server results. */ + [[nodiscard]] bool fetchOngoing() const; + Q_SIGNALS: void accountChanged(); void queryChanged(); void shareIdChanged(); + void fetchOngoingChanged(); private: AccountPtr _account = nullptr; @@ -62,8 +67,10 @@ class RecipientSearchModel : public QAbstractListModel QString _query; QString _shareId; QTimer _searchTimer; + bool _fetchOngoing = false; void search(); + void setFetchOngoing(bool fetchOngoing); }; } diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index 391af60b441e5..9aa54c2f842b7 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -9,8 +9,8 @@ #include #include -#include #include +#include #include "addrecipientjob.h" #include "addsourcejob.h" @@ -118,7 +118,23 @@ void SharingController::initialize(const QString &fileId) job->start(); } -void SharingController::createShare(const QString &fileId) +void SharingController::createShareForRecipient(const QString &fileId, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance) +{ + if (recipientType.isEmpty() || recipientValue.isEmpty()) { + qCWarning(lcSharingController) << "attempted to create a share without a recipient"; + return; + } + + startShareCreation(fileId, recipientType, recipientValue, recipientInstance); +} + +void SharingController::startShareCreation(const QString &fileId, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance) { if (!_account) { qCWarning(lcSharingController) << "attempted to create a new share without an account set"; @@ -139,7 +155,7 @@ void SharingController::createShare(const QString &fileId) setCreatingShare(true); const auto job = new CreateShareJob{_account}; - connect(job, &CreateShareJob::shareCreated, this, [this, fileId](QPointer share) -> void { + connect(job, &CreateShareJob::shareCreated, this, [this, fileId, recipientType, recipientValue, recipientInstance](QPointer share) -> void { if (!share || share->id().isEmpty()) { qCWarning(lcSharingController) << "share created without a valid Share object"; failShareCreation(tr("The server returned an invalid share."), share); @@ -147,7 +163,7 @@ void SharingController::createShare(const QString &fileId) } share->setParent(this); - addSourceAfterCreation(share, fileId); + addSourceAfterCreation(share, fileId, recipientType, recipientValue, recipientInstance); }); connect(job, &CreateShareJob::ocsError, this, [this](int, const QString &message) { failShareCreation(message.isEmpty() ? tr("Could not create the share.") : message); @@ -187,6 +203,9 @@ void SharingController::destroyShare(Share *share) const auto share = guardedShare.data(); _shares.removeAll(share); + _pendingDraftUpdates.remove(share); + _activationRequested.remove(share); + _activationBlocked.remove(share); setDestroyingShare(false); share->deleteLater(); Q_EMIT sharesChanged(); @@ -326,11 +345,13 @@ void SharingController::setPermission(Share *share, const QString &permissionCla const auto guardedShare = QPointer{share}; const auto permissionFailureReported = std::make_shared(false); const auto job = new SetPermissionJob{_account, *share, permissionClass, enabled}; + trackDraftUpdate(share, job); connect(job, &SetPermissionJob::ocsError, this, [this, guardedShare, permissionFailureReported](int, const QString &message) { if (*permissionFailureReported) { return; } *permissionFailureReported = true; + markDraftUpdateFailed(guardedShare); Q_EMIT permissionUpdateFailed(guardedShare, message.isEmpty() ? tr("Could not update the permissions.") : message); }); connect(job, &SetPermissionJob::networkError, this, [this, guardedShare, permissionFailureReported](const QNetworkReply *reply) { @@ -338,6 +359,7 @@ void SharingController::setPermission(Share *share, const QString &permissionCla return; } *permissionFailureReported = true; + markDraftUpdateFailed(guardedShare); Q_EMIT permissionUpdateFailed(guardedShare, reply ? reply->errorString() : tr("Could not update the permissions.")); }); job->start(); @@ -363,11 +385,13 @@ void SharingController::setPermissionPreset(Share *share, const QString &permiss const auto guardedShare = QPointer{share}; const auto permissionFailureReported = std::make_shared(false); const auto job = new SetPermissionPresetJob{_account, *share, permissionPreset}; + trackDraftUpdate(share, job); connect(job, &SetPermissionPresetJob::ocsError, this, [this, guardedShare, permissionFailureReported](int, const QString &message) { if (*permissionFailureReported) { return; } *permissionFailureReported = true; + markDraftUpdateFailed(guardedShare); Q_EMIT permissionUpdateFailed(guardedShare, message.isEmpty() ? tr("Could not update the permissions.") : message); }); connect(job, &SetPermissionPresetJob::networkError, this, [this, guardedShare, permissionFailureReported](const QNetworkReply *reply) { @@ -375,6 +399,7 @@ void SharingController::setPermissionPreset(Share *share, const QString &permiss return; } *permissionFailureReported = true; + markDraftUpdateFailed(guardedShare); Q_EMIT permissionUpdateFailed(guardedShare, reply ? reply->errorString() : tr("Could not update the permissions.")); }); job->start(); @@ -400,15 +425,18 @@ void SharingController::setProperty(Share *share, const QString &propertyClass, const auto guardedShare = QPointer{share}; const auto propertyValue = value.isEmpty() ? std::nullopt : std::optional{value}; const auto job = new SetPropertyJob{_account, *share, propertyClass, propertyValue}; + trackDraftUpdate(share, job); connect(job, &SetPropertyJob::shareUpdated, this, [this](QPointer updatedShare) { if (updatedShare) { Q_EMIT propertyUpdated(updatedShare); } }); connect(job, &SetPropertyJob::ocsError, this, [this, guardedShare](int, const QString &message) { + markDraftUpdateFailed(guardedShare); Q_EMIT propertyUpdateFailed(guardedShare, message.isEmpty() ? tr("Could not update the sharing setting.") : message); }); connect(job, &SetPropertyJob::networkError, this, [this, guardedShare](const QNetworkReply *reply) { + markDraftUpdateFailed(guardedShare); Q_EMIT propertyUpdateFailed(guardedShare, reply ? reply->errorString() : tr("Could not update the sharing setting.")); }); job->start(); @@ -431,11 +459,30 @@ void SharingController::activateShare(Share *share) return; } + if (_activationRequested.contains(share)) { + return; + } + + if (_pendingDraftUpdates.value(share) > 0) { + _activationRequested.insert(share); + return; + } + + startShareActivation(share); +} + +void SharingController::startShareActivation(Share *share) +{ + if (!containsShare(share) || share->state() != Share::ShareState::Draft) { + return; + } + const auto guardedShare = QPointer{share}; const auto job = new SetShareStateJob{_account, *share, Share::ShareState::Active}; connect(job, &SetShareStateJob::shareUpdated, this, [this, guardedShare](QPointer updatedShare) { if (updatedShare && updatedShare->state() == Share::ShareState::Active) { Q_EMIT shareActivated(updatedShare); + Q_EMIT sharesChanged(); return; } @@ -455,7 +502,11 @@ bool SharingController::containsShare(const Share *share) const return share && _shares.contains(share); } -void SharingController::addSourceAfterCreation(QPointer share, const QString &fileId) +void SharingController::addSourceAfterCreation(QPointer share, + const QString &fileId, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance) { if (!share) { failShareCreation(tr("The newly created share is no longer available.")); @@ -463,12 +514,18 @@ void SharingController::addSourceAfterCreation(QPointer share, const QStr } const auto job = new AddSourceJob{_account, *share, fileId}; - connect(job, &AddSourceJob::shareUpdated, this, [this](QPointer updatedShare) { + connect(job, &AddSourceJob::shareUpdated, this, [this, recipientType, recipientValue, recipientInstance](QPointer updatedShare) { if (!updatedShare) { failShareCreation(tr("The newly created share is no longer available.")); return; } - finishShareCreation(updatedShare); + + if (recipientType.isEmpty()) { + finishShareCreation(updatedShare); + return; + } + + addRecipientAfterCreation(updatedShare, recipientType, recipientValue, recipientInstance); }); connect(job, &AddSourceJob::ocsError, this, [this, share](int, const QString &message) { failShareCreation(message.isEmpty() ? tr("Could not attach the item to the share.") : message, share); @@ -479,10 +536,38 @@ void SharingController::addSourceAfterCreation(QPointer share, const QStr job->start(); } +void SharingController::addRecipientAfterCreation(QPointer share, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance) +{ + if (!share) { + failShareCreation(tr("The newly created share is no longer available.")); + return; + } + + const auto job = new AddRecipientJob{_account, *share, recipientType, recipientValue, optionalString(recipientInstance)}; + connect(job, &AddRecipientJob::shareUpdated, this, [this](QPointer updatedShare) { + if (!updatedShare) { + failShareCreation(tr("The newly created share is no longer available.")); + return; + } + finishShareCreation(updatedShare); + }); + connect(job, &AddRecipientJob::ocsError, this, [this, share](int, const QString &message) { + failShareCreation(message.isEmpty() ? tr("Could not add the recipient.") : message, share); + }); + connect(job, &AddRecipientJob::networkError, this, [this, share](const QNetworkReply *reply) { + failShareCreation(reply ? reply->errorString() : tr("Could not add the recipient."), share); + }); + job->start(); +} + void SharingController::finishShareCreation(QPointer share) { _shares.append(share); setCreatingShare(false); + Q_EMIT shareCreated(share); Q_EMIT sharesChanged(); } @@ -506,6 +591,50 @@ void SharingController::failShareCreation(const QString &error, QPointer delete share.data(); } +void SharingController::trackDraftUpdate(Share *share, QObject *job) +{ + if (!share || !job || share->state() != Share::ShareState::Draft) { + return; + } + + ++_pendingDraftUpdates[share]; + connect(job, &QObject::destroyed, this, [this, guardedShare = QPointer{share}] { + if (!guardedShare) { + return; + } + + const auto share = guardedShare.data(); + auto pendingUpdate = _pendingDraftUpdates.find(share); + if (pendingUpdate == _pendingDraftUpdates.end()) { + return; + } + + --pendingUpdate.value(); + if (pendingUpdate.value() > 0) { + return; + } + + _pendingDraftUpdates.erase(pendingUpdate); + if (!_activationRequested.remove(share)) { + return; + } + + if (_activationBlocked.remove(share)) { + Q_EMIT shareActivationFailed(share, tr("Could not save all changes to the share.")); + return; + } + + startShareActivation(share); + }); +} + +void SharingController::markDraftUpdateFailed(Share *share) +{ + if (share && _activationRequested.contains(share)) { + _activationBlocked.insert(share); + } +} + void SharingController::setCreatingShare(bool creatingShare) { if (_creatingShare == creatingShare) { diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index 82c50e021e1f0..820d5dbcf28c8 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -5,9 +5,11 @@ #pragma once +#include #include #include #include +#include #include #include "accountfwd.h" @@ -39,7 +41,7 @@ class SharingController : public QObject /** @brief Returns all shares associated with the initialized file. */ [[nodiscard]] const QList &shares() const; - /** @brief Returns whether a share and its source are currently being created. */ + /** @brief Returns whether a share, its source, and its initial recipient are currently being created. */ [[nodiscard]] bool creatingShare() const; /** @brief Returns the last share creation error, or an empty string after a new attempt starts. */ @@ -59,14 +61,20 @@ class SharingController : public QObject Q_INVOKABLE void initialize(const QString &fileId); /** - * @brief Creates a draft share and attaches the specified file as its source. + * @brief Creates a draft share for one recipient and attaches the specified file. * - * The share is added to shares only after both requests succeed. A second - * call while creation is in progress is ignored. + * The draft is exposed only after the share, source, and recipient requests + * all succeed. * * @param fileId Server file ID to attach to the new share - */ - Q_INVOKABLE void createShare(const QString &fileId); + * @param recipientType Server-defined recipient class + * @param recipientValue Server-defined recipient identifier + * @param recipientInstance Remote server identifying a federated recipient, or an empty string for a local recipient + */ + Q_INVOKABLE void createShareForRecipient(const QString &fileId, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance = {}); /** @brief Permanently removes a share managed by this controller. */ Q_INVOKABLE void destroyShare(Share *share); @@ -118,6 +126,9 @@ class SharingController : public QObject /** @brief Emitted when shareCreationError changes. */ void shareCreationErrorChanged(); + /** @brief Emitted after a draft share and its requested source and recipient have been created. */ + void shareCreated(Share *share); + /** @brief Emitted when destroyingShare changes. */ void destroyingShareChanged(); @@ -164,11 +175,29 @@ class SharingController : public QObject QString _shareCreationError; bool _destroyingShare = false; QString _shareDestructionError; + QHash _pendingDraftUpdates; + QSet _activationRequested; + QSet _activationBlocked; [[nodiscard]] bool containsShare(const Share *share) const; - void addSourceAfterCreation(QPointer share, const QString &fileId); + void startShareCreation(const QString &fileId, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance); + void addSourceAfterCreation(QPointer share, + const QString &fileId, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance); + void addRecipientAfterCreation(QPointer share, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance); void finishShareCreation(QPointer share); void failShareCreation(const QString &error, QPointer share = {}); + void trackDraftUpdate(Share *share, QObject *job); + void markDraftUpdateFailed(Share *share); + void startShareActivation(Share *share); void setCreatingShare(bool creatingShare); void setShareCreationError(const QString &error); void setDestroyingShare(bool destroyingShare); diff --git a/src/gui/sharing/unifiedsharelistmodel.cpp b/src/gui/sharing/unifiedsharelistmodel.cpp index e875bd6a60fac..4a100afac4b60 100644 --- a/src/gui/sharing/unifiedsharelistmodel.cpp +++ b/src/gui/sharing/unifiedsharelistmodel.cpp @@ -94,15 +94,17 @@ void UnifiedShareListModel::rebuild() _shareConnections.clear(); _shares = _sharingController ? _sharingController->shares() : QList{}; + _shares.removeIf([](const Share *share) { + return !share || share->state() != Share::ShareState::Active; + }); std::stable_sort(_shares.begin(), _shares.end(), [](const Share *left, const Share *right) { return isExternalShare(left) < isExternalShare(right); }); - _shareConnections.reserve(_shares.size()); + _shareConnections.reserve(_shares.size() * 2); for (const auto share : std::as_const(_shares)) { - if (share) { - _shareConnections.append(connect(share, &Share::recipientsChanged, this, &UnifiedShareListModel::rebuild)); - } + _shareConnections.append(connect(share, &Share::recipientsChanged, this, &UnifiedShareListModel::rebuild)); + _shareConnections.append(connect(share, &Share::stateChanged, this, &UnifiedShareListModel::rebuild)); } endResetModel(); diff --git a/test/testunifiedsharing.cpp b/test/testunifiedsharing.cpp index ade06a3180310..520944d4900c3 100644 --- a/test/testunifiedsharing.cpp +++ b/test/testunifiedsharing.cpp @@ -594,6 +594,14 @@ private slots: "display_name": "Bob", "value": "bob" }] + }, { + "id": "unfinished-share", + "state": "draft", + "recipients": [{ + "class": "user", + "display_name": "Carol", + "value": "carol" + }] }] } })json", @@ -711,7 +719,7 @@ private slots: QVERIFY(sharesChangedSpy.isEmpty()); } - void sharingControllerCreatesShareAfterAttachingSource() + void sharingControllerCreatesDraftForSelectedRecipient() { FakeFolder fakeFolder{{}, {}, {}, false}; auto requestPaths = QStringList{}; @@ -737,6 +745,7 @@ private slots: requestBodies.append(body); const auto creating = path.endsWith("/api/v1/share"_L1); + const auto addingRecipient = path.endsWith("/recipient"_L1); const auto response = QString{R"json({ "ocs": { "meta": { @@ -746,11 +755,15 @@ private slots: }, "data": { "id": "share-1", - "state": "draft" + "state": "draft", + "recipients": %2 } } })json"} .arg(creating ? 201 : 200) + .arg(addingRecipient + ? R"json([{"class":"user","display_name":"Alice","value":"alice","instance":"cloud.example.com"}])json"_L1 + : "[]"_L1) .toUtf8(); return new FakePayloadReply{operation, request, response, this}; }); @@ -759,23 +772,89 @@ private slots: controller.setAccount(fakeFolder.account()); QSignalSpy sharesChangedSpy{&controller, &SharingController::sharesChanged}; QSignalSpy creatingShareChangedSpy{&controller, &SharingController::creatingShareChanged}; + QSignalSpy shareCreatedSpy{&controller, &SharingController::shareCreated}; - controller.createShare("42"_L1); + controller.createShareForRecipient("42"_L1, "user"_L1, "alice"_L1, "cloud.example.com"_L1); QVERIFY(controller.creatingShare()); - controller.createShare("42"_L1); + controller.createShareForRecipient("42"_L1, "user"_L1, "alice"_L1, "cloud.example.com"_L1); QTRY_COMPARE(controller.shares().size(), 1); QCOMPARE(sharesChangedSpy.size(), 1); + QCOMPARE(shareCreatedSpy.size(), 1); + QCOMPARE(shareCreatedSpy.constFirst().constFirst().value(), controller.shares().constFirst()); QCOMPARE(creatingShareChangedSpy.size(), 2); QVERIFY(!controller.creatingShare()); QVERIFY(controller.shareCreationError().isEmpty()); QCOMPARE(controller.shares().constFirst()->id(), "share-1"_L1); - QCOMPARE(requestPaths.size(), 2); + QCOMPARE(controller.shares().constFirst()->recipients().size(), 1); + QCOMPARE(controller.shares().constFirst()->recipients().constFirst()->displayName(), "Alice"_L1); + QCOMPARE(requestPaths.size(), 3); QVERIFY(requestPaths.at(0).endsWith("/api/v1/share"_L1)); QVERIFY(requestPaths.at(1).endsWith("/api/v1/share/share-1/source"_L1)); - QCOMPARE(requestVerbs, (QList{"POST", "POST"})); + QVERIFY(requestPaths.at(2).endsWith("/api/v1/share/share-1/recipient"_L1)); + QCOMPARE(requestVerbs, (QList{"POST", "POST", "POST"})); QCOMPARE(requestBodies.at(0), QJsonObject{}); QCOMPARE(requestBodies.at(1), (QJsonObject{{"class"_L1, SourceTypeClasses::node}, {"value"_L1, "42"_L1}})); + QCOMPARE(requestBodies.at(2), + (QJsonObject{{"class"_L1, "user"_L1}, {"value"_L1, "alice"_L1}, {"instance"_L1, "cloud.example.com"_L1}})); + } + + void sharingControllerCleansUpDraftWhenInitialRecipientFails() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto cleanupRequests = 0; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + const auto path = request.url().path(); + if (path.endsWith("/recipient"_L1)) { + return new FakePayloadReply{operation, + request, + R"json({ + "ocs": { + "meta": {"status": "failure", "statuscode": 400, "message": "Recipient rejected"}, + "data": {} + } + })json", + this}; + } + + if (request.attribute(QNetworkRequest::CustomVerbAttribute).toByteArray() == "DELETE") { + ++cleanupRequests; + return new FakePayloadReply{operation, + request, + R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 204, "message": "OK"}, + "data": {} + } + })json", + this}; + } + + const auto statusCode = path.endsWith("/api/v1/share"_L1) ? 201 : 200; + return new FakePayloadReply{operation, + request, + QString{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": %1, "message": "OK"}, + "data": {"id": "share-1", "state": "draft"} + } + })json"} + .arg(statusCode) + .toUtf8(), + this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + QSignalSpy shareCreatedSpy{&controller, &SharingController::shareCreated}; + + controller.createShareForRecipient("42"_L1, "user"_L1, "alice"_L1); + + QTRY_COMPARE(controller.shareCreationError(), "Recipient rejected"_L1); + QTRY_COMPARE(cleanupRequests, 1); + QVERIFY(controller.shares().isEmpty()); + QVERIFY(shareCreatedSpy.isEmpty()); + QVERIFY(!controller.creatingShare()); } void sharingControllerReportsAddedRecipientAndUpdatesShare() @@ -1207,6 +1286,146 @@ private slots: QCOMPARE(activatedSpy.size(), 1); } + void sharingControllerWaitsForDraftUpdatesBeforeActivation() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto stateRequests = 0; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + const auto path = request.url().path(); + if (path.endsWith("/property"_L1)) { + return static_cast(new FakePayloadReply{operation, + request, + R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": { + "id": "share-1", + "state": "draft", + "properties": [{ + "class": "note-property", + "display_name": "Note to recipients", + "type": "string", + "value": "Saved before activation" + }] + } + } + })json", + 100, + this}); + } + + if (path.endsWith("/state"_L1)) { + ++stateRequests; + return static_cast(new FakePayloadReply{operation, + request, + R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": { + "id": "share-1", + "state": "active", + "properties": [{ + "class": "note-property", + "display_name": "Note to recipients", + "type": "string", + "value": "Saved before activation" + }] + } + } + })json", + this}); + } + + return static_cast(new FakePayloadReply{operation, + request, + R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": [{ + "id": "share-1", + "state": "draft", + "properties": [{ + "class": "note-property", + "display_name": "Note to recipients", + "type": "string" + }] + }] + } + })json", + this}); + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + controller.initialize("42"_L1); + QTRY_COMPARE(controller.shares().size(), 1); + const auto share = controller.shares().constFirst(); + + QSignalSpy activatedSpy{&controller, &SharingController::shareActivated}; + controller.setProperty(share, "note-property"_L1, "Saved before activation"_L1); + controller.activateShare(share); + + QTest::qWait(20); + QCOMPARE(stateRequests, 0); + QCOMPARE(share->state(), Share::ShareState::Draft); + + QTRY_COMPARE(activatedSpy.size(), 1); + QCOMPARE(stateRequests, 1); + QCOMPARE(share->state(), Share::ShareState::Active); + QCOMPARE(share->properties().constFirst()->value().toString(), "Saved before activation"_L1); + } + + void sharingControllerDoesNotActivateAfterPendingDraftUpdateFails() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto stateRequests = 0; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + const auto path = request.url().path(); + if (path.endsWith("/property"_L1)) { + return new FakePayloadReply{operation, + request, + R"json({ + "ocs": { + "meta": {"status": "failure", "statuscode": 400, "message": "Note rejected"}, + "data": {} + } + })json", + 100, + this}; + } + + if (path.endsWith("/state"_L1)) { + ++stateRequests; + } + + return new FakePayloadReply{operation, + request, + R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": [{"id": "share-1", "state": "draft"}] + } + })json", + this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + controller.initialize("42"_L1); + QTRY_COMPARE(controller.shares().size(), 1); + const auto share = controller.shares().constFirst(); + + QSignalSpy propertyFailedSpy{&controller, &SharingController::propertyUpdateFailed}; + QSignalSpy activationFailedSpy{&controller, &SharingController::shareActivationFailed}; + controller.setProperty(share, "note-property"_L1, "Rejected note"_L1); + controller.activateShare(share); + + QTRY_COMPARE(propertyFailedSpy.size(), 1); + QTRY_COMPARE(activationFailedSpy.size(), 1); + QCOMPARE(stateRequests, 0); + QCOMPARE(share->state(), Share::ShareState::Draft); + } + void sharingControllerReportsShareActivationFailure() { FakeFolder fakeFolder{{}, {}, {}, false}; @@ -1287,7 +1506,7 @@ private slots: controller.setAccount(fakeFolder.account()); QSignalSpy sharesChangedSpy{&controller, &SharingController::sharesChanged}; - controller.createShare("42"_L1); + controller.createShareForRecipient("42"_L1, "user"_L1, "alice"_L1); QTRY_COMPARE(requestPaths.size(), 3); QVERIFY(!controller.creatingShare()); @@ -1598,6 +1817,7 @@ private slots: RecipientSearchModel model; model.setAccount(fakeFolder.account()); + QVERIFY(!model.fetchOngoing()); model.setQuery("o"_L1); model.setQuery("ol"_L1); model.setQuery("old"_L1); @@ -1605,10 +1825,12 @@ private slots: QTest::qWait(200); QCOMPARE(requestCount, 0); QTRY_COMPARE_WITH_TIMEOUT(requestCount, 1, 500); + QVERIFY(model.fetchOngoing()); model.setQuery("new"_L1); QTRY_COMPARE_WITH_TIMEOUT(requestCount, 2, 500); QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 1, 500); + QTRY_VERIFY_WITH_TIMEOUT(!model.fetchOngoing(), 500); QCOMPARE(model.data(model.index(0), RecipientSearchModel::DisplayNameRole).toString(), "new"_L1); QTest::qWait(500); From e95bde4f371c95a605d238c9af1a150baf06f74e Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Fri, 14 Aug 2026 15:09:07 +0800 Subject: [PATCH 098/104] feat(sharing): Display pending shares Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareDialog.qml | 7 ++++- src/gui/sharing/ShareRow.qml | 11 +++++-- src/gui/sharing/setsharestatejob.cpp | 2 ++ src/gui/sharing/share.cpp | 6 ++-- src/gui/sharing/share.h | 3 +- src/gui/sharing/sharingcontroller.cpp | 1 - src/gui/sharing/unifiedsharelistmodel.cpp | 28 ++++++++++++++++-- src/gui/sharing/unifiedsharelistmodel.h | 4 ++- test/testunifiedsharing.cpp | 36 ++++++++++++++++++++++- 9 files changed, 86 insertions(+), 12 deletions(-) diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index cda5dbd6c1f96..bfda4aef8f14f 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -263,7 +263,12 @@ WizardStyledWindow { width: shareListView.width topPadding: Style.standardSpacing bottomPadding: Style.extraSmallSpacing - text: section === "external" ? qsTr("External shares") : qsTr("Internal shares") + text: { + if (section === "pending") { + return qsTr("Pending shares") + } + return section === "external" ? qsTr("External shares") : qsTr("Internal shares") + } font.weight: Font.DemiBold } diff --git a/src/gui/sharing/ShareRow.qml b/src/gui/sharing/ShareRow.qml index f44f960d03122..7bf8be7f71e87 100644 --- a/src/gui/sharing/ShareRow.qml +++ b/src/gui/sharing/ShareRow.qml @@ -17,6 +17,8 @@ WizardItemDelegate { required property Share share signal configureRequested + readonly property bool pending: share.state === Share.Draft + implicitHeight: contentItem.implicitHeight + topPadding + bottomPadding contentItem: RowLayout { spacing: Style.standardSpacing @@ -44,7 +46,10 @@ WizardItemDelegate { } } } - return names.length > 0 ? names.join(", ") : qsTr("New share") + if (names.length > 0) { + return names.join(", ") + } + return root.pending ? qsTr("Unfinished share") : qsTr("Share") } color: Style.wizardPrimaryText elide: Text.ElideRight @@ -52,7 +57,9 @@ WizardItemDelegate { Label { Layout.fillWidth: true - text: root.share && root.share.recipients && root.share.recipients.length > 0 ? qsTr("%1 recipient(s)").arg(root.share.recipients.length) : qsTr("Not shared yet") + text: root.pending + ? qsTr("Not active — select to finish") + : qsTr("%n recipient(s)", "", root.share.recipients.length) color: Style.wizardSecondaryText elide: Text.ElideRight visible: text.length > 0 diff --git a/src/gui/sharing/setsharestatejob.cpp b/src/gui/sharing/setsharestatejob.cpp index bb3c98b2c5cf5..953de1967a77f 100644 --- a/src/gui/sharing/setsharestatejob.cpp +++ b/src/gui/sharing/setsharestatejob.cpp @@ -21,6 +21,8 @@ QString stateName(Share::ShareState state) return "deleted"_L1; case Share::ShareState::Draft: return "draft"_L1; + case Share::ShareState::Unknown: + break; } Q_UNREACHABLE_RETURN({}); } diff --git a/src/gui/sharing/share.cpp b/src/gui/sharing/share.cpp index dc47a1cd9f94b..497794e629f68 100644 --- a/src/gui/sharing/share.cpp +++ b/src/gui/sharing/share.cpp @@ -97,9 +97,11 @@ void Share::setId(const QString &id) void Share::setState(const QString &state) { - ShareState newState = ShareState::Draft; + auto newState = ShareState::Unknown; - if (state == "active"_L1) { + if (state == "draft"_L1) { + newState = ShareState::Draft; + } else if (state == "active"_L1) { newState = ShareState::Active; } else if (state == "deleted"_L1) { newState = ShareState::Deleted; diff --git a/src/gui/sharing/share.h b/src/gui/sharing/share.h index 3a7013ddd85e3..68a331f17095f 100644 --- a/src/gui/sharing/share.h +++ b/src/gui/sharing/share.h @@ -34,6 +34,7 @@ class Share : public QObject [[nodiscard]] static QPointer fromJson(const QJsonDocument &json, const AccountPtr &account); enum class ShareState { + Unknown, Draft, Active, Deleted @@ -60,7 +61,7 @@ class Share : public QObject private: AccountPtr _account; QString _id; - ShareState _state = ShareState::Draft; + ShareState _state = ShareState::Unknown; QString _permissionPreset; QList> _permissions; QList> _properties; diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index 9aa54c2f842b7..ddda3d5c05a82 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -482,7 +482,6 @@ void SharingController::startShareActivation(Share *share) connect(job, &SetShareStateJob::shareUpdated, this, [this, guardedShare](QPointer updatedShare) { if (updatedShare && updatedShare->state() == Share::ShareState::Active) { Q_EMIT shareActivated(updatedShare); - Q_EMIT sharesChanged(); return; } diff --git a/src/gui/sharing/unifiedsharelistmodel.cpp b/src/gui/sharing/unifiedsharelistmodel.cpp index 4a100afac4b60..65e2849fab39a 100644 --- a/src/gui/sharing/unifiedsharelistmodel.cpp +++ b/src/gui/sharing/unifiedsharelistmodel.cpp @@ -19,6 +19,7 @@ namespace { constexpr auto internalSection = "internal"_L1; constexpr auto externalSection = "external"_L1; +constexpr auto pendingSection = "pending"_L1; } UnifiedShareListModel::UnifiedShareListModel(QObject *parent) @@ -70,7 +71,7 @@ QVariant UnifiedShareListModel::data(const QModelIndex &index, int role) const case ShareRole: return QVariant::fromValue(share); case SectionRole: - return isExternalShare(share) ? externalSection : internalSection; + return sectionForShare(share); default: return {}; } @@ -95,10 +96,10 @@ void UnifiedShareListModel::rebuild() _shares = _sharingController ? _sharingController->shares() : QList{}; _shares.removeIf([](const Share *share) { - return !share || share->state() != Share::ShareState::Active; + return !share || share->state() == Share::ShareState::Deleted || share->state() == Share::ShareState::Unknown; }); std::stable_sort(_shares.begin(), _shares.end(), [](const Share *left, const Share *right) { - return isExternalShare(left) < isExternalShare(right); + return sectionOrder(left) < sectionOrder(right); }); _shareConnections.reserve(_shares.size() * 2); @@ -110,6 +111,27 @@ void UnifiedShareListModel::rebuild() endResetModel(); } +QString UnifiedShareListModel::sectionForShare(const Share *share) +{ + if (share && share->state() == Share::ShareState::Draft) { + return pendingSection; + } + + return isExternalShare(share) ? externalSection : internalSection; +} + +int UnifiedShareListModel::sectionOrder(const Share *share) +{ + const auto section = sectionForShare(share); + if (section == internalSection) { + return 0; + } + if (section == externalSection) { + return 1; + } + return 2; +} + bool UnifiedShareListModel::isExternalShare(const Share *share) { if (!share) { diff --git a/src/gui/sharing/unifiedsharelistmodel.h b/src/gui/sharing/unifiedsharelistmodel.h index b036742dd97a8..fd27b34d0c774 100644 --- a/src/gui/sharing/unifiedsharelistmodel.h +++ b/src/gui/sharing/unifiedsharelistmodel.h @@ -18,7 +18,7 @@ class Share; class SharingController; /** - * @brief Exposes unified shares in the order needed by the categorized sharing list. + * @brief Exposes active and pending unified shares in categorized display order. */ class UnifiedShareListModel : public QAbstractListModel { @@ -63,6 +63,8 @@ class UnifiedShareListModel : public QAbstractListModel QList _shareConnections; void rebuild(); + [[nodiscard]] static QString sectionForShare(const Share *share); + [[nodiscard]] static int sectionOrder(const Share *share); [[nodiscard]] static bool isExternalShare(const Share *share); }; diff --git a/test/testunifiedsharing.cpp b/test/testunifiedsharing.cpp index 520944d4900c3..63a2b940bdbcb 100644 --- a/test/testunifiedsharing.cpp +++ b/test/testunifiedsharing.cpp @@ -518,6 +518,13 @@ private slots: QCOMPARE(share->recipients().size(), 1); delete share; + + const auto unknownShare = Share::fromJson(QJsonDocument::fromJson(R"json({ + "ocs": {"data": {"id": "unknown-share", "state": "paused"}} + })json"), + fakeFolder.account()); + QCOMPARE(unknownShare->state(), Share::ShareState::Unknown); + delete unknownShare; } void sharingControllerLoadsAllSharesWithoutCreatingOne() @@ -602,6 +609,15 @@ private slots: "display_name": "Carol", "value": "carol" }] + }, { + "id": "empty-draft", + "state": "draft" + }, { + "id": "deleted-share", + "state": "deleted" + }, { + "id": "unknown-share", + "state": "paused" }] } })json", @@ -617,11 +633,15 @@ private slots: controller.initialize("42"_L1); - QTRY_COMPARE(model.rowCount(), 2); + QTRY_COMPARE(model.rowCount(), 4); QCOMPARE(model.data(model.index(0), UnifiedShareListModel::ShareRole).value()->id(), "internal-share"_L1); QCOMPARE(model.data(model.index(0), UnifiedShareListModel::SectionRole).toString(), "internal"_L1); QCOMPARE(model.data(model.index(1), UnifiedShareListModel::ShareRole).value()->id(), "external-share"_L1); QCOMPARE(model.data(model.index(1), UnifiedShareListModel::SectionRole).toString(), "external"_L1); + QCOMPARE(model.data(model.index(2), UnifiedShareListModel::ShareRole).value()->id(), "unfinished-share"_L1); + QCOMPARE(model.data(model.index(2), UnifiedShareListModel::SectionRole).toString(), "pending"_L1); + QCOMPARE(model.data(model.index(3), UnifiedShareListModel::ShareRole).value()->id(), "empty-draft"_L1); + QCOMPARE(model.data(model.index(3), UnifiedShareListModel::SectionRole).toString(), "pending"_L1); QCOMPARE(model.rowCount(model.index(0)), 0); QVERIFY(!model.data({}, UnifiedShareListModel::ShareRole).isValid()); @@ -637,6 +657,20 @@ private slots: QTRY_COMPARE(modelResetSpy.size(), 2); QCOMPARE(model.data(model.index(0), UnifiedShareListModel::SectionRole).toString(), "external"_L1); QCOMPARE(model.data(model.index(1), UnifiedShareListModel::SectionRole).toString(), "external"_L1); + QCOMPARE(model.data(model.index(2), UnifiedShareListModel::SectionRole).toString(), "pending"_L1); + QCOMPARE(model.data(model.index(3), UnifiedShareListModel::SectionRole).toString(), "pending"_L1); + + const auto pendingShare = model.data(model.index(2), UnifiedShareListModel::ShareRole).value(); + pendingShare->updateFromJson(QJsonDocument::fromJson(R"json({ + "ocs": {"data": {"state": "active"}} + })json")); + + QTRY_COMPARE(modelResetSpy.size(), 3); + QCOMPARE(model.data(model.index(0), UnifiedShareListModel::ShareRole).value()->id(), "unfinished-share"_L1); + QCOMPARE(model.data(model.index(0), UnifiedShareListModel::SectionRole).toString(), "internal"_L1); + QCOMPARE(model.data(model.index(1), UnifiedShareListModel::SectionRole).toString(), "external"_L1); + QCOMPARE(model.data(model.index(2), UnifiedShareListModel::SectionRole).toString(), "external"_L1); + QCOMPARE(model.data(model.index(3), UnifiedShareListModel::SectionRole).toString(), "pending"_L1); model.setSharingController(nullptr); QCOMPARE(model.rowCount(), 0); From 1c51165b2eef09477ff9036b011b5b9d10e1131c Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Fri, 14 Aug 2026 15:29:36 +0800 Subject: [PATCH 099/104] feat(sharing): Adjust layout Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareDialog.qml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index bfda4aef8f14f..520f5f5efd6ff 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -195,9 +195,6 @@ WizardStyledWindow { id: shareListView anchors.fill: parent - anchors.leftMargin: Style.sharingDialogWindowMargin - anchors.rightMargin: Style.sharingDialogWindowMargin - anchors.bottomMargin: Style.standardSpacing clip: true spacing: Style.extraSmallSpacing model: shareListModel @@ -229,6 +226,8 @@ WizardStyledWindow { Label { Layout.fillWidth: true + Layout.leftMargin: Style.sharingDialogWindowMargin + Layout.rightMargin: Style.sharingDialogWindowMargin text: qsTr("Creating share…") color: Style.wizardSecondaryText visible: sharingController.creatingShare @@ -236,18 +235,24 @@ WizardStyledWindow { ErrorBox { Layout.fillWidth: true + Layout.leftMargin: Style.sharingDialogWindowMargin + Layout.rightMargin: Style.sharingDialogWindowMargin text: sharingController.shareCreationError visible: text.length > 0 } ErrorBox { Layout.fillWidth: true + Layout.leftMargin: Style.sharingDialogWindowMargin + Layout.rightMargin: Style.sharingDialogWindowMargin text: sharingController.shareDestructionError visible: text.length > 0 } Label { Layout.fillWidth: true + Layout.leftMargin: Style.sharingDialogWindowMargin + Layout.rightMargin: Style.sharingDialogWindowMargin text: qsTr("This item has not been shared yet.") color: palette.placeholderText wrapMode: Text.Wrap @@ -260,7 +265,8 @@ WizardStyledWindow { section.delegate: Label { required property string section - width: shareListView.width + x: Style.sharingDialogWindowMargin + width: shareListView.width - Style.sharingDialogWindowMargin * 2 topPadding: Style.standardSpacing bottomPadding: Style.extraSmallSpacing text: { @@ -273,7 +279,8 @@ WizardStyledWindow { } delegate: ShareRow { - width: ListView.view.width + x: Style.sharingDialogWindowMargin + width: ListView.view.width - Style.sharingDialogWindowMargin * 2 onConfigureRequested: dialog.selectedShare = share } From 30f3ba23a61b54b1914fcb4c8645efde94b80844 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Fri, 14 Aug 2026 15:29:51 +0800 Subject: [PATCH 100/104] feat(sharing): Adjust naming of shares Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareDialog.qml | 7 +++++-- src/gui/sharing/ShareRow.qml | 16 ++-------------- src/gui/sharing/unifiedsharelistmodel.cpp | 15 +++++++++++++++ src/gui/sharing/unifiedsharelistmodel.h | 1 + test/testunifiedsharing.cpp | 5 ++++- 5 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 520f5f5efd6ff..73b635bac759c 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -61,8 +61,11 @@ WizardStyledWindow { const names = [] for (const recipient of Array.from(share.recipients)) { - if (recipient && recipient.displayName) { - names.push(recipient.displayName) + if (recipient) { + const name = recipient.displayName || recipient.value + if (name) { + names.push(name) + } } } return names.length > 0 ? qsTr("Share with %1").arg(names.join(", ")) : qsTr("New share") diff --git a/src/gui/sharing/ShareRow.qml b/src/gui/sharing/ShareRow.qml index 7bf8be7f71e87..ca8a226bafb64 100644 --- a/src/gui/sharing/ShareRow.qml +++ b/src/gui/sharing/ShareRow.qml @@ -15,6 +15,7 @@ WizardItemDelegate { id: root required property Share share + required property string recipientNames signal configureRequested readonly property bool pending: share.state === Share.Draft @@ -37,20 +38,7 @@ WizardItemDelegate { Label { Layout.fillWidth: true - text: { - const names = [] - if (root.share && root.share.recipients) { - for (const recipient of Array.from(root.share.recipients)) { - if (recipient && recipient.displayName) { - names.push(recipient.displayName) - } - } - } - if (names.length > 0) { - return names.join(", ") - } - return root.pending ? qsTr("Unfinished share") : qsTr("Share") - } + text: root.recipientNames || (root.pending ? qsTr("Unfinished share") : qsTr("Share")) color: Style.wizardPrimaryText elide: Text.ElideRight } diff --git a/src/gui/sharing/unifiedsharelistmodel.cpp b/src/gui/sharing/unifiedsharelistmodel.cpp index 65e2849fab39a..d0f86a9d6a5e8 100644 --- a/src/gui/sharing/unifiedsharelistmodel.cpp +++ b/src/gui/sharing/unifiedsharelistmodel.cpp @@ -72,6 +72,20 @@ QVariant UnifiedShareListModel::data(const QModelIndex &index, int role) const return QVariant::fromValue(share); case SectionRole: return sectionForShare(share); + case RecipientNamesRole: { + auto recipientNames = QStringList{}; + for (const auto &recipient : share->recipients()) { + if (!recipient) { + continue; + } + + const auto name = recipient->displayName().isEmpty() ? recipient->value() : recipient->displayName(); + if (!name.isEmpty()) { + recipientNames.append(name); + } + } + return recipientNames.join(", "_L1); + } default: return {}; } @@ -82,6 +96,7 @@ QHash UnifiedShareListModel::roleNames() const return { {ShareRole, "share"}, {SectionRole, "section"}, + {RecipientNamesRole, "recipientNames"}, }; } diff --git a/src/gui/sharing/unifiedsharelistmodel.h b/src/gui/sharing/unifiedsharelistmodel.h index fd27b34d0c774..d05a5699dcb1a 100644 --- a/src/gui/sharing/unifiedsharelistmodel.h +++ b/src/gui/sharing/unifiedsharelistmodel.h @@ -32,6 +32,7 @@ class UnifiedShareListModel : public QAbstractListModel enum Role { ShareRole = Qt::UserRole + 1, SectionRole, + RecipientNamesRole, }; Q_ENUM(Role) diff --git a/test/testunifiedsharing.cpp b/test/testunifiedsharing.cpp index 63a2b940bdbcb..29f00e627f79d 100644 --- a/test/testunifiedsharing.cpp +++ b/test/testunifiedsharing.cpp @@ -606,7 +606,6 @@ private slots: "state": "draft", "recipients": [{ "class": "user", - "display_name": "Carol", "value": "carol" }] }, { @@ -636,12 +635,16 @@ private slots: QTRY_COMPARE(model.rowCount(), 4); QCOMPARE(model.data(model.index(0), UnifiedShareListModel::ShareRole).value()->id(), "internal-share"_L1); QCOMPARE(model.data(model.index(0), UnifiedShareListModel::SectionRole).toString(), "internal"_L1); + QCOMPARE(model.data(model.index(0), UnifiedShareListModel::RecipientNamesRole).toString(), "Bob"_L1); QCOMPARE(model.data(model.index(1), UnifiedShareListModel::ShareRole).value()->id(), "external-share"_L1); QCOMPARE(model.data(model.index(1), UnifiedShareListModel::SectionRole).toString(), "external"_L1); + QCOMPARE(model.data(model.index(1), UnifiedShareListModel::RecipientNamesRole).toString(), "alice@example.com"_L1); QCOMPARE(model.data(model.index(2), UnifiedShareListModel::ShareRole).value()->id(), "unfinished-share"_L1); QCOMPARE(model.data(model.index(2), UnifiedShareListModel::SectionRole).toString(), "pending"_L1); + QCOMPARE(model.data(model.index(2), UnifiedShareListModel::RecipientNamesRole).toString(), "carol"_L1); QCOMPARE(model.data(model.index(3), UnifiedShareListModel::ShareRole).value()->id(), "empty-draft"_L1); QCOMPARE(model.data(model.index(3), UnifiedShareListModel::SectionRole).toString(), "pending"_L1); + QVERIFY(model.data(model.index(3), UnifiedShareListModel::RecipientNamesRole).toString().isEmpty()); QCOMPARE(model.rowCount(model.index(0)), 0); QVERIFY(!model.data({}, UnifiedShareListModel::ShareRole).isValid()); From b8e5504441449b14c38f2ea14bbed4dabb9c57ff Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Fri, 14 Aug 2026 16:10:55 +0800 Subject: [PATCH 101/104] feat(sharing): Add info button to headers Signed-off-by: Claudio Cambra --- src/gui/sharing/CMakeLists.txt | 1 + src/gui/sharing/ShareDialog.qml | 78 +++++++++++++++++++++----- src/gui/sharing/ShareSectionHeader.qml | 40 +++++++++++++ 3 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 src/gui/sharing/ShareSectionHeader.qml diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index 3f0152d7dad35..1a18c4823ae34 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -77,6 +77,7 @@ ecm_add_qml_module(nextcloudGuiSharing RecipientSearchField.qml ShareDetailsPage.qml ShareRow.qml + ShareSectionHeader.qml ShareDialog.qml ) diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 73b635bac759c..5e219ee53c1d9 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -71,6 +71,31 @@ WizardStyledWindow { return names.length > 0 ? qsTr("Share with %1").arg(names.join(", ")) : qsTr("New share") } + function sectionTitle(section: string): string { + if (section === "internal") { + return qsTr("Internal shares") + } + if (section === "external") { + return qsTr("External shares") + } + if (section === "additional") { + return qsTr("Additional shares") + } + return qsTr("Pending shares") + } + + function sectionDescription(section: string): string { + if (section === "internal") { + return qsTr("Share files within your organisation. Recipients who can already view the file can also use this link for easy access.") + } + if (section === "external") { + return qsTr("Share files with others outside your organisation via public links and email addresses. You can also share to Nextcloud accounts on other instances using their federated cloud ID.") + } + if (section === "additional") { + return qsTr("Shares from apps or other sources which are not included in internal or external shares.") + } + return "" + } onSelectedShareChanged: { dialog.activatingShare = false dialog.shareActivationError = "" @@ -263,29 +288,56 @@ WizardStyledWindow { } } - section.property: "section" - section.criteria: ViewSection.FullString - section.delegate: Label { + delegate: Loader { + id: rowLoader + + required property int itemType required property string section + required property Share share + required property string recipientNames + required property bool publicLink + required property string publicLinkUrl + + width: ListView.view.width + height: item ? item.implicitHeight : 0 + sourceComponent: { + if (itemType === UnifiedShareListModel.SectionHeader) { + return sectionHeaderComponent + } + if (itemType === UnifiedShareListModel.CreatePublicLink) { + return createPublicLinkComponent + } + return shareComponent + } + + Component { + id: sectionHeaderComponent + ShareSectionHeader { x: Style.sharingDialogWindowMargin - width: shareListView.width - Style.sharingDialogWindowMargin * 2 - topPadding: Style.standardSpacing - bottomPadding: Style.extraSmallSpacing - text: { - if (section === "pending") { - return qsTr("Pending shares") + width: rowLoader.width - Style.sharingDialogWindowMargin * 2 + title: dialog.sectionTitle(rowLoader.section) + description: dialog.sectionDescription(rowLoader.section) } - return section === "external" ? qsTr("External shares") : qsTr("Internal shares") } - font.weight: Font.DemiBold } - delegate: ShareRow { + Component { + id: shareComponent + + ShareRow { x: Style.sharingDialogWindowMargin - width: ListView.view.width - Style.sharingDialogWindowMargin * 2 + width: rowLoader.width - Style.sharingDialogWindowMargin * 2 + share: rowLoader.share + recipientNames: rowLoader.recipientNames + publicLink: rowLoader.publicLink + publicLinkUrl: rowLoader.publicLinkUrl + + onCopyRequested: dialog.copyToClipboard(publicLinkUrl) onConfigureRequested: dialog.selectedShare = share } + } + } ScrollBar.vertical: ScrollBar { parent: shareListView diff --git a/src/gui/sharing/ShareSectionHeader.qml b/src/gui/sharing/ShareSectionHeader.qml new file mode 100644 index 0000000000000..edb174594756b --- /dev/null +++ b/src/gui/sharing/ShareSectionHeader.qml @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +import Style + +RowLayout { + id: root + + required property string title + required property string description + + spacing: Style.smallSpacing + + Label { + text: root.title + font.weight: Font.DemiBold + } + + ToolButton { + visible: root.description.length > 0 + display: AbstractButton.IconOnly + icon.source: "image://svgimage-custom-color/info.svg/" + palette.buttonText + + Accessible.name: qsTr("About %1").arg(root.title) + Accessible.description: root.description + ToolTip.visible: hovered + ToolTip.text: root.description + ToolTip.delay: 0 + } + + Item { + Layout.fillWidth: true + } +} From 96dea4c4c570c38351fbe5fe754efe5e48c227ea Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Fri, 14 Aug 2026 16:12:09 +0800 Subject: [PATCH 102/104] feat(sharing): Added internal link and public link support Signed-off-by: Claudio Cambra --- src/gui/sharing/CMakeLists.txt | 1 + src/gui/sharing/ShareActionRow.qml | 71 +++++++ src/gui/sharing/ShareDialog.qml | 89 ++++++-- src/gui/sharing/ShareRow.qml | 39 +++- src/gui/sharing/share.cpp | 19 ++ src/gui/sharing/share.h | 6 + src/gui/sharing/sharingconstants.h | 11 + src/gui/sharing/sharingcontroller.cpp | 130 ++++++++++-- src/gui/sharing/sharingcontroller.h | 44 +++- src/gui/sharing/unifiedsharelistmodel.cpp | 137 ++++++++---- src/gui/sharing/unifiedsharelistmodel.h | 29 ++- src/gui/systray.cpp | 6 +- test/testunifiedsharing.cpp | 244 +++++++++++++++++++--- 13 files changed, 717 insertions(+), 109 deletions(-) create mode 100644 src/gui/sharing/ShareActionRow.qml diff --git a/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt index 1a18c4823ae34..160fe080fe910 100644 --- a/src/gui/sharing/CMakeLists.txt +++ b/src/gui/sharing/CMakeLists.txt @@ -75,6 +75,7 @@ ecm_add_qml_module(nextcloudGuiSharing FieldDelegate.qml RecipientIcon.qml RecipientSearchField.qml + ShareActionRow.qml ShareDetailsPage.qml ShareRow.qml ShareSectionHeader.qml diff --git a/src/gui/sharing/ShareActionRow.qml b/src/gui/sharing/ShareActionRow.qml new file mode 100644 index 0000000000000..65881bdeb0647 --- /dev/null +++ b/src/gui/sharing/ShareActionRow.qml @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +import Style +import "qrc:/qml/src/gui/wizard/qml" + +WizardItemDelegate { + id: root + + required property string title + required property string subtitle + required property string actionIcon + required property string actionName + property bool actionEnabled: true + + signal actionRequested + + implicitHeight: contentItem.implicitHeight + topPadding + bottomPadding + contentItem: RowLayout { + spacing: Style.standardSpacing + + Image { + Layout.preferredWidth: Style.activityListButtonIconSize + Layout.preferredHeight: Style.activityListButtonIconSize + source: "image://svgimage-custom-color/share.svg/" + palette.buttonText + sourceSize: Qt.size(Style.activityListButtonIconSize, Style.activityListButtonIconSize) + fillMode: Image.PreserveAspectFit + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Label { + Layout.fillWidth: true + text: root.title + color: Style.wizardPrimaryText + elide: Text.ElideRight + } + + Label { + Layout.fillWidth: true + text: root.subtitle + color: Style.wizardSecondaryText + elide: Text.ElideRight + visible: text.length > 0 + } + } + + WizardButton { + Layout.preferredWidth: implicitHeight + leftPadding: 0 + rightPadding: 0 + text: "" + iconSource: root.actionIcon + enabled: root.actionEnabled + + Accessible.name: root.actionName + ToolTip.visible: hovered + ToolTip.text: Accessible.name + + onClicked: root.actionRequested() + } + } +} diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index 5e219ee53c1d9..cfe57597dfe11 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -24,6 +24,7 @@ WizardStyledWindow { property string localPath: "" property string shortLocalPath: dialog.localPath.split("/").reverse()[0] property string fileId: "" + property string remotePath: "" property Share selectedShare: null property Share sharePendingDeletion: null property bool activatingShare: false @@ -96,6 +97,14 @@ WizardStyledWindow { } return "" } + + function copyToClipboard(value: string): void { + clipboardHelper.text = value + clipboardHelper.selectAll() + clipboardHelper.copy() + clipboardHelper.clear() + } + onSelectedShareChanged: { dialog.activatingShare = false dialog.shareActivationError = "" @@ -110,6 +119,11 @@ WizardStyledWindow { sharingController: sharingController } + TextEdit { + id: clipboardHelper + visible: false + } + Shortcut { sequences: [StandardKey.Cancel] onActivated: dialog.close() @@ -129,7 +143,9 @@ WizardStyledWindow { function onShareCreated(share) { dialog.clearNewShareRecipientSearch() - dialog.selectedShare = share + if (!share.publicLink) { + dialog.selectedShare = share + } } function onShareActivated(share) { @@ -143,8 +159,14 @@ WizardStyledWindow { if (share === dialog.selectedShare) { dialog.activatingShare = false dialog.shareActivationError = error + } else if (share && share.publicLink) { + dialog.shareActivationError = error } } + + function onInternalLinkResolved(url) { + dialog.copyToClipboard(url) + } } ColumnLayout { @@ -198,7 +220,7 @@ WizardStyledWindow { Layout.fillWidth: true Layout.preferredHeight: Style.normalBorderWidth color: Style.sharingDialogSeparatorColor - } + } EnforcedPlainTextLabel { Layout.fillWidth: true @@ -277,14 +299,20 @@ WizardStyledWindow { visible: text.length > 0 } - Label { + ErrorBox { + Layout.fillWidth: true + Layout.leftMargin: Style.sharingDialogWindowMargin + Layout.rightMargin: Style.sharingDialogWindowMargin + text: sharingController.internalLinkError + visible: text.length > 0 + } + + ErrorBox { Layout.fillWidth: true Layout.leftMargin: Style.sharingDialogWindowMargin Layout.rightMargin: Style.sharingDialogWindowMargin - text: qsTr("This item has not been shared yet.") - color: palette.placeholderText - wrapMode: Text.Wrap - visible: shareListView.count === 0 + text: dialog.shareActivationError + visible: !dialog.selectedShare && text.length > 0 } } @@ -304,6 +332,9 @@ WizardStyledWindow { if (itemType === UnifiedShareListModel.SectionHeader) { return sectionHeaderComponent } + if (itemType === UnifiedShareListModel.InternalLink) { + return internalLinkComponent + } if (itemType === UnifiedShareListModel.CreatePublicLink) { return createPublicLinkComponent } @@ -314,19 +345,53 @@ WizardStyledWindow { id: sectionHeaderComponent ShareSectionHeader { - x: Style.sharingDialogWindowMargin + x: Style.sharingDialogWindowMargin width: rowLoader.width - Style.sharingDialogWindowMargin * 2 title: dialog.sectionTitle(rowLoader.section) description: dialog.sectionDescription(rowLoader.section) } } - } + + Component { + id: internalLinkComponent + + ShareActionRow { + x: Style.sharingDialogWindowMargin + width: rowLoader.width - Style.sharingDialogWindowMargin * 2 + title: qsTr("Internal link") + subtitle: qsTr("For people who already have access") + actionIcon: "image://svgimage-custom-color/copy.svg/" + palette.buttonText + actionName: qsTr("Copy internal link") + actionEnabled: !sharingController.resolvingInternalLink && dialog.remotePath.length > 0 + + onActionRequested: sharingController.requestInternalLink(dialog.remotePath, dialog.fileId) + } + } + + Component { + id: createPublicLinkComponent + + ShareActionRow { + x: Style.sharingDialogWindowMargin + width: rowLoader.width - Style.sharingDialogWindowMargin * 2 + title: qsTr("Create public link") + subtitle: "" + actionIcon: "image://svgimage-custom-color/add.svg/" + palette.buttonText + actionName: qsTr("Create public link") + actionEnabled: !sharingController.creatingShare && dialog.fileId.length > 0 + + onActionRequested: { + dialog.shareActivationError = "" + sharingController.createPublicLink(dialog.fileId) + } + } + } Component { id: shareComponent ShareRow { - x: Style.sharingDialogWindowMargin + x: Style.sharingDialogWindowMargin width: rowLoader.width - Style.sharingDialogWindowMargin * 2 share: rowLoader.share recipientNames: rowLoader.recipientNames @@ -334,8 +399,8 @@ WizardStyledWindow { publicLinkUrl: rowLoader.publicLinkUrl onCopyRequested: dialog.copyToClipboard(publicLinkUrl) - onConfigureRequested: dialog.selectedShare = share - } + onConfigureRequested: dialog.selectedShare = share + } } } diff --git a/src/gui/sharing/ShareRow.qml b/src/gui/sharing/ShareRow.qml index ca8a226bafb64..9d2ca1251b319 100644 --- a/src/gui/sharing/ShareRow.qml +++ b/src/gui/sharing/ShareRow.qml @@ -16,6 +16,9 @@ WizardItemDelegate { required property Share share required property string recipientNames + required property bool publicLink + required property string publicLinkUrl + signal copyRequested signal configureRequested readonly property bool pending: share.state === Share.Draft @@ -38,22 +41,50 @@ WizardItemDelegate { Label { Layout.fillWidth: true - text: root.recipientNames || (root.pending ? qsTr("Unfinished share") : qsTr("Share")) + text: root.publicLink ? qsTr("Share link") : root.recipientNames || (root.pending ? qsTr("Unfinished share") : qsTr("Share")) color: Style.wizardPrimaryText elide: Text.ElideRight } Label { Layout.fillWidth: true - text: root.pending - ? qsTr("Not active — select to finish") - : qsTr("%n recipient(s)", "", root.share.recipients.length) + text: { + if (root.pending) { + return qsTr("Not active — select to finish") + } + if (root.publicLink) { + if (root.share.permissionPreset.endsWith("\\ViewSharePermissionPreset")) { + return qsTr("View only") + } + if (root.share.permissionPreset.endsWith("\\EditSharePermissionPreset")) { + return qsTr("Can edit") + } + return "" + } + return qsTr("%n recipient(s)", "", root.share.recipients.length) + } color: Style.wizardSecondaryText elide: Text.ElideRight visible: text.length > 0 } } + WizardButton { + Layout.preferredWidth: implicitHeight + leftPadding: 0 + rightPadding: 0 + text: "" + iconSource: "image://svgimage-custom-color/copy.svg/" + palette.buttonText + visible: root.publicLink && root.publicLinkUrl.length > 0 + enabled: visible + + Accessible.name: qsTr("Copy public link") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + + onClicked: root.copyRequested() + } + WizardButton { Layout.preferredWidth: implicitHeight leftPadding: 0 diff --git a/src/gui/sharing/share.cpp b/src/gui/sharing/share.cpp index 497794e629f68..0aa3c81e7c323 100644 --- a/src/gui/sharing/share.cpp +++ b/src/gui/sharing/share.cpp @@ -6,6 +6,10 @@ #include "share.h" +#include "sharingconstants.h" + +#include + #include #include #include @@ -85,6 +89,21 @@ const QList> &Share::recipients() const return _recipients; } +bool Share::isPublicLink() const +{ + return std::ranges::any_of(_recipients, [](const QPointer &recipient) { + return recipient && recipient->className() == RecipientTypeClasses::token; + }); +} + +QString Share::publicLinkUrl() const +{ + const auto recipient = std::ranges::find_if(_recipients, [](const QPointer &recipient) { + return recipient && recipient->className() == RecipientTypeClasses::token; + }); + return recipient == _recipients.cend() || !*recipient ? QString{} : (*recipient)->secretUrlString(); +} + void Share::setId(const QString &id) { if (_id == id) { diff --git a/src/gui/sharing/share.h b/src/gui/sharing/share.h index 68a331f17095f..a950eff0b68b7 100644 --- a/src/gui/sharing/share.h +++ b/src/gui/sharing/share.h @@ -29,6 +29,8 @@ class Share : public QObject Q_PROPERTY(QList> permissions READ permissions NOTIFY permissionsChanged) Q_PROPERTY(QList> properties READ properties NOTIFY propertiesChanged) Q_PROPERTY(QList> recipients READ recipients NOTIFY recipientsChanged) + Q_PROPERTY(bool publicLink READ isPublicLink NOTIFY recipientsChanged) + Q_PROPERTY(QString publicLinkUrl READ publicLinkUrl NOTIFY recipientsChanged) public: [[nodiscard]] static QPointer fromJson(const QJsonDocument &json, const AccountPtr &account); @@ -49,6 +51,10 @@ class Share : public QObject [[nodiscard]] const QList> &permissions() const; [[nodiscard]] const QList> &properties() const; [[nodiscard]] const QList> &recipients() const; + /** @brief Returns whether this share has the server's public-link recipient type. */ + [[nodiscard]] bool isPublicLink() const; + /** @brief Returns the public URL exposed by the public-link recipient, if available. */ + [[nodiscard]] QString publicLinkUrl() const; Q_SIGNALS: void idChanged(); diff --git a/src/gui/sharing/sharingconstants.h b/src/gui/sharing/sharingconstants.h index 8a32b89dfd303..3d29f46fbdb40 100644 --- a/src/gui/sharing/sharingconstants.h +++ b/src/gui/sharing/sharingconstants.h @@ -13,3 +13,14 @@ namespace OCC::Gui::Sharing::SourceTypeClasses inline constexpr auto node = QLatin1StringView{"OCA\\Files\\Sharing\\Source\\NodeShareSourceType"}; } + +namespace OCC::Gui::Sharing::RecipientTypeClasses +{ + +inline constexpr auto email = QLatin1StringView{"OC\\Core\\Sharing\\Recipient\\EmailShareRecipientType"}; +inline constexpr auto group = QLatin1StringView{"OC\\Core\\Sharing\\Recipient\\GroupShareRecipientType"}; +inline constexpr auto team = QLatin1StringView{"OC\\Core\\Sharing\\Recipient\\TeamShareRecipientType"}; +inline constexpr auto token = QLatin1StringView{"OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType"}; +inline constexpr auto user = QLatin1StringView{"OC\\Core\\Sharing\\Recipient\\UserShareRecipientType"}; + +} diff --git a/src/gui/sharing/sharingcontroller.cpp b/src/gui/sharing/sharingcontroller.cpp index ddda3d5c05a82..0fe8f3495c48f 100644 --- a/src/gui/sharing/sharingcontroller.cpp +++ b/src/gui/sharing/sharingcontroller.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -18,6 +19,7 @@ #include "destroysharejob.h" #include "generatesecretjob.h" #include "getsharesjob.h" +#include "networkjobs.h" #include "removerecipientjob.h" #include "setpermissionjob.h" #include "setpermissionpresetjob.h" @@ -92,6 +94,16 @@ QString SharingController::shareDestructionError() const return _shareDestructionError; } +bool SharingController::resolvingInternalLink() const +{ + return _resolvingInternalLink; +} + +QString SharingController::internalLinkError() const +{ + return _internalLinkError; +} + void SharingController::initialize(const QString &fileId) { if (!_account) { @@ -128,34 +140,93 @@ void SharingController::createShareForRecipient(const QString &fileId, return; } - startShareCreation(fileId, recipientType, recipientValue, recipientInstance); + if (beginShareCreation(fileId)) { + startShareCreation(fileId, recipientType, recipientValue, recipientInstance); + } } -void SharingController::startShareCreation(const QString &fileId, - const QString &recipientType, - const QString &recipientValue, - const QString &recipientInstance) +void SharingController::createPublicLink(const QString &fileId) +{ + if (std::ranges::any_of(_shares, [](const Share *share) { + return share && share->isPublicLink(); + })) { + qCDebug(lcSharingController) << "ignoring attempt to create a second public link"; + return; + } + + if (!beginShareCreation(fileId)) { + return; + } + + const auto generateJob = new GenerateSecretJob{_account}; + connect(generateJob, &GenerateSecretJob::secretGenerated, this, [this, fileId](const QString &recipientValue) { + if (recipientValue.isEmpty()) { + failShareCreation(tr("The server did not generate a valid public-link identifier.")); + return; + } + startShareCreation(fileId, QString{RecipientTypeClasses::token}, recipientValue, {}, true); + }); + connect(generateJob, &GenerateSecretJob::ocsError, this, [this](int, const QString &message) { + failShareCreation(message.isEmpty() ? tr("Could not create the public link.") : message); + }); + connect(generateJob, &GenerateSecretJob::networkError, this, [this](const QNetworkReply *reply) { + failShareCreation(reply ? reply->errorString() : tr("Could not create the public link.")); + }); + generateJob->start(); +} + +void SharingController::requestInternalLink(const QString &remotePath, const QString &numericFileId) +{ + if (!_account || remotePath.isEmpty() || _resolvingInternalLink) { + return; + } + + setInternalLinkError({}); + setResolvingInternalLink(true); + fetchPrivateLinkUrl(_account, remotePath, numericFileId.toUtf8(), this, [this](const QString &url) { + setResolvingInternalLink(false); + if (url.isEmpty()) { + setInternalLinkError(tr("Could not retrieve the internal link.")); + return; + } + Q_EMIT internalLinkResolved(url); + }); +} + +bool SharingController::beginShareCreation(const QString &fileId) { if (!_account) { qCWarning(lcSharingController) << "attempted to create a new share without an account set"; - return; + return false; } if (fileId.isEmpty()) { qCWarning(lcSharingController) << "attempted to create a new share without a file ID"; - return; + return false; } if (_creatingShare) { qCDebug(lcSharingController) << "ignoring attempt to create a share while another creation is in progress"; - return; + return false; } setShareCreationError({}); setCreatingShare(true); + return true; +} + +void SharingController::startShareCreation(const QString &fileId, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance, + bool activateAfterCreation) +{ + if (!_creatingShare) { + return; + } const auto job = new CreateShareJob{_account}; - connect(job, &CreateShareJob::shareCreated, this, [this, fileId, recipientType, recipientValue, recipientInstance](QPointer share) -> void { + connect(job, &CreateShareJob::shareCreated, this, [this, fileId, recipientType, recipientValue, recipientInstance, activateAfterCreation](QPointer share) -> void { if (!share || share->id().isEmpty()) { qCWarning(lcSharingController) << "share created without a valid Share object"; failShareCreation(tr("The server returned an invalid share."), share); @@ -163,7 +234,7 @@ void SharingController::startShareCreation(const QString &fileId, } share->setParent(this); - addSourceAfterCreation(share, fileId, recipientType, recipientValue, recipientInstance); + addSourceAfterCreation(share, fileId, recipientType, recipientValue, recipientInstance, activateAfterCreation); }); connect(job, &CreateShareJob::ocsError, this, [this](int, const QString &message) { failShareCreation(message.isEmpty() ? tr("Could not create the share.") : message); @@ -505,7 +576,8 @@ void SharingController::addSourceAfterCreation(QPointer share, const QString &fileId, const QString &recipientType, const QString &recipientValue, - const QString &recipientInstance) + const QString &recipientInstance, + bool activateAfterCreation) { if (!share) { failShareCreation(tr("The newly created share is no longer available.")); @@ -513,18 +585,18 @@ void SharingController::addSourceAfterCreation(QPointer share, } const auto job = new AddSourceJob{_account, *share, fileId}; - connect(job, &AddSourceJob::shareUpdated, this, [this, recipientType, recipientValue, recipientInstance](QPointer updatedShare) { + connect(job, &AddSourceJob::shareUpdated, this, [this, recipientType, recipientValue, recipientInstance, activateAfterCreation](QPointer updatedShare) { if (!updatedShare) { failShareCreation(tr("The newly created share is no longer available.")); return; } if (recipientType.isEmpty()) { - finishShareCreation(updatedShare); + finishShareCreation(updatedShare, activateAfterCreation); return; } - addRecipientAfterCreation(updatedShare, recipientType, recipientValue, recipientInstance); + addRecipientAfterCreation(updatedShare, recipientType, recipientValue, recipientInstance, activateAfterCreation); }); connect(job, &AddSourceJob::ocsError, this, [this, share](int, const QString &message) { failShareCreation(message.isEmpty() ? tr("Could not attach the item to the share.") : message, share); @@ -538,7 +610,8 @@ void SharingController::addSourceAfterCreation(QPointer share, void SharingController::addRecipientAfterCreation(QPointer share, const QString &recipientType, const QString &recipientValue, - const QString &recipientInstance) + const QString &recipientInstance, + bool activateAfterCreation) { if (!share) { failShareCreation(tr("The newly created share is no longer available.")); @@ -546,12 +619,12 @@ void SharingController::addRecipientAfterCreation(QPointer share, } const auto job = new AddRecipientJob{_account, *share, recipientType, recipientValue, optionalString(recipientInstance)}; - connect(job, &AddRecipientJob::shareUpdated, this, [this](QPointer updatedShare) { + connect(job, &AddRecipientJob::shareUpdated, this, [this, activateAfterCreation](QPointer updatedShare) { if (!updatedShare) { failShareCreation(tr("The newly created share is no longer available.")); return; } - finishShareCreation(updatedShare); + finishShareCreation(updatedShare, activateAfterCreation); }); connect(job, &AddRecipientJob::ocsError, this, [this, share](int, const QString &message) { failShareCreation(message.isEmpty() ? tr("Could not add the recipient.") : message, share); @@ -562,12 +635,15 @@ void SharingController::addRecipientAfterCreation(QPointer share, job->start(); } -void SharingController::finishShareCreation(QPointer share) +void SharingController::finishShareCreation(QPointer share, bool activateAfterCreation) { _shares.append(share); setCreatingShare(false); Q_EMIT shareCreated(share); Q_EMIT sharesChanged(); + if (activateAfterCreation) { + startShareActivation(share); + } } void SharingController::failShareCreation(const QString &error, QPointer share) @@ -670,6 +746,24 @@ void SharingController::setShareDestructionError(const QString &error) Q_EMIT shareDestructionErrorChanged(); } +void SharingController::setResolvingInternalLink(bool resolvingInternalLink) +{ + if (_resolvingInternalLink == resolvingInternalLink) { + return; + } + _resolvingInternalLink = resolvingInternalLink; + Q_EMIT resolvingInternalLinkChanged(); +} + +void SharingController::setInternalLinkError(const QString &error) +{ + if (_internalLinkError == error) { + return; + } + _internalLinkError = error; + Q_EMIT internalLinkErrorChanged(); +} + void SharingController::replaceShares(const QList &shares) { qDeleteAll(_shares); diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h index 820d5dbcf28c8..64a84464a7f04 100644 --- a/src/gui/sharing/sharingcontroller.h +++ b/src/gui/sharing/sharingcontroller.h @@ -30,6 +30,8 @@ class SharingController : public QObject Q_PROPERTY(QString shareCreationError READ shareCreationError NOTIFY shareCreationErrorChanged) Q_PROPERTY(bool destroyingShare READ destroyingShare NOTIFY destroyingShareChanged) Q_PROPERTY(QString shareDestructionError READ shareDestructionError NOTIFY shareDestructionErrorChanged) + Q_PROPERTY(bool resolvingInternalLink READ resolvingInternalLink NOTIFY resolvingInternalLinkChanged) + Q_PROPERTY(QString internalLinkError READ internalLinkError NOTIFY internalLinkErrorChanged) public: SharingController(QObject *parent = nullptr); @@ -53,6 +55,12 @@ class SharingController : public QObject /** @brief Returns the last share deletion error, or an empty string after a new attempt starts. */ [[nodiscard]] QString shareDestructionError() const; + /** @brief Returns whether the item's internal link is currently being resolved. */ + [[nodiscard]] bool resolvingInternalLink() const; + + /** @brief Returns the last internal-link resolution error. */ + [[nodiscard]] QString internalLinkError() const; + /** * @brief Loads all shares associated with a file without creating a share. * @@ -76,6 +84,17 @@ class SharingController : public QObject const QString &recipientValue, const QString &recipientInstance = {}); + /** @brief Creates and activates a public-link share for the specified file. */ + Q_INVOKABLE void createPublicLink(const QString &fileId); + + /** + * @brief Resolves the server-provided internal link for the specified file. + * + * @param remotePath Path of the file relative to the account's WebDAV root + * @param numericFileId Numeric file ID used if the server does not expose a private-link property + */ + Q_INVOKABLE void requestInternalLink(const QString &remotePath, const QString &numericFileId); + /** @brief Permanently removes a share managed by this controller. */ Q_INVOKABLE void destroyShare(Share *share); @@ -135,6 +154,15 @@ class SharingController : public QObject /** @brief Emitted when shareDestructionError changes. */ void shareDestructionErrorChanged(); + /** @brief Emitted when resolvingInternalLink changes. */ + void resolvingInternalLinkChanged(); + + /** @brief Emitted when internalLinkError changes. */ + void internalLinkErrorChanged(); + + /** @brief Emitted with the server-provided internal link after it is resolved. */ + void internalLinkResolved(const QString &url); + /** @brief Emitted after a recipient was added and the Share was updated. */ void recipientAdded(Share *share); @@ -175,25 +203,31 @@ class SharingController : public QObject QString _shareCreationError; bool _destroyingShare = false; QString _shareDestructionError; + bool _resolvingInternalLink = false; + QString _internalLinkError; QHash _pendingDraftUpdates; QSet _activationRequested; QSet _activationBlocked; [[nodiscard]] bool containsShare(const Share *share) const; + [[nodiscard]] bool beginShareCreation(const QString &fileId); void startShareCreation(const QString &fileId, const QString &recipientType, const QString &recipientValue, - const QString &recipientInstance); + const QString &recipientInstance, + bool activateAfterCreation = false); void addSourceAfterCreation(QPointer share, const QString &fileId, const QString &recipientType, const QString &recipientValue, - const QString &recipientInstance); + const QString &recipientInstance, + bool activateAfterCreation); void addRecipientAfterCreation(QPointer share, const QString &recipientType, const QString &recipientValue, - const QString &recipientInstance); - void finishShareCreation(QPointer share); + const QString &recipientInstance, + bool activateAfterCreation); + void finishShareCreation(QPointer share, bool activateAfterCreation); void failShareCreation(const QString &error, QPointer share = {}); void trackDraftUpdate(Share *share, QObject *job); void markDraftUpdateFailed(Share *share); @@ -202,6 +236,8 @@ class SharingController : public QObject void setShareCreationError(const QString &error); void setDestroyingShare(bool destroyingShare); void setShareDestructionError(const QString &error); + void setResolvingInternalLink(bool resolvingInternalLink); + void setInternalLinkError(const QString &error); void replaceShares(const QList &shares); }; diff --git a/src/gui/sharing/unifiedsharelistmodel.cpp b/src/gui/sharing/unifiedsharelistmodel.cpp index d0f86a9d6a5e8..d90e3fa305ff7 100644 --- a/src/gui/sharing/unifiedsharelistmodel.cpp +++ b/src/gui/sharing/unifiedsharelistmodel.cpp @@ -7,6 +7,7 @@ #include "recipient.h" #include "share.h" +#include "sharingconstants.h" #include "sharingcontroller.h" #include @@ -19,7 +20,28 @@ namespace { constexpr auto internalSection = "internal"_L1; constexpr auto externalSection = "external"_L1; +constexpr auto additionalSection = "additional"_L1; constexpr auto pendingSection = "pending"_L1; + +QString recipientNames(const Share *share) +{ + auto names = QStringList{}; + if (!share) { + return {}; + } + + for (const auto &recipient : share->recipients()) { + if (!recipient) { + continue; + } + + const auto name = recipient->displayName().isEmpty() ? recipient->value() : recipient->displayName(); + if (!name.isEmpty()) { + names.append(name); + } + } + return names.join(", "_L1); +} } UnifiedShareListModel::UnifiedShareListModel(QObject *parent) @@ -57,7 +79,7 @@ void UnifiedShareListModel::setSharingController(SharingController *sharingContr int UnifiedShareListModel::rowCount(const QModelIndex &parent) const { - return parent.isValid() ? 0 : _shares.size(); + return parent.isValid() ? 0 : _items.size(); } QVariant UnifiedShareListModel::data(const QModelIndex &index, int role) const @@ -66,26 +88,21 @@ QVariant UnifiedShareListModel::data(const QModelIndex &index, int role) const return {}; } - const auto share = _shares.at(index.row()); + const auto &item = _items.at(index.row()); + const auto share = item.share.data(); switch (role) { case ShareRole: - return QVariant::fromValue(share); + return QVariant::fromValue(share); case SectionRole: - return sectionForShare(share); - case RecipientNamesRole: { - auto recipientNames = QStringList{}; - for (const auto &recipient : share->recipients()) { - if (!recipient) { - continue; - } - - const auto name = recipient->displayName().isEmpty() ? recipient->value() : recipient->displayName(); - if (!name.isEmpty()) { - recipientNames.append(name); - } - } - return recipientNames.join(", "_L1); - } + return item.section; + case RecipientNamesRole: + return recipientNames(share); + case ItemTypeRole: + return QVariant::fromValue(item.type); + case PublicLinkRole: + return share && share->isPublicLink(); + case PublicLinkUrlRole: + return share ? share->publicLinkUrl() : QString{}; default: return {}; } @@ -97,6 +114,9 @@ QHash UnifiedShareListModel::roleNames() const {ShareRole, "share"}, {SectionRole, "section"}, {RecipientNamesRole, "recipientNames"}, + {ItemTypeRole, "itemType"}, + {PublicLinkRole, "publicLink"}, + {PublicLinkUrlRole, "publicLinkUrl"}, }; } @@ -109,20 +129,57 @@ void UnifiedShareListModel::rebuild() } _shareConnections.clear(); - _shares = _sharingController ? _sharingController->shares() : QList{}; - _shares.removeIf([](const Share *share) { + _items.clear(); + if (!_sharingController) { + endResetModel(); + return; + } + + auto shares = _sharingController->shares(); + shares.removeIf([](const Share *share) { return !share || share->state() == Share::ShareState::Deleted || share->state() == Share::ShareState::Unknown; }); - std::stable_sort(_shares.begin(), _shares.end(), [](const Share *left, const Share *right) { - return sectionOrder(left) < sectionOrder(right); - }); - _shareConnections.reserve(_shares.size() * 2); - for (const auto share : std::as_const(_shares)) { + _shareConnections.reserve(shares.size() * 2); + for (const auto share : std::as_const(shares)) { _shareConnections.append(connect(share, &Share::recipientsChanged, this, &UnifiedShareListModel::rebuild)); _shareConnections.append(connect(share, &Share::stateChanged, this, &UnifiedShareListModel::rebuild)); } + const auto appendHeader = [this](const QString §ion) { + _items.append({ItemType::SectionHeader, section, nullptr}); + }; + const auto appendShares = [this, &shares](const QString §ion) { + for (const auto share : std::as_const(shares)) { + if (sectionForShare(share) == section) { + _items.append({ItemType::Share, section, share}); + } + } + }; + + appendHeader(internalSection); + appendShares(internalSection); + _items.append({ItemType::InternalLink, internalSection, nullptr}); + + appendHeader(externalSection); + appendShares(externalSection); + const auto hasPublicLink = std::ranges::any_of(shares, [](const Share *share) { + return share && share->isPublicLink(); + }); + if (!hasPublicLink) { + _items.append({ItemType::CreatePublicLink, externalSection, nullptr}); + } + + appendHeader(additionalSection); + appendShares(additionalSection); + + if (std::ranges::any_of(shares, [](const Share *share) { + return share && share->state() == Share::ShareState::Draft; + })) { + appendHeader(pendingSection); + appendShares(pendingSection); + } + endResetModel(); } @@ -132,19 +189,26 @@ QString UnifiedShareListModel::sectionForShare(const Share *share) return pendingSection; } - return isExternalShare(share) ? externalSection : internalSection; + if (isExternalShare(share)) { + return externalSection; + } + return isInternalShare(share) ? internalSection : additionalSection; } -int UnifiedShareListModel::sectionOrder(const Share *share) +bool UnifiedShareListModel::isInternalShare(const Share *share) { - const auto section = sectionForShare(share); - if (section == internalSection) { - return 0; - } - if (section == externalSection) { - return 1; + if (!share || share->recipients().isEmpty()) { + return false; } - return 2; + + return std::ranges::all_of(share->recipients(), [](const QPointer &recipient) { + if (!recipient) { + return false; + } + + const auto &className = recipient->className(); + return className == RecipientTypeClasses::user || className == RecipientTypeClasses::group || className == RecipientTypeClasses::team; + }); } bool UnifiedShareListModel::isExternalShare(const Share *share) @@ -158,8 +222,7 @@ bool UnifiedShareListModel::isExternalShare(const Share *share) return false; } - const auto className = recipient->className().toLower(); - return recipient->instance().has_value() || recipient->secretUrl().has_value() || className.contains("federated"_L1) || className.contains("remote"_L1) - || className.contains("email"_L1) || className.contains("link"_L1); + const auto &className = recipient->className(); + return recipient->instance().has_value() || className == RecipientTypeClasses::email || className == RecipientTypeClasses::token; }); } diff --git a/src/gui/sharing/unifiedsharelistmodel.h b/src/gui/sharing/unifiedsharelistmodel.h index d05a5699dcb1a..efc9b949a8f5e 100644 --- a/src/gui/sharing/unifiedsharelistmodel.h +++ b/src/gui/sharing/unifiedsharelistmodel.h @@ -18,7 +18,7 @@ class Share; class SharingController; /** - * @brief Exposes active and pending unified shares in categorized display order. + * @brief Exposes unified sharing section headers, actions, and shares in display order. */ class UnifiedShareListModel : public QAbstractListModel { @@ -28,11 +28,23 @@ class UnifiedShareListModel : public QAbstractListModel Q_PROPERTY(SharingController *sharingController READ sharingController WRITE setSharingController NOTIFY sharingControllerChanged) public: + /** @brief Identifies the delegate needed for a list row. */ + enum class ItemType { + SectionHeader, + Share, + InternalLink, + CreatePublicLink, + }; + Q_ENUM(ItemType) + /** @brief Roles exposed to the sharing list delegates. */ enum Role { ShareRole = Qt::UserRole + 1, SectionRole, RecipientNamesRole, + ItemTypeRole, + PublicLinkRole, + PublicLinkUrlRole, }; Q_ENUM(Role) @@ -45,10 +57,10 @@ class UnifiedShareListModel : public QAbstractListModel /** @brief Sets the controller whose shares are exposed by this model. */ void setSharingController(SharingController *sharingController); - /** @brief Returns the number of shares in the root list. */ + /** @brief Returns the number of headers, actions, and shares in the root list. */ [[nodiscard]] int rowCount(const QModelIndex &parent = {}) const override; - /** @brief Returns the share or section value for a model index. */ + /** @brief Returns the requested display data for a model index. */ [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; /** @brief Returns the QML role names exposed by the model. */ @@ -59,13 +71,20 @@ class UnifiedShareListModel : public QAbstractListModel void sharingControllerChanged(); private: + struct Item + { + ItemType type; + QString section; + QPointer share; + }; + QPointer _sharingController; - QList _shares; + QList _items; QList _shareConnections; void rebuild(); [[nodiscard]] static QString sectionForShare(const Share *share); - [[nodiscard]] static int sectionOrder(const Share *share); + [[nodiscard]] static bool isInternalShare(const Share *share); [[nodiscard]] static bool isExternalShare(const Share *share); }; diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index 235daece31bc2..2aff1af75ecbe 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -863,14 +863,15 @@ void Systray::createFileDetailsDialog(const QString &localPath, const QString &f return; } + const auto relativePath = localPath.mid(folder->cleanPath().length() + 1); auto resolvedFileId = fileId; if (resolvedFileId.isEmpty()) { - const auto relativePath = localPath.mid(folder->cleanPath().length() + 1); auto fileRecord = SyncJournalFileRecord{}; if (folder->journalDb()->getFileRecord(relativePath, &fileRecord)) { - resolvedFileId = QString::fromUtf8(fileRecord._fileId); + resolvedFileId = QString::fromUtf8(fileRecord.numericFileId()); } } + const auto remotePath = QDir(folder->remotePath()).filePath(relativePath); if (folder->accountState()->account()->capabilities().unifiedSharingAvailable()) { // we have a server with the new unified sharing system, let's show the new fancy one @@ -880,6 +881,7 @@ void Systray::createFileDetailsDialog(const QString &localPath, const QString &f {"account", QVariant::fromValue(folder->accountState()->account())}, {"localPath", localPath}, {"fileId", resolvedFileId}, + {"remotePath", remotePath}, }; QQmlComponent fileDetailsDialog(trayEngine(), "com.nextcloud.desktopclient.sharing"_L1, "ShareDialog"_L1); diff --git a/test/testunifiedsharing.cpp b/test/testunifiedsharing.cpp index 29f00e627f79d..c4ad9692a99aa 100644 --- a/test/testunifiedsharing.cpp +++ b/test/testunifiedsharing.cpp @@ -589,7 +589,7 @@ private slots: "id": "external-share", "state": "active", "recipients": [{ - "class": "email", + "class": "OC\\Core\\Sharing\\Recipient\\EmailShareRecipientType", "display_name": "alice@example.com", "value": "alice@example.com" }] @@ -597,15 +597,23 @@ private slots: "id": "internal-share", "state": "active", "recipients": [{ - "class": "user", + "class": "OC\\Core\\Sharing\\Recipient\\UserShareRecipientType", "display_name": "Bob", "value": "bob" }] + }, { + "id": "additional-share", + "state": "active", + "recipients": [{ + "class": "OCA\\Deck\\Sharing\\Recipient\\BoardShareRecipientType", + "display_name": "Board members", + "value": "board-1" + }] }, { "id": "unfinished-share", "state": "draft", "recipients": [{ - "class": "user", + "class": "OC\\Core\\Sharing\\Recipient\\UserShareRecipientType", "value": "carol" }] }, { @@ -632,48 +640,56 @@ private slots: controller.initialize("42"_L1); - QTRY_COMPARE(model.rowCount(), 4); - QCOMPARE(model.data(model.index(0), UnifiedShareListModel::ShareRole).value()->id(), "internal-share"_L1); + QTRY_COMPARE(model.rowCount(), 11); + QCOMPARE(model.data(model.index(0), UnifiedShareListModel::ItemTypeRole).value(), + UnifiedShareListModel::ItemType::SectionHeader); QCOMPARE(model.data(model.index(0), UnifiedShareListModel::SectionRole).toString(), "internal"_L1); - QCOMPARE(model.data(model.index(0), UnifiedShareListModel::RecipientNamesRole).toString(), "Bob"_L1); - QCOMPARE(model.data(model.index(1), UnifiedShareListModel::ShareRole).value()->id(), "external-share"_L1); - QCOMPARE(model.data(model.index(1), UnifiedShareListModel::SectionRole).toString(), "external"_L1); - QCOMPARE(model.data(model.index(1), UnifiedShareListModel::RecipientNamesRole).toString(), "alice@example.com"_L1); - QCOMPARE(model.data(model.index(2), UnifiedShareListModel::ShareRole).value()->id(), "unfinished-share"_L1); - QCOMPARE(model.data(model.index(2), UnifiedShareListModel::SectionRole).toString(), "pending"_L1); - QCOMPARE(model.data(model.index(2), UnifiedShareListModel::RecipientNamesRole).toString(), "carol"_L1); - QCOMPARE(model.data(model.index(3), UnifiedShareListModel::ShareRole).value()->id(), "empty-draft"_L1); - QCOMPARE(model.data(model.index(3), UnifiedShareListModel::SectionRole).toString(), "pending"_L1); - QVERIFY(model.data(model.index(3), UnifiedShareListModel::RecipientNamesRole).toString().isEmpty()); + QCOMPARE(model.data(model.index(1), UnifiedShareListModel::ShareRole).value()->id(), "internal-share"_L1); + QCOMPARE(model.data(model.index(1), UnifiedShareListModel::RecipientNamesRole).toString(), "Bob"_L1); + QCOMPARE(model.data(model.index(2), UnifiedShareListModel::ItemTypeRole).value(), + UnifiedShareListModel::ItemType::InternalLink); + QCOMPARE(model.data(model.index(3), UnifiedShareListModel::SectionRole).toString(), "external"_L1); + QCOMPARE(model.data(model.index(4), UnifiedShareListModel::ShareRole).value()->id(), "external-share"_L1); + QCOMPARE(model.data(model.index(4), UnifiedShareListModel::RecipientNamesRole).toString(), "alice@example.com"_L1); + QCOMPARE(model.data(model.index(5), UnifiedShareListModel::ItemTypeRole).value(), + UnifiedShareListModel::ItemType::CreatePublicLink); + QCOMPARE(model.data(model.index(6), UnifiedShareListModel::SectionRole).toString(), "additional"_L1); + QCOMPARE(model.data(model.index(7), UnifiedShareListModel::ShareRole).value()->id(), "additional-share"_L1); + QCOMPARE(model.data(model.index(8), UnifiedShareListModel::SectionRole).toString(), "pending"_L1); + QCOMPARE(model.data(model.index(9), UnifiedShareListModel::ShareRole).value()->id(), "unfinished-share"_L1); + QCOMPARE(model.data(model.index(9), UnifiedShareListModel::RecipientNamesRole).toString(), "carol"_L1); + QCOMPARE(model.data(model.index(10), UnifiedShareListModel::ShareRole).value()->id(), "empty-draft"_L1); + QVERIFY(model.data(model.index(10), UnifiedShareListModel::RecipientNamesRole).toString().isEmpty()); QCOMPARE(model.rowCount(model.index(0)), 0); QVERIFY(!model.data({}, UnifiedShareListModel::ShareRole).isValid()); - const auto internalShare = model.data(model.index(0), UnifiedShareListModel::ShareRole).value(); + const auto internalShare = model.data(model.index(1), UnifiedShareListModel::ShareRole).value(); internalShare->updateFromJson(QJsonDocument::fromJson(R"json({ "ocs": {"data": {"recipients": [{ - "class": "email", + "class": "OC\\Core\\Sharing\\Recipient\\EmailShareRecipientType", "display_name": "bob@example.com", "value": "bob@example.com" }]}} })json")); QTRY_COMPARE(modelResetSpy.size(), 2); - QCOMPARE(model.data(model.index(0), UnifiedShareListModel::SectionRole).toString(), "external"_L1); - QCOMPARE(model.data(model.index(1), UnifiedShareListModel::SectionRole).toString(), "external"_L1); - QCOMPARE(model.data(model.index(2), UnifiedShareListModel::SectionRole).toString(), "pending"_L1); - QCOMPARE(model.data(model.index(3), UnifiedShareListModel::SectionRole).toString(), "pending"_L1); + QCOMPARE(model.data(model.index(1), UnifiedShareListModel::ItemTypeRole).value(), + UnifiedShareListModel::ItemType::InternalLink); + QCOMPARE(model.data(model.index(3), UnifiedShareListModel::ShareRole).value()->id(), "external-share"_L1); + QCOMPARE(model.data(model.index(4), UnifiedShareListModel::ShareRole).value()->id(), "internal-share"_L1); - const auto pendingShare = model.data(model.index(2), UnifiedShareListModel::ShareRole).value(); + const auto pendingShare = model.data(model.index(9), UnifiedShareListModel::ShareRole).value(); pendingShare->updateFromJson(QJsonDocument::fromJson(R"json({ "ocs": {"data": {"state": "active"}} })json")); QTRY_COMPARE(modelResetSpy.size(), 3); - QCOMPARE(model.data(model.index(0), UnifiedShareListModel::ShareRole).value()->id(), "unfinished-share"_L1); - QCOMPARE(model.data(model.index(0), UnifiedShareListModel::SectionRole).toString(), "internal"_L1); - QCOMPARE(model.data(model.index(1), UnifiedShareListModel::SectionRole).toString(), "external"_L1); - QCOMPARE(model.data(model.index(2), UnifiedShareListModel::SectionRole).toString(), "external"_L1); - QCOMPARE(model.data(model.index(3), UnifiedShareListModel::SectionRole).toString(), "pending"_L1); + QCOMPARE(model.data(model.index(1), UnifiedShareListModel::ShareRole).value()->id(), "unfinished-share"_L1); + QCOMPARE(model.data(model.index(2), UnifiedShareListModel::ItemTypeRole).value(), + UnifiedShareListModel::ItemType::InternalLink); + QCOMPARE(model.data(model.index(4), UnifiedShareListModel::ShareRole).value()->id(), "external-share"_L1); + QCOMPARE(model.data(model.index(5), UnifiedShareListModel::ShareRole).value()->id(), "internal-share"_L1); + QCOMPARE(model.data(model.index(10), UnifiedShareListModel::ShareRole).value()->id(), "empty-draft"_L1); model.setSharingController(nullptr); QCOMPARE(model.rowCount(), 0); @@ -836,6 +852,180 @@ private slots: (QJsonObject{{"class"_L1, "user"_L1}, {"value"_L1, "alice"_L1}, {"instance"_L1, "cloud.example.com"_L1}})); } + void sharingControllerCreatesAndActivatesPublicLink() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + const auto generatedRecipientValue = "abcdefghijklmnopqrstuvwxyz123456"_L1; + const auto publicUrl = "https://example.com/s/public-secret"_L1; + auto requestPaths = QStringList{}; + auto recipientBody = QJsonObject{}; + + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *outgoingData) { + const auto path = request.url().path(); + requestPaths.append(path); + + if (path.endsWith("/api/v1/secret"_L1)) { + return new FakePayloadReply{operation, + request, + QString{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": "%1" + } + })json"} + .arg(generatedRecipientValue) + .toUtf8(), + this}; + } + + if (path.endsWith("/recipient"_L1) && outgoingData) { + if (!outgoingData->isOpen()) { + outgoingData->open(QIODevice::ReadOnly); + } + recipientBody = QJsonDocument::fromJson(outgoingData->peek(outgoingData->bytesAvailable())).object(); + outgoingData->reset(); + } + + const auto creating = path.endsWith("/api/v1/share"_L1); + const auto activating = path.endsWith("/state"_L1); + const auto hasRecipient = path.endsWith("/recipient"_L1) || activating; + const auto response = QString{R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": %1, "message": "OK"}, + "data": { + "id": "public-share", + "state": "%2", + "permission_preset": "OC\\Core\\Sharing\\Permission\\ViewSharePermissionPreset", + "recipients": %3 + } + } + })json"} + .arg(creating ? 201 : 200) + .arg(activating ? "active"_L1 : "draft"_L1) + .arg(hasRecipient + ? QString{R"json([{ + "class": "OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType", + "display_name": "Public link", + "value": "%1", + "secret": {"updatable": true, "url": "%2"} + }])json"} + .arg(generatedRecipientValue, publicUrl) + : "[]"_L1) + .toUtf8(); + return new FakePayloadReply{operation, request, response, this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + QSignalSpy shareActivatedSpy{&controller, &SharingController::shareActivated}; + + controller.createPublicLink("42"_L1); + + QTRY_COMPARE(shareActivatedSpy.size(), 1); + QCOMPARE(controller.shares().size(), 1); + const auto share = controller.shares().constFirst(); + QCOMPARE(share->state(), Share::ShareState::Active); + QVERIFY(share->isPublicLink()); + QCOMPARE(share->publicLinkUrl(), publicUrl); + + UnifiedShareListModel model; + model.setSharingController(&controller); + QCOMPARE(model.rowCount(), 5); + QCOMPARE(model.data(model.index(3), UnifiedShareListModel::PublicLinkRole).toBool(), true); + QCOMPARE(model.data(model.index(3), UnifiedShareListModel::PublicLinkUrlRole).toString(), publicUrl); + for (auto row = 0; row < model.rowCount(); ++row) { + QVERIFY(model.data(model.index(row), UnifiedShareListModel::ItemTypeRole).value() + != UnifiedShareListModel::ItemType::CreatePublicLink); + } + + QCOMPARE(recipientBody, + (QJsonObject{{"class"_L1, RecipientTypeClasses::token}, {"value"_L1, generatedRecipientValue}})); + QCOMPARE(requestPaths.size(), 5); + QVERIFY(requestPaths.at(0).endsWith("/api/v1/secret"_L1)); + QVERIFY(requestPaths.at(1).endsWith("/api/v1/share"_L1)); + QVERIFY(requestPaths.at(2).endsWith("/api/v1/share/public-share/source"_L1)); + QVERIFY(requestPaths.at(3).endsWith("/api/v1/share/public-share/recipient"_L1)); + QVERIFY(requestPaths.at(4).endsWith("/api/v1/share/public-share/state"_L1)); + + controller.createPublicLink("42"_L1); + QCOMPARE(requestPaths.size(), 5); + } + + void sharingControllerReportsPublicLinkSecretFailure() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto requestCount = 0; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + ++requestCount; + return new FakePayloadReply{operation, + request, + R"json({ + "ocs": { + "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, + "data": "" + } + })json", + this}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + + controller.createPublicLink("42"_L1); + + QTRY_VERIFY(!controller.shareCreationError().isEmpty()); + QCOMPARE(requestCount, 1); + QVERIFY(controller.shares().isEmpty()); + QVERIFY(!controller.creatingShare()); + } + + void sharingControllerResolvesInternalLinkAndFallback() + { + FakeFolder fakeFolder{{}, {}, {}, false}; + auto includePrivateLink = true; + auto requestCount = 0; + fakeFolder.setServerOverride([&](FakeQNAM::Operation operation, const QNetworkRequest &request, QIODevice *) { + ++requestCount; + const auto properties = includePrivateLink + ? "42https://example.com/f/42"_ba + : QByteArray{}; + const auto reply = new FakePropfindReply{QByteArray{"" + "" + "/remote.php/dav/files/user/Documents/file.txt" + ""} + + properties + + QByteArray{"HTTP/1.1 200 OK" + ""}, + operation, + request, + this}; + reply->open(QIODevice::ReadOnly); + return reply; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + QSignalSpy internalLinkSpy{&controller, &SharingController::internalLinkResolved}; + + controller.requestInternalLink("/Documents/file.txt"_L1, "42"_L1); + controller.requestInternalLink("/Documents/file.txt"_L1, "42"_L1); + + QTRY_COMPARE(internalLinkSpy.size(), 1); + QCOMPARE(internalLinkSpy.at(0).at(0).toString(), "https://example.com/f/42"_L1); + QCOMPARE(requestCount, 1); + QVERIFY(!controller.resolvingInternalLink()); + QVERIFY(controller.internalLinkError().isEmpty()); + + includePrivateLink = false; + controller.requestInternalLink("/Documents/file.txt"_L1, "99"_L1); + QTRY_COMPARE(internalLinkSpy.size(), 2); + QCOMPARE(internalLinkSpy.at(1).at(0).toString(), fakeFolder.account()->deprecatedPrivateLinkUrl("99").toString(QUrl::FullyEncoded)); + + controller.requestInternalLink("/Documents/file.txt"_L1, {}); + QTRY_VERIFY(!controller.internalLinkError().isEmpty()); + QVERIFY(!controller.resolvingInternalLink()); + } + void sharingControllerCleansUpDraftWhenInitialRecipientFails() { FakeFolder fakeFolder{{}, {}, {}, false}; From 8d0f788650ef705ff1fbd2355163b7c3f77cbb5d Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Fri, 14 Aug 2026 16:57:55 +0800 Subject: [PATCH 103/104] feat(sharing): Fix and improve layout of sharing Signed-off-by: Claudio Cambra --- src/gui/sharing/ShareDialog.qml | 236 +++++++++++++---------- src/gui/wizard/qml/WizardDialogFrame.qml | 15 +- 2 files changed, 144 insertions(+), 107 deletions(-) diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml index cfe57597dfe11..1311b85ff5260 100644 --- a/src/gui/sharing/ShareDialog.qml +++ b/src/gui/sharing/ShareDialog.qml @@ -20,6 +20,22 @@ WizardStyledWindow { id: dialog visible: true + component DetachedVerticalScrollBar: ScrollBar { + required property var flickable + + orientation: Qt.Vertical + policy: ScrollBar.AsNeeded + size: flickable.visibleArea.heightRatio + position: flickable.visibleArea.yPosition + active: flickable.movingVertically || hovered || pressed + + onPositionChanged: { + if (pressed) { + flickable.contentY = flickable.originY + position * flickable.contentHeight + } + } + } + required property var account property string localPath: "" property string shortLocalPath: dialog.localPath.split("/").reverse()[0] @@ -235,22 +251,31 @@ WizardStyledWindow { visible: dialog.selectedShare } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Style.normalBorderWidth + color: Style.sharingDialogSeparatorColor + visible: dialog.selectedShare + } + StackLayout { Layout.fillWidth: true Layout.fillHeight: true currentIndex: dialog.selectedShare ? 1 : 0 Item { - ListView { - id: shareListView + id: shareListPane + ColumnLayout { anchors.fill: parent - clip: true - spacing: Style.extraSmallSpacing - model: shareListModel + spacing: 0 - header: ColumnLayout { - width: shareListView.width + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: Style.sharingDialogWindowMargin + Layout.rightMargin: Style.sharingDialogWindowMargin + Layout.topMargin: Style.standardSpacing + Layout.bottomMargin: Style.standardSpacing spacing: Style.standardSpacing RecipientSearchField { @@ -276,8 +301,6 @@ WizardStyledWindow { Label { Layout.fillWidth: true - Layout.leftMargin: Style.sharingDialogWindowMargin - Layout.rightMargin: Style.sharingDialogWindowMargin text: qsTr("Creating share…") color: Style.wizardSecondaryText visible: sharingController.creatingShare @@ -285,131 +308,130 @@ WizardStyledWindow { ErrorBox { Layout.fillWidth: true - Layout.leftMargin: Style.sharingDialogWindowMargin - Layout.rightMargin: Style.sharingDialogWindowMargin text: sharingController.shareCreationError visible: text.length > 0 } ErrorBox { Layout.fillWidth: true - Layout.leftMargin: Style.sharingDialogWindowMargin - Layout.rightMargin: Style.sharingDialogWindowMargin text: sharingController.shareDestructionError visible: text.length > 0 } ErrorBox { Layout.fillWidth: true - Layout.leftMargin: Style.sharingDialogWindowMargin - Layout.rightMargin: Style.sharingDialogWindowMargin text: sharingController.internalLinkError visible: text.length > 0 } ErrorBox { Layout.fillWidth: true - Layout.leftMargin: Style.sharingDialogWindowMargin - Layout.rightMargin: Style.sharingDialogWindowMargin text: dialog.shareActivationError visible: !dialog.selectedShare && text.length > 0 } } - delegate: Loader { - id: rowLoader - - required property int itemType - required property string section - required property Share share - required property string recipientNames - required property bool publicLink - required property string publicLinkUrl - - width: ListView.view.width - height: item ? item.implicitHeight : 0 - sourceComponent: { - if (itemType === UnifiedShareListModel.SectionHeader) { - return sectionHeaderComponent - } - if (itemType === UnifiedShareListModel.InternalLink) { - return internalLinkComponent - } - if (itemType === UnifiedShareListModel.CreatePublicLink) { - return createPublicLinkComponent - } - return shareComponent - } + Item { + Layout.fillWidth: true + Layout.fillHeight: true - Component { - id: sectionHeaderComponent + ListView { + id: shareListView + + anchors.fill: parent + anchors.leftMargin: Style.sharingDialogWindowMargin + anchors.rightMargin: Style.sharingDialogWindowMargin + clip: true + spacing: Style.extraSmallSpacing + model: shareListModel + + delegate: Loader { + id: rowLoader + + required property int itemType + required property string section + required property Share share + required property string recipientNames + required property bool publicLink + required property string publicLinkUrl + + width: ListView.view.width + height: item ? item.implicitHeight : 0 + sourceComponent: { + if (itemType === UnifiedShareListModel.SectionHeader) { + return sectionHeaderComponent + } + if (itemType === UnifiedShareListModel.InternalLink) { + return internalLinkComponent + } + if (itemType === UnifiedShareListModel.CreatePublicLink) { + return createPublicLinkComponent + } + return shareComponent + } - ShareSectionHeader { - x: Style.sharingDialogWindowMargin - width: rowLoader.width - Style.sharingDialogWindowMargin * 2 - title: dialog.sectionTitle(rowLoader.section) - description: dialog.sectionDescription(rowLoader.section) - } - } + Component { + id: sectionHeaderComponent - Component { - id: internalLinkComponent + ShareSectionHeader { + title: dialog.sectionTitle(rowLoader.section) + description: dialog.sectionDescription(rowLoader.section) + } + } - ShareActionRow { - x: Style.sharingDialogWindowMargin - width: rowLoader.width - Style.sharingDialogWindowMargin * 2 - title: qsTr("Internal link") - subtitle: qsTr("For people who already have access") - actionIcon: "image://svgimage-custom-color/copy.svg/" + palette.buttonText - actionName: qsTr("Copy internal link") - actionEnabled: !sharingController.resolvingInternalLink && dialog.remotePath.length > 0 + Component { + id: internalLinkComponent - onActionRequested: sharingController.requestInternalLink(dialog.remotePath, dialog.fileId) - } - } + ShareActionRow { + title: qsTr("Internal link") + subtitle: qsTr("For people who already have access") + actionIcon: "image://svgimage-custom-color/copy.svg/" + palette.buttonText + actionName: qsTr("Copy internal link") + actionEnabled: !sharingController.resolvingInternalLink && dialog.remotePath.length > 0 - Component { - id: createPublicLinkComponent - - ShareActionRow { - x: Style.sharingDialogWindowMargin - width: rowLoader.width - Style.sharingDialogWindowMargin * 2 - title: qsTr("Create public link") - subtitle: "" - actionIcon: "image://svgimage-custom-color/add.svg/" + palette.buttonText - actionName: qsTr("Create public link") - actionEnabled: !sharingController.creatingShare && dialog.fileId.length > 0 - - onActionRequested: { - dialog.shareActivationError = "" - sharingController.createPublicLink(dialog.fileId) + onActionRequested: sharingController.requestInternalLink(dialog.remotePath, dialog.fileId) + } } - } - } - Component { - id: shareComponent + Component { + id: createPublicLinkComponent + + ShareActionRow { + title: qsTr("Create public link") + subtitle: "" + actionIcon: "image://svgimage-custom-color/add.svg/" + palette.buttonText + actionName: qsTr("Create public link") + actionEnabled: !sharingController.creatingShare && dialog.fileId.length > 0 + + onActionRequested: { + dialog.shareActivationError = "" + sharingController.createPublicLink(dialog.fileId) + } + } + } + + Component { + id: shareComponent - ShareRow { - x: Style.sharingDialogWindowMargin - width: rowLoader.width - Style.sharingDialogWindowMargin * 2 - share: rowLoader.share - recipientNames: rowLoader.recipientNames - publicLink: rowLoader.publicLink - publicLinkUrl: rowLoader.publicLinkUrl + ShareRow { + share: rowLoader.share + recipientNames: rowLoader.recipientNames + publicLink: rowLoader.publicLink + publicLinkUrl: rowLoader.publicLinkUrl - onCopyRequested: dialog.copyToClipboard(publicLinkUrl) - onConfigureRequested: dialog.selectedShare = share + onCopyRequested: dialog.copyToClipboard(publicLinkUrl) + onConfigureRequested: dialog.selectedShare = share + } + } } } - } - ScrollBar.vertical: ScrollBar { - parent: shareListView - anchors.top: shareListView.top - anchors.right: shareListView.right - anchors.bottom: shareListView.bottom - policy: ScrollBar.AsNeeded + DetachedVerticalScrollBar { + anchors.top: parent.top + anchors.right: parent.right + anchors.bottom: parent.bottom + flickable: shareListView + } } } } @@ -417,6 +439,9 @@ WizardStyledWindow { WizardDialogFrame { id: shareDetailsFrame + footerSeparatorVisible: dialog.selectedShare !== null + footerTopPadding: Style.standardSpacing + ColumnLayout { anchors.fill: parent spacing: Style.wizardSectionSpacing @@ -453,13 +478,7 @@ WizardStyledWindow { policy: ScrollBar.AlwaysOff } - ScrollBar.vertical: ScrollBar { - parent: shareDetailsScrollView - anchors.top: shareDetailsScrollView.top - anchors.right: shareDetailsScrollView.right - anchors.bottom: shareDetailsScrollView.bottom - policy: ScrollBar.AsNeeded - } + ScrollBar.vertical.policy: ScrollBar.AlwaysOff } ErrorBox { @@ -489,6 +508,13 @@ WizardStyledWindow { } } + DetachedVerticalScrollBar { + anchors.top: parent.top + anchors.right: parent.right + anchors.bottom: parent.bottom + flickable: shareDetailsScrollView.contentItem + } + footer: [ WizardButton { text: qsTr("Delete share") diff --git a/src/gui/wizard/qml/WizardDialogFrame.qml b/src/gui/wizard/qml/WizardDialogFrame.qml index 6d75ff0e71406..6bf3902720a99 100644 --- a/src/gui/wizard/qml/WizardDialogFrame.qml +++ b/src/gui/wizard/qml/WizardDialogFrame.qml @@ -14,6 +14,8 @@ Pane { default property alias contents: body.data property alias footer: footerLayout.data + property bool footerSeparatorVisible: false + property int footerTopPadding: 0 readonly property int windowMargin: Style.wizardWindowMargin readonly property int footerButtonHeight: Style.wizardFooterButtonHeight @@ -35,14 +37,23 @@ Pane { Item { Layout.fillWidth: true - Layout.preferredHeight: root.footerButtonHeight + root.windowMargin + Layout.preferredHeight: root.footerButtonHeight + root.windowMargin + root.footerTopPadding + (root.footerSeparatorVisible ? Style.normalBorderWidth : 0) + + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: Style.normalBorderWidth + color: Style.wizardRowBorder + visible: root.footerSeparatorVisible + } RowLayout { id: footerLayout anchors.fill: parent anchors.leftMargin: root.windowMargin anchors.rightMargin: root.windowMargin - anchors.topMargin: 0 + anchors.topMargin: root.footerTopPadding + (root.footerSeparatorVisible ? Style.normalBorderWidth : 0) anchors.bottomMargin: root.windowMargin spacing: Style.wizardFooterSpacing } From be483285045dcf21d4439be51b5710afd209bcc3 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Fri, 14 Aug 2026 20:59:04 +0800 Subject: [PATCH 104/104] feat(sharing): Open QML unified sharing window (when this is supported by server) from FileProvider context menu Signed-off-by: Claudio Cambra --- .../Services/FPUIExtensionService.swift | 7 ++ .../Services/FPUIExtensionServiceSource.swift | 41 ++++++++ .../Utilities/UnifiedSharingCapability.swift | 19 ++++ .../include/AppProtocol.h | 13 +++ .../BundleExclusionReporterTests.swift | 7 ++ .../InsufficientQuotaReporterTests.swift | 7 ++ .../ItemCreateTests.swift | 1 + .../ReportCurrentSyncStateTests.swift | 1 + .../UnifiedSharingCapabilityTests.swift | 36 +++++++ .../DocumentActionViewController.swift | 38 +++++++- src/gui/macOS/fileproviderservice.h | 9 ++ src/gui/macOS/fileproviderservice.mm | 29 +++++- src/gui/owncloudgui.cpp | 1 + src/gui/systray.cpp | 93 ++++++++++++------- src/gui/systray.h | 14 +++ 15 files changed, 279 insertions(+), 37 deletions(-) create mode 100644 shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/UnifiedSharingCapability.swift create mode 100644 shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/UnifiedSharingCapabilityTests.swift diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Services/FPUIExtensionService.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Services/FPUIExtensionService.swift index 39d5f54ab57bd..e9b7759b8551d 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Services/FPUIExtensionService.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Services/FPUIExtensionService.swift @@ -36,4 +36,11 @@ public let fpUiExtensionServiceName = NSFileProviderServiceName("com.nextcloud.d /// Get a server URL for the given local file provider item. /// func itemServerPath(identifier: NSFileProviderItemIdentifier) async -> NSString? + + /// + /// Present the unified sharing dialog for the given local file provider item when supported. + /// + /// - Returns: `true` when the request was handed to the main app, otherwise `false` so the caller can use the legacy sharing interface. + /// + func presentUnifiedSharingDialog(identifier: NSFileProviderItemIdentifier, localPath: NSString) async -> Bool } diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Services/FPUIExtensionServiceSource.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Services/FPUIExtensionServiceSource.swift index 7ff672d29ec61..4fa4ec3a0430a 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Services/FPUIExtensionServiceSource.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Services/FPUIExtensionServiceSource.swift @@ -92,4 +92,45 @@ class FPUIExtensionServiceSource: NSObject, NSFileProviderServiceSource, NSXPCLi let completePath = item.serverUrl + "/" + item.fileName return completePath.replacingOccurrences(of: baseUrl, with: "") as NSString } + + func presentUnifiedSharingDialog(identifier: NSFileProviderItemIdentifier, localPath: NSString) async -> Bool { + guard let account = fpExtension.ncAccount else { + logger.error("Could not present unified sharing because the account is unavailable.", [.item: identifier]) + return false + } + + guard let dbManager = fpExtension.dbManager, + let metadata = dbManager.itemMetadata(identifier), + !metadata.fileId.isEmpty + else { + logger.error("Could not present unified sharing because the item metadata or numeric file id is unavailable.", [.item: identifier]) + return false + } + + let (_, _, responseData, error) = await fpExtension.ncKit.fetchCapabilities(account: account) + guard error == .success else { + logger.error("Could not determine whether unified sharing is supported.", [.item: identifier, .error: error]) + return false + } + + guard UnifiedSharingCapability.isAvailable(in: responseData) else { + logger.info("Unified sharing is not supported; using the legacy sharing interface.", [.item: identifier]) + return false + } + + guard let app = fpExtension.app else { + logger.error("Could not present unified sharing because the main app connection is unavailable.", [.item: identifier]) + return false + } + + let domainIdentifier = fpExtension.domain.identifier.rawValue + app.presentUnifiedSharing( + forItem: metadata.fileId, + localPath: localPath as String, + remoteItemPath: metadata.path, + forDomainIdentifier: domainIdentifier + ) + logger.info("Asked the main app to present unified sharing.", [.item: identifier, .domain: fpExtension.domain.identifier]) + return true + } } diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/UnifiedSharingCapability.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/UnifiedSharingCapability.swift new file mode 100644 index 0000000000000..6445d6471fdc2 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/UnifiedSharingCapability.swift @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation + +enum UnifiedSharingCapability { + static func isAvailable(in responseData: Data?) -> Bool { + guard let responseData, + let root = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any], + let ocs = root["ocs"] as? [String: Any], + let data = ocs["data"] as? [String: Any], + let capabilities = data["capabilities"] as? [String: Any] + else { + return false + } + + return capabilities.keys.contains("sharing") + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderXPC/include/AppProtocol.h b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderXPC/include/AppProtocol.h index 749a130847c4c..da63737b2f0d1 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderXPC/include/AppProtocol.h +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderXPC/include/AppProtocol.h @@ -23,6 +23,19 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)presentFileActions:(NSString *)fileId path:(NSString *)path remoteItemPath:(NSString *)remoteItemPath withDomainIdentifier:(NSString *)domainIdentifier; +/** + * @brief The file provider extension asks the main app to present the unified sharing dialog for an item. + * @param fileId The numeric server file id, equal to the WebDAV `fileid` property. + * @param localPath The local and absolute path of the item. + * @param remoteItemPath The server-side path of the item. + * @param domainIdentifier The file provider domain identifier for the account that owns the item. + */ +- (void)presentUnifiedSharingForItem:(NSString *)fileId + localPath:(NSString *)localPath + remoteItemPath:(NSString *)remoteItemPath + forDomainIdentifier:(NSString *)domainIdentifier + NS_SWIFT_NAME(presentUnifiedSharing(forItem:localPath:remoteItemPath:forDomainIdentifier:)); + /** * @brief The file provider extension asks the main app to open the item's page in the user's web browser. * diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/BundleExclusionReporterTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/BundleExclusionReporterTests.swift index 7aba1b32d99a4..5f61b0327e99b 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/BundleExclusionReporterTests.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/BundleExclusionReporterTests.swift @@ -30,6 +30,13 @@ private final class CapturingAppProxy: NSObject, AppProtocol { presentedFileActions.append(fileId) } + func presentUnifiedSharing( + forItem _: String, + localPath _: String, + remoteItemPath _: String, + forDomainIdentifier _: String + ) {} + /// Unused by these tests but required for protocol conformance — see /// `FileProviderExtensionOpenInBrowserTests` for the dedicated coverage. func openItemInBrowser(_: String, remoteItemPath _: String, forDomainIdentifier _: String) {} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/InsufficientQuotaReporterTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/InsufficientQuotaReporterTests.swift index 0347414104827..75432f0d2cb22 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/InsufficientQuotaReporterTests.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/InsufficientQuotaReporterTests.swift @@ -26,6 +26,13 @@ private final class CapturingAppProxy: NSObject, AppProtocol { _: String, path _: String, remoteItemPath _: String, withDomainIdentifier _: String ) {} + func presentUnifiedSharing( + forItem _: String, + localPath _: String, + remoteItemPath _: String, + forDomainIdentifier _: String + ) {} + func openItemInBrowser(_: String, remoteItemPath _: String, forDomainIdentifier _: String) {} func copyInternalLink(forItem _: String, remoteItemPath _: String, forDomainIdentifier _: String) {} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift index 534cf6d31c385..626c92222f08a 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift @@ -25,6 +25,7 @@ final class QuotaCapturingAppProxy: NSObject, AppProtocol { var capturedSummaryDomains: [String] = [] func presentFileActions(_: String, path _: String, remoteItemPath _: String, withDomainIdentifier _: String) {} + func presentUnifiedSharing(forItem _: String, localPath _: String, remoteItemPath _: String, forDomainIdentifier _: String) {} func openItemInBrowser(_: String, remoteItemPath _: String, forDomainIdentifier _: String) {} func copyInternalLink(forItem _: String, remoteItemPath _: String, forDomainIdentifier _: String) {} func reportSyncStatus(_: String, forDomainIdentifier _: String) {} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ReportCurrentSyncStateTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ReportCurrentSyncStateTests.swift index 209494dc02a95..551d9cefb99bc 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ReportCurrentSyncStateTests.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ReportCurrentSyncStateTests.swift @@ -17,6 +17,7 @@ private final class SyncStatusCapturingAppProxy: NSObject, AppProtocol { // The following are unused by these tests but required for protocol conformance. func presentFileActions(_: String, path _: String, remoteItemPath _: String, withDomainIdentifier _: String) {} + func presentUnifiedSharing(forItem _: String, localPath _: String, remoteItemPath _: String, forDomainIdentifier _: String) {} func openItemInBrowser(_: String, remoteItemPath _: String, forDomainIdentifier _: String) {} func copyInternalLink(forItem _: String, remoteItemPath _: String, forDomainIdentifier _: String) {} func reportItemExcluded(fromSync _: String, fileName _: String, reason _: String, forDomainIdentifier _: String) {} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/UnifiedSharingCapabilityTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/UnifiedSharingCapabilityTests.swift new file mode 100644 index 0000000000000..dd1144211d485 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/UnifiedSharingCapabilityTests.swift @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation +@testable import NextcloudFileProviderKit +import Testing + +@Suite("Unified sharing capability") +struct UnifiedSharingCapabilityTests { + @Test("Detects the sharing capability") + func detectsSharingCapability() { + let responseData = Data(#"{"ocs":{"data":{"capabilities":{"sharing":{}}}}}"#.utf8) + + #expect(UnifiedSharingCapability.isAvailable(in: responseData)) + } + + @Test("Treats the presence of a null sharing capability as available") + func detectsNullSharingCapability() { + let responseData = Data(#"{"ocs":{"data":{"capabilities":{"sharing":null}}}}"#.utf8) + + #expect(UnifiedSharingCapability.isAvailable(in: responseData)) + } + + @Test("Rejects a capabilities response without sharing") + func rejectsMissingSharingCapability() { + let responseData = Data(#"{"ocs":{"data":{"capabilities":{"files":{}}}}}"#.utf8) + + #expect(!UnifiedSharingCapability.isAvailable(in: responseData)) + } + + @Test("Rejects malformed capability data") + func rejectsMalformedCapabilityData() { + #expect(!UnifiedSharingCapability.isAvailable(in: Data("not json".utf8))) + #expect(!UnifiedSharingCapability.isAvailable(in: nil)) + } +} diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderUIExt/DocumentActionViewController.swift b/shell_integration/MacOSX/NextcloudIntegration/FileProviderUIExt/DocumentActionViewController.swift index ec4671d70e9f7..68348f140dc9f 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderUIExt/DocumentActionViewController.swift +++ b/shell_integration/MacOSX/NextcloudIntegration/FileProviderUIExt/DocumentActionViewController.swift @@ -65,7 +65,7 @@ class DocumentActionViewController: FPUIActionExtensionViewController { switch (actionIdentifier) { case "com.nextcloud.desktopclient.FileProviderUIExt.ShareAction": - prepare(childViewController: ShareViewController(itemIdentifiers, serviceResolver: serviceResolver, log: log)) + prepareSharingAction(itemIdentifiers: itemIdentifiers) case "com.nextcloud.desktopclient.FileProviderUIExt.LockFileAction": prepare(childViewController: LockViewController(itemIdentifiers, locking: true, serviceResolver: serviceResolver, log: log)) case "com.nextcloud.desktopclient.FileProviderUIExt.UnlockFileAction": @@ -94,6 +94,42 @@ class DocumentActionViewController: FPUIActionExtensionViewController { self.view = NSView() } + // MARK: - Sharing + + private func prepareSharingAction(itemIdentifiers: [NSFileProviderItemIdentifier]) { + guard itemIdentifiers.count == 1, + let itemIdentifier = itemIdentifiers.first, + let manager = NSFileProviderManager(for: domain) + else { + prepareLegacySharingAction(itemIdentifiers: itemIdentifiers) + return + } + + Task { @MainActor in + do { + let localUrl = try await manager.getUserVisibleURL(for: itemIdentifier) + let service = try await serviceResolver.getService(at: localUrl) + let presented = await service.presentUnifiedSharingDialog( + identifier: itemIdentifier, + localPath: localUrl.path as NSString + ) + + if presented { + extensionContext.completeRequest() + return + } + } catch { + logger.error("Could not route the sharing action to unified sharing.", [.item: itemIdentifier, .error: error]) + } + + prepareLegacySharingAction(itemIdentifiers: itemIdentifiers) + } + } + + private func prepareLegacySharingAction(itemIdentifiers: [NSFileProviderItemIdentifier]) { + prepare(childViewController: ShareViewController(itemIdentifiers, serviceResolver: serviceResolver, log: log)) + } + // MARK: - Eviction /// diff --git a/src/gui/macOS/fileproviderservice.h b/src/gui/macOS/fileproviderservice.h index 328d2a44f1381..a77532020302c 100644 --- a/src/gui/macOS/fileproviderservice.h +++ b/src/gui/macOS/fileproviderservice.h @@ -68,6 +68,15 @@ class FileProviderService : public QObject */ void showFileActionsDialog(const QString &fileId, const QString &localFile, const QString &remoteItemPath, const QString &fileProviderDomainIdentifier); + /** + * @brief Emitted when a file provider extension requests the unified sharing dialog. + * @param fileId The numeric server file id, equal to the WebDAV `fileid` property. + * @param localFile The local file path to share. + * @param remoteItemPath The server-side path of the item. + * @param fileProviderDomainIdentifier The file provider domain identifier for the account that owns the item. + */ + void showUnifiedSharingDialog(const QString &fileId, const QString &localFile, const QString &remoteItemPath, const QString &fileProviderDomainIdentifier); + /** * @brief Emitted when a file provider extension requests to open an item's page in the user's web browser. * diff --git a/src/gui/macOS/fileproviderservice.mm b/src/gui/macOS/fileproviderservice.mm index ae26eb104bce1..0eb2e4913e158 100644 --- a/src/gui/macOS/fileproviderservice.mm +++ b/src/gui/macOS/fileproviderservice.mm @@ -21,6 +21,8 @@ } // namespace OCC + + /** * @brief Objective-C delegate that implements the AppProtocol. */ @@ -55,6 +57,32 @@ - (void)presentFileActions:(NSString *)fileId path:(NSString *)path remoteItemPa Q_ARG(QString, domainId)); } +- (void)presentUnifiedSharingForItem:(NSString *)fileId + localPath:(NSString *)localPath + remoteItemPath:(NSString *)remoteItemPath + forDomainIdentifier:(NSString *)domainIdentifier +{ + qCDebug(OCC::lcMacFileProviderService) << "Should present unified sharing for item with fileId:" + << fileId + << "and path:" + << localPath + << "remote item path:" + << remoteItemPath + << "domain identifier:" + << domainIdentifier; + + const auto qFileId = QString::fromNSString(fileId); + const auto qLocalPath = QString::fromNSString(localPath); + const auto qRemoteItemPath = QString::fromNSString(remoteItemPath); + const auto domainId = QString::fromNSString(domainIdentifier); + + QMetaObject::invokeMethod(_service, "showUnifiedSharingDialog", Qt::QueuedConnection, + Q_ARG(QString, qFileId), + Q_ARG(QString, qLocalPath), + Q_ARG(QString, qRemoteItemPath), + Q_ARG(QString, domainId)); +} + - (void)openItemInBrowser:(NSString *)fileId remoteItemPath:(NSString *)remoteItemPath forDomainIdentifier:(NSString *)domainIdentifier @@ -277,4 +305,3 @@ - (void)reportSyncStatus:(NSString *)status forDomainIdentifier:(NSString *)doma } // namespace Mac } // namespace OCC - diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index 4c736d835416d..c863c9c7a97c7 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -133,6 +133,7 @@ ownCloudGui::ownCloudGui(Application *parent) #ifdef BUILD_FILE_PROVIDER_MODULE connect(Mac::FileProvider::instance()->service(), &Mac::FileProviderService::syncStateChanged, this, &ownCloudGui::slotComputeOverallSyncStatus); connect(Mac::FileProvider::instance()->service(), &Mac::FileProviderService::showFileActionsDialog, _tray.data(), &Systray::slotShowFileProviderFileActionsDialog); + connect(Mac::FileProvider::instance()->service(), &Mac::FileProviderService::showUnifiedSharingDialog, _tray.data(), &Systray::slotShowFileProviderUnifiedSharingDialog); connect(Mac::FileProvider::instance()->service(), &Mac::FileProviderService::openItemInBrowserRequested, this, &ownCloudGui::slotOpenItemInBrowserFromFileProvider); connect(Mac::FileProvider::instance()->service(), &Mac::FileProviderService::copyInternalLinkRequested, this, &ownCloudGui::slotCopyInternalLinkFromFileProvider); #endif diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index 2aff1af75ecbe..4245b4b7f6b51 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -874,41 +874,7 @@ void Systray::createFileDetailsDialog(const QString &localPath, const QString &f const auto remotePath = QDir(folder->remotePath()).filePath(relativePath); if (folder->accountState()->account()->capabilities().unifiedSharingAvailable()) { - // we have a server with the new unified sharing system, let's show the new fancy one - // TODO: reduce code duplication - - const QVariantMap initialProperties{ - {"account", QVariant::fromValue(folder->accountState()->account())}, - {"localPath", localPath}, - {"fileId", resolvedFileId}, - {"remotePath", remotePath}, - }; - - QQmlComponent fileDetailsDialog(trayEngine(), "com.nextcloud.desktopclient.sharing"_L1, "ShareDialog"_L1); - - if (fileDetailsDialog.isError()) { - qCWarning(lcSystray) << fileDetailsDialog.errorString(); - return; - } - - const auto createdDialog = fileDetailsDialog.createWithInitialProperties(initialProperties); - const auto dialog = qobject_cast(createdDialog); - - if (!dialog) { - qCWarning(lcSystray) << "File details dialog window resulted in creation of object that was not a window!"; - return; - } - - _fileDetailDialogs.append(dialog); - -#if defined(Q_OS_MACOS) - configureMacOSExpandedQuickWindow(dialog); -#endif - - dialog->show(); - dialog->raise(); - dialog->requestActivate(); - + createUnifiedSharingDialog(folder->accountState()->account(), localPath, resolvedFileId, remotePath); return; } @@ -939,6 +905,46 @@ void Systray::createFileDetailsDialog(const QString &localPath, const QString &f } } +void Systray::createUnifiedSharingDialog(const AccountPtr &account, const QString &localPath, const QString &fileId, const QString &remotePath) +{ + if (!_trayEngine) { + qCWarning(lcSystray) << "Could not open unified sharing dialog for" << localPath << "as no tray engine was available"; + return; + } + + const QVariantMap initialProperties{ + {"account", QVariant::fromValue(account)}, + {"localPath", localPath}, + {"fileId", fileId}, + {"remotePath", remotePath}, + }; + + QQmlComponent fileDetailsDialog(trayEngine(), "com.nextcloud.desktopclient.sharing"_L1, "ShareDialog"_L1); + + if (fileDetailsDialog.isError()) { + qCWarning(lcSystray) << fileDetailsDialog.errorString(); + return; + } + + const auto createdDialog = fileDetailsDialog.createWithInitialProperties(initialProperties); + const auto dialog = qobject_cast(createdDialog); + + if (!dialog) { + qCWarning(lcSystray) << "Unified sharing dialog resulted in creation of object that was not a window!"; + return; + } + + _fileDetailDialogs.append(dialog); + +#if defined(Q_OS_MACOS) + configureMacOSExpandedQuickWindow(dialog); +#endif + + dialog->show(); + dialog->raise(); + dialog->requestActivate(); +} + void Systray::createShareDialog(const QString &localPath, const QString &fileId) { createFileDetailsDialog(localPath, fileId); @@ -963,6 +969,23 @@ void Systray::slotShowFileProviderFileActionsDialog(const QString &fileId, const createFileProviderFileActionsDialog(fileId, localPath, remoteItemPath, fileProviderDomainIdentifier); } +void Systray::slotShowFileProviderUnifiedSharingDialog(const QString &fileId, const QString &localPath, const QString &remoteItemPath, const QString &fileProviderDomainIdentifier) +{ + if (raiseFileDetailDialogs(localPath)) { + qCDebug(lcSystray) << "Reopening an existing unified sharing dialog for" << localPath; + return; + } + + const auto accountState = AccountManager::instance()->accountFromFileProviderDomainIdentifier(fileProviderDomainIdentifier); + if (!accountState) { + qCWarning(lcSystray) << "Could not open unified sharing dialog for" << localPath + << "no account found for domain identifier" << fileProviderDomainIdentifier; + return; + } + + createUnifiedSharingDialog(accountState->account(), localPath, fileId, remoteItemPath); +} + void Systray::createFileProviderFileActionsDialog(const QString &fileId, const QString &localPath, const QString &remoteItemPath, const QString &fileProviderDomainIdentifier) { if (!_trayEngine) { diff --git a/src/gui/systray.h b/src/gui/systray.h index 6b3a19f701622..32a8936c676fd 100644 --- a/src/gui/systray.h +++ b/src/gui/systray.h @@ -169,6 +169,15 @@ public slots: void slotShowFileProviderFileActionsDialog(const QString &fileId, const QString &localPath, const QString &remoteItemPath, const QString &fileProviderDomainIdentifier); + /** + * @brief Opens unified sharing for a file provider item. + * @param fileId The numeric server file id, equal to the WebDAV `fileid` property. + * @param localPath The local and absolute path of the item. + * @param remoteItemPath The server-side path of the item. + * @param fileProviderDomainIdentifier The file provider domain identifier for the account that owns the item. + */ + void slotShowFileProviderUnifiedSharingDialog(const QString &fileId, const QString &localPath, const QString &remoteItemPath, const QString &fileProviderDomainIdentifier); + #endif void presentShareViewInTray(const QString &localPath); @@ -190,6 +199,11 @@ private slots: void setupContextMenu(); void createFileDetailsDialog(const QString &localPath, const QString &fileId = {}); + + /** + * @brief Creates the unified sharing QML dialog with already-resolved item and account data. + */ + void createUnifiedSharingDialog(const AccountPtr &account, const QString &localPath, const QString &fileId, const QString &remotePath); void createFileActionsDialog(const QString &localPath); #ifdef BUILD_FILE_PROVIDER_MODULE