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/resources.qrc b/resources.qrc index a9e58cda6e896..857e2ddb25405 100644 --- a/resources.qrc +++ b/resources.qrc @@ -40,6 +40,8 @@ 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/ActivityFileMenuButton.qml src/gui/activity/qml/ActivityItemContent.qml src/gui/activity/qml/TalkReplyTextField.qml src/gui/tray/CallNotificationDialog.qml @@ -70,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/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/CMakeLists.txt b/src/gui/CMakeLists.txt index eb40bf61f527c..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) @@ -577,9 +578,6 @@ target_link_libraries(nextcloudCore Qt::QuickWidgets KF6::Archive KDAB::kdsingleapplication - - nextcloudGuiSearch - nextcloudGuiSearchplugin ) if(KF6GuiAddons_FOUND) @@ -691,7 +689,13 @@ set_target_properties(nextcloud PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${BIN_OUTPUT_DIRECTORY} ) -target_link_libraries(nextcloud PRIVATE nextcloudCore) +target_link_libraries(nextcloud PRIVATE + nextcloudGuiSearch + nextcloudGuiSearchplugin + nextcloudGuiSharing + nextcloudGuiSharingplugin + nextcloudCore +) if(TARGET PkgConfig::CLOUDPROVIDERS) message("Building with libcloudproviderssupport") diff --git a/src/gui/activity/qml/ActivityFileMenu.qml b/src/gui/activity/qml/ActivityFileMenu.qml new file mode 100644 index 0000000000000..465648d92e05b --- /dev/null +++ b/src/gui/activity/qml/ActivityFileMenu.qml @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls +import "../../tray" + +Menu { + 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 + + property Action fileDetailsAction: Action { + id: fileDetailsAction + objectName: "fileDetailsAction" + text: qsTr("File details") + onTriggered: root.fileDetailsRequested(root.filePath) + } + + property Action fileActionsAction: Action { + id: fileActionsAction + objectName: "fileActionsAction" + text: qsTr("File actions") + onTriggered: root.fileActionsRequested(root.filePath) + } + + MenuItem { + id: fileDetailsMenuItem + action: root.fileDetailsAction + } + + MenuItem { + id: fileActionsMenuItem + action: root.fileActionsAction + visible: root.serverHasIntegration + } +} 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) + } +} diff --git a/src/gui/activity/qml/ActivityItemContent.qml b/src/gui/activity/qml/ActivityItemContent.qml index 822259d97d734..253c164e7c522 100644 --- a/src/gui/activity/qml/ActivityItemContent.qml +++ b/src/gui/activity/qml/ActivityItemContent.qml @@ -170,48 +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() - - AutoSizingMenu { - 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) - } - } + + onFileDetailsRequested: path => Systray.presentShareViewInTray(path) + onFileActionsRequested: path => Systray.presentFileActionsViewInSystray(path) } Button { 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/main.cpp b/src/gui/main.cpp index 04b148f3d39f2..c32df8ce438b1 100644 --- a/src/gui/main.cpp +++ b/src/gui/main.cpp @@ -32,6 +32,10 @@ #include #include #include +#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/ocsjob.cpp b/src/gui/ocsjob.cpp index 9f4d918ce3a5b..0b975340f31c0 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) @@ -32,7 +35,12 @@ 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::setJsonBody(const QJsonObject &body) +{ + _jsonBody = QJsonDocument{body}.toJson(QJsonDocument::Compact); } void OcsJob::addPassStatusCode(int code) @@ -40,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); @@ -52,19 +65,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; } @@ -72,25 +88,29 @@ static QUrlQuery percentEncodeQueryItems( void OcsJob::start() { addRawHeader("Ocs-APIREQUEST", "true"); - addRawHeader("Content-Type", "application/x-www-form-urlencoded"); 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. - QByteArray postData; - for (auto it = std::cbegin(_params); it != std::cend(_params); ++it) { - if (!postData.isEmpty()) { - postData.append("&"); + 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()) { + postData.append("&"); + } + postData.append(QUrl::toPercentEncoding(name)); + postData.append("="); + postData.append(QUrl::toPercentEncoding(value)); } - postData.append(QUrl::toPercentEncoding(it.key())); - postData.append("="); - postData.append(QUrl::toPercentEncoding(it.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 bcb060c491c5a..0535898d9a3ec 100644 --- a/src/gui/ocsjob.h +++ b/src/gui/ocsjob.h @@ -10,9 +10,12 @@ #include "accountfwd.h" #include "abstractnetworkjob.h" -#include -#include +#include +#include #include +#include + +#include #define OCS_SUCCESS_STATUS_CODE 100 // Apparently the v2.php URLs can return that @@ -23,6 +26,7 @@ #define OCS_NOT_MODIFIED_STATUS_CODE_V2 304 class QJsonDocument; +class QJsonObject; namespace OCC { @@ -59,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 @@ -75,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. @@ -142,7 +152,8 @@ private slots: private: QByteArray _verb; - QHash _params; + QList> _params; + std::optional _jsonBody; QVector _passStatusCodes; QNetworkRequest _request; }; diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index 6e6f60619e625..c863c9c7a97c7 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" @@ -134,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 @@ -167,7 +167,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 +176,6 @@ ownCloudGui::ownCloudGui(Application *parent) qRegisterMetaType("ActivityListModel*"); qRegisterMetaType("SyncStatusSummary*"); - qRegisterMetaType("UnifiedSearchResultsListModel*"); qRegisterMetaType("UserStatus"); qRegisterMetaType("SharePtr"); qRegisterMetaType("ShareePtr"); @@ -783,9 +781,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/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/src/gui/sharing/CMakeLists.txt b/src/gui/sharing/CMakeLists.txt new file mode 100644 index 0000000000000..160fe080fe910 --- /dev/null +++ b/src/gui/sharing/CMakeLists.txt @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: GPL-2.0-or-later + +add_library(nextcloudGuiSharing STATIC) + +set_source_files_properties(RecipientIcon.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE) + +target_sources(nextcloudGuiSharing + PRIVATE + abstractsharemodel.h + abstractsharemodel.cpp + sharingconstants.h + unifiedsharingrequest.h + unifiedsharingrequest.cpp + createsharejob.h + 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 + permissionmodel.cpp + property.h + property.cpp + propertymodel.h + propertymodel.cpp + recipient.h + recipient.cpp + recipienticonutils.h + recipienticonutils.cpp + recipientmodel.h + recipientmodel.cpp + recipientsearchmodel.h + recipientsearchmodel.cpp + share.h + share.cpp + sharingcontroller.h + sharingcontroller.cpp + unifiedsharelistmodel.h + unifiedsharelistmodel.cpp +) + +ecm_add_qml_module(nextcloudGuiSharing + URI com.nextcloud.desktopclient.sharing + GENERATE_PLUGIN_SOURCE + QML_FILES + FieldDelegate.qml + RecipientIcon.qml + RecipientSearchField.qml + ShareActionRow.qml + ShareDetailsPage.qml + ShareRow.qml + ShareSectionHeader.qml + ShareDialog.qml +) + +target_link_libraries(nextcloudGuiSharing PRIVATE nextcloudCore) diff --git a/src/gui/sharing/FieldDelegate.qml b/src/gui/sharing/FieldDelegate.qml new file mode 100644 index 0000000000000..110018293226a --- /dev/null +++ b/src/gui/sharing/FieldDelegate.qml @@ -0,0 +1,218 @@ +/* + * 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 +import "qrc:/qml/src/gui/wizard/qml" + +Loader { + id: instantiator + required property var model + + 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) + } + + function commit(): void { + if (item && item.commit) { + item.commit() + } + } + + 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 + } + + Component { + id: booleanComponent + + SwitchDelegate { + text: instantiator.labelText() + checked: instantiator.model.value === "true" + + onToggled: { + instantiator.submit(checked ? "true" : "false") + } + } + } + + Component { + id: dateComponent + + ColumnLayout { + id: dateColumn + + function commit(): void { + if (dateField.valid) { + instantiator.submit(dateField.text) + } + } + + Label { + text: instantiator.labelText() + } + WizardTextField { + 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: { + dateColumn.commit() + } + } + 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 + } + } + } + + Component { + id: enumComponent + + ColumnLayout { + Label { + text: instantiator.labelText() + } + WizardComboBox { + id: enumSelector + + Layout.fillWidth: true + model: instantiator.model.validValues.map(value => ({ + "name": value, + "isSelected": value === instantiator.model.value + })) + textRole: "name" + currentIndex: instantiator.model.validValues.indexOf(instantiator.model.value) + + onActivated: index => { + instantiator.submit(instantiator.model.validValues[index]) + } + } + } + } + + Component { + 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: { + passwordColumn.commit() + } + } + } + } + + Component { + id: stringComponent + + ColumnLayout { + id: stringColumn + + function commit(): void { + if (stringField.valid) { + instantiator.submit(stringField.text) + } + } + + Label { + text: instantiator.labelText() + } + WizardTextField { + id: stringField + + Layout.fillWidth: true + text: instantiator.model.value ?? "" + 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) + + onEditingFinished: { + stringColumn.commit() + } + } + 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: unknownComponent + + ColumnLayout { + Label { + 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/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 new file mode 100644 index 0000000000000..d895a33961e30 --- /dev/null +++ b/src/gui/sharing/RecipientSearchField.qml @@ -0,0 +1,265 @@ +/* + * 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 +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. +WizardTextField { + id: root + + signal recipientSelected(string recipientType, string recipientValue, string recipientInstance) + + required property var account + required property string shareId + property RecipientSearchModel recipientModel: RecipientSearchModel { + account: root.account + query: root.text + shareId: root.shareId + } + + 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 + 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 + } + Image { + id: busyIndicator + + anchors { + top: parent.top + left: parent.left + bottom: parent.bottom + } + + width: height + 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 + + RotationAnimator { + target: busyIndicator + running: busyIndicator.visible + from: 0 + to: 360 + loops: Animation.Infinite + duration: Style.shortAnimationDuration * 15 + } + } + + 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: Style.extraSmallSpacing + currentIndex: -1 + interactive: true + highlightFollowsCurrentItem: true + highlightMoveDuration: 0 + highlightResizeDuration: 0 + highlightRangeMode: ListView.ApplyRange + preferredHighlightBegin: 0 + preferredHighlightEnd: suggestionsScrollView.height + + onCountChanged: root.triggerSuggestionsVisibility() + + model: root.recipientModel + delegate: WizardItemDelegate { + id: recipientDelegate + required property int index + + required property string type + required property string value + required property string displayName + required property var instance + required property string iconSvgUrl + required property string iconLight + required property string iconDark + + width: ListView.view.width + highlighted: ListView.isCurrentItem + + contentItem: RowLayout { + spacing: Style.standardSpacing + + Image { + 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: Style.wizardSecondaryText + elide: Text.ElideRight + } + } + + // enabled: model.type !== NC.recipient.LookupServerSearchResults + // hoverEnabled: model.type !== NC.recipient.LookupServerSearchResults + + function selectSharee() { + root.recipientSelected(recipientDelegate.type, recipientDelegate.value, recipientDelegate.instance || "") + 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/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/ShareDetailsPage.qml b/src/gui/sharing/ShareDetailsPage.qml new file mode 100644 index 0000000000000..c532e142eec08 --- /dev/null +++ b/src/gui/sharing/ShareDetailsPage.qml @@ -0,0 +1,398 @@ +/* + * 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" +import "qrc:/qml/src/gui/wizard/qml" + +ColumnLayout { + id: root + + required property SharingController sharingController + required property Share share + property string recipientOperationError: "" + property string permissionUpdateError: "" + property string propertyUpdateError: "" + readonly property bool shareIsActive: share.state === Share.Active + + signal commitRequested + + spacing: Style.standardSpacing + + function copyToClipboard(value: string): void { + clipboardHelper.text = value + clipboardHelper.selectAll() + clipboardHelper.copy() + clipboardHelper.clear() + } + + function commitPendingChanges(): void { + commitRequested() + } + + TextEdit { + id: clipboardHelper + visible: false + } + + RecipientSearchField { + id: recipientSearch + Layout.fillWidth: true + + account: root.sharingController.account + shareId: root.share.id + visible: root.shareIsActive + + onRecipientSelected: (recipientType, recipientValue, recipientInstance) => { + root.recipientOperationError = "" + root.sharingController.addRecipient(root.share, recipientType, recipientValue, recipientInstance) + } + } + + ErrorBox { + Layout.fillWidth: true + + text: root.recipientOperationError + visible: text.length > 0 + } + + ListView { + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + interactive: false + spacing: Style.extraSmallSpacing + model: RecipientModel { + share: root.share + } + + delegate: WizardItemDelegate { + id: recipientDelegate + + required property var model + + width: ListView.view.width + 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 + color: Style.wizardPrimaryText + 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: 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.shareIsActive && recipientDelegate.model.secretUrl !== "" + enabled: visible + + Accessible.name: qsTr("Copy recipient link") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + + onClicked: root.copyToClipboard(recipientDelegate.model.secretUrl) + } + + WizardButton { + Layout.preferredWidth: implicitHeight + leftPadding: 0 + rightPadding: 0 + text: "" + iconSource: "image://svgimage-custom-color/change.svg/" + palette.buttonText + visible: root.shareIsActive && 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 || "") + } + } + + WizardButton { + Layout.preferredWidth: implicitHeight + leftPadding: 0 + rightPadding: 0 + text: "" + iconSource: "image://svgimage-custom-color/delete.svg/" + palette.buttonText + visible: root.shareIsActive + + 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 || "") + } + } + } + } + } + + WizardComboBox { + id: permissionPresetSelector + Layout.fillWidth: true + + readonly property var presetValues: ["OC\\Core\\Sharing\\Permission\\ViewSharePermissionPreset", "OC\\Core\\Sharing\\Permission\\EditSharePermissionPreset", ""] + readonly property int selectedPresetIndex: { + const preset = root.share.permissionPreset + if (preset.endsWith("\\ViewSharePermissionPreset")) { + return 0 + } + if (preset.endsWith("\\EditSharePermissionPreset")) { + return 1 + } + 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] + if (preset) { + root.permissionUpdateError = "" + root.sharingController.setPermissionPreset(root.share, preset) + } + } + } + + ListView { + Layout.fillWidth: true + Layout.preferredHeight: visible ? contentHeight : 0 + + interactive: false + visible: permissionPresetSelector.currentIndex === 2 + model: PermissionModel { + share: root.share + } + + delegate: SwitchDelegate { + required property var model + + width: ListView.view.width + text: model.label + checked: model.enabled + + onToggled: { + root.permissionUpdateError = "" + root.sharingController.setPermission(root.share, model.className, checked) + } + } + } + + ErrorBox { + Layout.fillWidth: true + + text: root.permissionUpdateError + visible: text.length > 0 + } + + Label { + Layout.fillWidth: true + Layout.topMargin: Style.standardSpacing + + text: qsTr("Sharing settings") + font.weight: Font.DemiBold + visible: propertyList.count > 0 || advancedPropertyList.count > 0 + } + + ListView { + id: propertyList + + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + interactive: false + spacing: Style.standardSpacing + + model: PropertyModel { + share: root.share + } + + delegate: FieldDelegate { + id: propertyDelegate + + width: propertyList.width + height: item ? item.implicitHeight : 0 + + onValueEdited: (propertyClass, value) => { + root.propertyUpdateError = "" + root.sharingController.setProperty(root.share, propertyClass, value) + } + + Connections { + target: root + function onCommitRequested() { + propertyDelegate.commit() + } + } + } + } + + 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 { + id: advancedPropertyDelegate + + width: advancedPropertyList.width + height: item ? item.implicitHeight : 0 + + onValueEdited: (propertyClass, value) => { + root.propertyUpdateError = "" + root.sharingController.setProperty(root.share, propertyClass, value) + } + + Connections { + target: root + function onCommitRequested() { + advancedPropertyDelegate.commit() + } + } + } + } + + ErrorBox { + Layout.fillWidth: true + + text: root.propertyUpdateError + visible: text.length > 0 + } + + Connections { + target: root.sharingController + + function onRecipientAdded(share) { + if (share === root.share) { + recipientSearch.clear() + root.recipientOperationError = "" + } + } + + function onRecipientAdditionFailed(share, error) { + if (share === root.share) { + root.recipientOperationError = error + } + } + + function onRecipientRemoved(share) { + if (share === root.share) { + root.recipientOperationError = "" + } + } + + function onRecipientRemovalFailed(share, error) { + if (share === root.share) { + root.recipientOperationError = error + } + } + + 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 + } + } + + function onPermissionUpdateFailed(share, error) { + if (share === root.share) { + root.permissionUpdateError = error + } + } + } +} diff --git a/src/gui/sharing/ShareDialog.qml b/src/gui/sharing/ShareDialog.qml new file mode 100644 index 0000000000000..1311b85ff5260 --- /dev/null +++ b/src/gui/sharing/ShareDialog.qml @@ -0,0 +1,606 @@ +/* + * 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 +import "qrc:/qml/src/gui" +import "qrc:/qml/src/gui/tray" +import "qrc:/qml/src/gui/wizard/qml" + +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] + property string fileId: "" + property string remotePath: "" + property Share selectedShare: null + property Share sharePendingDeletion: null + property bool activatingShare: false + property string shareActivationError: "" + + signal clearNewShareRecipientSearch + + 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 = dialog.currentShares() + if (dialog.selectedShare && shares.indexOf(dialog.selectedShare) !== -1) { + return + } + + 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) { + 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") + } + + 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 "" + } + + function copyToClipboard(value: string): void { + clipboardHelper.text = value + clipboardHelper.selectAll() + clipboardHelper.copy() + clipboardHelper.clear() + } + + onSelectedShareChanged: { + dialog.activatingShare = false + dialog.shareActivationError = "" + } + + SharingController { + id: sharingController + } + + UnifiedShareListModel { + id: shareListModel + sharingController: sharingController + } + + TextEdit { + id: clipboardHelper + visible: false + } + + Shortcut { + sequences: [StandardKey.Cancel] + onActivated: dialog.close() + } + + Component.onCompleted: { + sharingController.account = dialog.account + sharingController.initialize(dialog.fileId) + } + + Connections { + target: sharingController + + function onSharesChanged() { + dialog.reconcileSelectedShare() + } + + function onShareCreated(share) { + dialog.clearNewShareRecipientSearch() + if (!share.publicLink) { + dialog.selectedShare = share + } + } + + function onShareActivated(share) { + if (share === dialog.selectedShare) { + dialog.activatingShare = false + dialog.selectedShare = null + } + } + + function onShareActivationFailed(share, error) { + 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 { + anchors.fill: parent + anchors.topMargin: Style.standardSpacing + spacing: 0 + + ColumnLayout { + Layout.leftMargin: Style.sharingDialogWindowMargin + Layout.rightMargin: Style.sharingDialogWindowMargin + Layout.bottomMargin: Style.standardSpacing + spacing: Style.smallSpacing + + 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 { + Layout.fillWidth: true + Layout.preferredHeight: Style.normalBorderWidth + color: Style.sharingDialogSeparatorColor + } + + EnforcedPlainTextLabel { + Layout.fillWidth: true + Layout.leftMargin: Style.sharingDialogWindowMargin + Layout.rightMargin: Style.sharingDialogWindowMargin + Layout.topMargin: Style.standardSpacing + Layout.preferredHeight: Style.sharingDialogPaneHeaderHeight + + text: dialog.shareTitle(dialog.selectedShare) + font.pointSize: Style.subheaderFontPtSize + font.weight: Font.DemiBold + 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 { + id: shareListPane + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: Style.sharingDialogWindowMargin + Layout.rightMargin: Style.sharingDialogWindowMargin + Layout.topMargin: Style.standardSpacing + Layout.bottomMargin: Style.standardSpacing + spacing: Style.standardSpacing + + RecipientSearchField { + id: newShareRecipientSearch + + Layout.fillWidth: true + enabled: !sharingController.creatingShare && dialog.fileId.length > 0 + account: dialog.account + shareId: "" + + onRecipientSelected: (recipientType, recipientValue, recipientInstance) => { + sharingController.createShareForRecipient(dialog.fileId, recipientType, recipientValue, recipientInstance) + } + + 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 + visible: text.length > 0 + } + + ErrorBox { + Layout.fillWidth: true + text: sharingController.shareDestructionError + visible: text.length > 0 + } + + ErrorBox { + Layout.fillWidth: true + text: sharingController.internalLinkError + visible: text.length > 0 + } + + ErrorBox { + Layout.fillWidth: true + text: dialog.shareActivationError + visible: !dialog.selectedShare && text.length > 0 + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + 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 + } + + Component { + id: sectionHeaderComponent + + ShareSectionHeader { + title: dialog.sectionTitle(rowLoader.section) + description: dialog.sectionDescription(rowLoader.section) + } + } + + Component { + id: internalLinkComponent + + 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 + + onActionRequested: sharingController.requestInternalLink(dialog.remotePath, dialog.fileId) + } + } + + 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 { + share: rowLoader.share + recipientNames: rowLoader.recipientNames + publicLink: rowLoader.publicLink + publicLinkUrl: rowLoader.publicLinkUrl + + onCopyRequested: dialog.copyToClipboard(publicLinkUrl) + onConfigureRequested: dialog.selectedShare = share + } + } + } + } + + DetachedVerticalScrollBar { + anchors.top: parent.top + anchors.right: parent.right + anchors.bottom: parent.bottom + flickable: shareListView + } + } + } + } + + WizardDialogFrame { + id: shareDetailsFrame + + footerSeparatorVisible: dialog.selectedShare !== null + footerTopPadding: Style.standardSpacing + + ColumnLayout { + anchors.fill: parent + spacing: Style.wizardSectionSpacing + + ScrollView { + id: shareDetailsScrollView + + Layout.fillWidth: true + 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.policy: ScrollBar.AlwaysOff + } + + ErrorBox { + Layout.fillWidth: true + Layout.leftMargin: shareDetailsFrame.windowMargin + Layout.rightMargin: shareDetailsFrame.windowMargin + text: dialog.shareActivationError + 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 + 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 + } + } + + DetachedVerticalScrollBar { + anchors.top: parent.top + anchors.right: parent.right + anchors.bottom: parent.bottom + flickable: shareDetailsScrollView.contentItem + } + + footer: [ + 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: { + dialog.sharePendingDeletion = dialog.selectedShare + deleteShareConfirmation.open() + } + }, + Item { + Layout.fillWidth: true + }, + 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("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) + } + } + ] + } + } + } + + 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 { + WizardButton { + primary: true + text: qsTr("Delete") + DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole + onClicked: deleteShareConfirmation.accept() + } + + WizardButton { + text: qsTr("Cancel") + DialogButtonBox.buttonRole: DialogButtonBox.RejectRole + onClicked: deleteShareConfirmation.reject() + } + } + + onAccepted: { + 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/ShareRow.qml b/src/gui/sharing/ShareRow.qml new file mode 100644 index 0000000000000..9d2ca1251b319 --- /dev/null +++ b/src/gui/sharing/ShareRow.qml @@ -0,0 +1,101 @@ +/* + * 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 +import "qrc:/qml/src/gui/wizard/qml" + +WizardItemDelegate { + id: root + + 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 + + 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.publicLink ? qsTr("Share link") : root.recipientNames || (root.pending ? qsTr("Unfinished share") : qsTr("Share")) + color: Style.wizardPrimaryText + elide: Text.ElideRight + } + + Label { + Layout.fillWidth: true + 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 + rightPadding: 0 + text: "" + iconSource: "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/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 + } +} 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/addrecipientjob.cpp b/src/gui/sharing/addrecipientjob.cpp new file mode 100644 index 0000000000000..7d2e82ac84a65 --- /dev/null +++ b/src/gui/sharing/addrecipientjob.cpp @@ -0,0 +1,42 @@ +/* + * 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 +{ + +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, + {.body = addRecipientBody(recipientTypeClass, recipientValue, instance)}} +{ +} + +} diff --git a/src/gui/sharing/addrecipientjob.h b/src/gui/sharing/addrecipientjob.h new file mode 100644 index 0000000000000..d5101d3d4c8a5 --- /dev/null +++ b/src/gui/sharing/addrecipientjob.h @@ -0,0 +1,38 @@ +/* + * 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 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: + /** + * @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 new file mode 100644 index 0000000000000..13fb80a4d808d --- /dev/null +++ b/src/gui/sharing/addsourcejob.cpp @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "addsourcejob.h" + +#include "share.h" +#include "sharingconstants.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, + {.body = QJsonObject{{"class"_L1, SourceTypeClasses::node}, {"value"_L1, fileId}}}} +{ +} + +} diff --git a/src/gui/sharing/addsourcejob.h b/src/gui/sharing/addsourcejob.h new file mode 100644 index 0000000000000..378a7fe7aa474 --- /dev/null +++ b/src/gui/sharing/addsourcejob.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 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 new file mode 100644 index 0000000000000..10431b8acde56 --- /dev/null +++ b/src/gui/sharing/createsharejob.cpp @@ -0,0 +1,26 @@ +/* + * 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, + {.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 new file mode 100644 index 0000000000000..b8a04ea8b4c92 --- /dev/null +++ b/src/gui/sharing/createsharejob.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; + +/** + * @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 new file mode 100644 index 0000000000000..9b244696e692d --- /dev/null +++ b/src/gui/sharing/destroysharejob.cpp @@ -0,0 +1,21 @@ +/* + * 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, + {.passStatusCodes = QList{204}}} +{ +} + +} diff --git a/src/gui/sharing/destroysharejob.h b/src/gui/sharing/destroysharejob.h new file mode 100644 index 0000000000000..e4ec6e75c5a73 --- /dev/null +++ b/src/gui/sharing/destroysharejob.h @@ -0,0 +1,26 @@ +/* + * 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 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/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/permissionmodel.cpp b/src/gui/sharing/permissionmodel.cpp new file mode 100644 index 0000000000000..e360be2c95207 --- /dev/null +++ b/src/gui/sharing/permissionmodel.cpp @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "permissionmodel.h" + +#include "share.h" +#include "permission.h" + +using namespace Qt::StringLiterals; +using namespace OCC; +using namespace OCC::Gui::Sharing; + +PermissionModel::PermissionModel(QObject *parent) + : AbstractShareModel{parent} +{} + +int PermissionModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid() || !_share) { + return 0; + } + + return _share->permissions().size(); +} + +QVariant PermissionModel::data(const QModelIndex &index, int role) const +{ + if (!_share || !checkIndex(index, CheckIndexOption::IndexIsValid | CheckIndexOption::ParentIsInvalid)) { + 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 {}; + } +} + +QHash PermissionModel::roleNames() const +{ + return { + { LabelRole, "label"_ba}, + { ClassNameRole, "className"_ba}, + { PlaceholderRole, "hint"_ba}, + { EnabledRole, "enabled"_ba}, + }; +}; + +void PermissionModel::setShare(Share *share) +{ + if (_share == share) { + return; + } + + QObject::disconnect(_permissionsChangedConnection); + AbstractShareModel::setShare(share); + if (!_share) { + return; + } + + _permissionsChangedConnection = connect(_share, &Share::permissionsChanged, this, [this]() -> void { + beginResetModel(); + endResetModel(); + }); +} diff --git a/src/gui/sharing/permissionmodel.h b/src/gui/sharing/permissionmodel.h new file mode 100644 index 0000000000000..e133f9223b738 --- /dev/null +++ b/src/gui/sharing/permissionmodel.h @@ -0,0 +1,39 @@ +/* + * 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 PermissionModel : public AbstractShareModel +{ + Q_OBJECT + QML_ELEMENT + +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; + QHash roleNames() const override; + + void setShare(Share* share) override; + +private: + QMetaObject::Connection _permissionsChangedConnection; +}; + +} diff --git a/src/gui/sharing/property.cpp b/src/gui/sharing/property.cpp new file mode 100644 index 0000000000000..f503c105cab0b --- /dev/null +++ b/src/gui/sharing/property.cpp @@ -0,0 +1,120 @@ + +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "property.h" + +#include +#include +#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->_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; +} + +Property::Property(QObject *parent) + : QObject{parent} +{ +} + +QString Property::className() const +{ + return _className; +} + +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; +} + +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..e1e9e51d0573b --- /dev/null +++ b/src/gui/sharing/property.h @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include + +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: + explicit Property(QObject *parent = nullptr); + + QString _className; + QString _displayName; + 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 new file mode 100644 index 0000000000000..075d1385a1a7b --- /dev/null +++ b/src/gui/sharing/propertymodel.cpp @@ -0,0 +1,132 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "propertymodel.h" + +#include "share.h" +#include "property.h" + +#include + +using namespace Qt::StringLiterals; +using namespace OCC; +using namespace OCC::Gui::Sharing; + +PropertyModel::PropertyModel(QObject *parent) + : AbstractShareModel{parent} +{} + +int PropertyModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + + return _properties.size(); +} + +QVariant PropertyModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= _properties.size()) { + return {}; + } + + const auto property = _properties.at(index.row()); + + switch (role) { + case LabelRole: + return property->displayName(); + case PropertyRole: + return property->className(); + 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 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 {}; + } +} + +QHash PropertyModel::roleNames() const +{ + return { + { LabelRole, "label"_ba}, + { PropertyRole, "property"_ba}, + { 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 { + 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 new file mode 100644 index 0000000000000..d66611dd7d7fa --- /dev/null +++ b/src/gui/sharing/propertymodel.h @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#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 { + LabelRole = Qt::UserRole, + PropertyRole, + TypeRole, + PlaceholderRole, + ValueRole, + RequiredRole, + AdvancedRole, + ValidValuesRole, + MinimumRole, + MaximumRole, + }; + + enum FieldTypes { + Unknown, + Boolean, + Date, + Enum, + Password, + String, + }; + 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; + 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: + void resetProperties(); + + QList> _properties; + bool _advanced = false; +}; + +} diff --git a/src/gui/sharing/recipient.cpp b/src/gui/sharing/recipient.cpp new file mode 100644 index 0000000000000..47ba8feee9b26 --- /dev/null +++ b/src/gui/sharing/recipient.cpp @@ -0,0 +1,111 @@ +/* + * 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(); + 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(); + + 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(); + } + + recipient->_initiatorDisplayName = json.value("initiator"_L1).toObject().value("display_name"_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; +} + +const std::optional &Recipient::instance() const +{ + return _instance; +} + +QString Recipient::instanceString() const +{ + return _instance.value_or(QString{}); +} + +QString Recipient::iconSvg() const +{ + return _iconSvg; +} + +QString Recipient::iconLight() const +{ + return _iconLight; +} + +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; +} + +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 new file mode 100644 index 0000000000000..9f9c7e895655c --- /dev/null +++ b/src/gui/sharing/recipient.h @@ -0,0 +1,74 @@ +/* + * 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 { + +/** + * @brief A recipient attached to a unified share. + */ +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) + 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. */ + [[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; + [[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. */ + [[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; + [[nodiscard]] QString secretUrlString() const; + /** @brief Returns the user-facing name of the user who added the recipient. */ + [[nodiscard]] QString initiatorDisplayName() const; + +private: + explicit Recipient(QObject *parent = nullptr); + + QString _className; + QString _displayName; + QString _value; + std::optional _instance; + QString _iconSvg; + QString _iconLight; + QString _iconDark; + bool _secretUpdatable = false; + std::optional _secretValue; + std::optional _secretUrl; + QString _initiatorDisplayName; +}; + +} 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 new file mode 100644 index 0000000000000..abb0a3ccf7d35 --- /dev/null +++ b/src/gui/sharing/recipientmodel.cpp @@ -0,0 +1,94 @@ +/* + * 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" +#include "recipienticonutils.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; + } + + return _share->recipients().size(); +} + +QVariant RecipientModel::data(const QModelIndex &index, int role) const +{ + Q_ASSERT(checkIndex(index, CheckIndexOption::IndexIsValid | CheckIndexOption::ParentIsInvalid)); + + 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(); + 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(); + case SecretUpdatableRole: + return recipient->secretUpdatable(); + case SecretValueRole: + return recipient->secretValue().value_or(QString{}); + case SecretUrlRole: + return recipient->secretUrl().value_or(QString{}); + case InitiatorDisplayNameRole: + return recipient->initiatorDisplayName(); + default: + return {}; + } +} + +QHash RecipientModel::roleNames() const +{ + return { + { LabelRole, "label"_ba}, + { ClassNameRole, "className"_ba}, + { ValueRole, "value"_ba}, + { InstanceRole, "instance"_ba}, + { IconSvgUrlRole, "iconSvgUrl"_ba}, + { IconLightRole, "iconLight"_ba}, + { IconDarkRole, "iconDark"_ba}, + { SecretUpdatableRole, "secretUpdatable"_ba}, + { SecretValueRole, "secretValue"_ba}, + { SecretUrlRole, "secretUrl"_ba}, + { InitiatorDisplayNameRole, "initiatorDisplayName"_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..135801e1210a3 --- /dev/null +++ b/src/gui/sharing/recipientmodel.h @@ -0,0 +1,43 @@ +/* + * 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, + InstanceRole, + IconSvgUrlRole, + IconLightRole, + IconDarkRole, + SecretUpdatableRole, + SecretValueRole, + SecretUrlRole, + InitiatorDisplayNameRole, + }; + + 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/recipientsearchmodel.cpp b/src/gui/sharing/recipientsearchmodel.cpp new file mode 100644 index 0000000000000..3050302808e21 --- /dev/null +++ b/src/gui/sharing/recipientsearchmodel.cpp @@ -0,0 +1,203 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "recipientsearchmodel.h" + +#include +#include + +#include "searchrecipientsjob.h" +#include "recipienticonutils.h" + +Q_LOGGING_CATEGORY(lcSharingRecipientShareModel, "nextcloud.gui.sharing.recipientsearchmodel", QtInfoMsg) + +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 +{ + if (parent.isValid()) { + return 0; + } + + return _searchResults.size(); +} + +QVariant RecipientSearchModel::data(const QModelIndex &index, int role) const +{ + 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.value("class"_L1).toString(); + case ValueRole: + return item.value("value"_L1).toString(); + case DisplayNameRole: + 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 {}; + } +} + +QHash RecipientSearchModel::roleNames() const +{ + return { + {TypeRole, "type"_ba}, + {ValueRole, "value"_ba}, + {DisplayNameRole, "displayName"_ba}, + {InstanceRole, "instance"_ba}, + {IconSvgUrlRole, "iconSvgUrl"_ba}, + {IconLightRole, "iconLight"_ba}, + {IconDarkRole, "iconDark"_ba}, + }; +}; + +AccountPtr RecipientSearchModel::account() const +{ + return _account; +} + +void RecipientSearchModel::setAccount(AccountPtr account) +{ + if (_account == account) { + return; + } + + beginResetModel(); + _searchTimer.stop(); + setFetchOngoing(false); + _account = account; + _searchResults = {}; + Q_EMIT accountChanged(); + endResetModel(); +} + +QString RecipientSearchModel::query() const +{ + return _query; +} + +void RecipientSearchModel::setQuery(const QString &query) +{ + if (!_account) { + return; + } + + if (_query == query) { + return; + } + + qCDebug(lcSharingRecipientShareModel) << "query set to" << query; + _query = query; + Q_EMIT queryChanged(); + + if (_query.isEmpty()) { + _searchTimer.stop(); + setFetchOngoing(false); + beginResetModel(); + _searchResults = {}; + endResetModel(); + return; + } + + _searchTimer.start(); +} + +QString RecipientSearchModel::shareId() const +{ + return _shareId; +} + +void RecipientSearchModel::setShareId(const QString &shareId) +{ + if (_shareId == shareId) { + return; + } + + _shareId = shareId; + setFetchOngoing(false); + beginResetModel(); + _searchResults = {}; + endResetModel(); + Q_EMIT shareIdChanged(); + if (!_query.isEmpty()) { + _searchTimer.start(); + } +} + +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, + 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; + } + + 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 new file mode 100644 index 0000000000000..48686556ec0b5 --- /dev/null +++ b/src/gui/sharing/recipientsearchmodel.h @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include + +#include +#include + +#include "accountfwd.h" + +namespace OCC::Gui::Sharing +{ + +class RecipientSearchModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + + 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 { + TypeRole = Qt::UserRole, + ValueRole, + DisplayNameRole, + InstanceRole, + IconSvgUrlRole, + IconLightRole, + IconDarkRole, + }; + + 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]] AccountPtr account() const; + void setAccount(AccountPtr account); + + [[nodiscard]] QString query() const; + void setQuery(const QString &query); + + [[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; + QJsonArray _searchResults; + QString _query; + QString _shareId; + QTimer _searchTimer; + bool _fetchOngoing = false; + + void search(); + void setFetchOngoing(bool fetchOngoing); +}; + +} diff --git a/src/gui/sharing/removerecipientjob.cpp b/src/gui/sharing/removerecipientjob.cpp new file mode 100644 index 0000000000000..876ccede37a9f --- /dev/null +++ b/src/gui/sharing/removerecipientjob.cpp @@ -0,0 +1,42 @@ +/* + * 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 +{ + +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, + {.parameters = removeRecipientParameters(recipientTypeClass, recipientValue, instance)}} +{ +} + +} diff --git a/src/gui/sharing/removerecipientjob.h b/src/gui/sharing/removerecipientjob.h new file mode 100644 index 0000000000000..fe163ed5dfd27 --- /dev/null +++ b/src/gui/sharing/removerecipientjob.h @@ -0,0 +1,39 @@ +/* + * 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 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: + /** + * @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..2da8eadea513f --- /dev/null +++ b/src/gui/sharing/removesourcejob.cpp @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "removesourcejob.h" + +#include "share.h" +#include "sharingconstants.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, SourceTypeClasses::node}, {"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 new file mode 100644 index 0000000000000..8f531b3eb2b72 --- /dev/null +++ b/src/gui/sharing/searchrecipientsjob.cpp @@ -0,0 +1,58 @@ +/* + * 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 +{ + +namespace +{ +constexpr auto requestTimeoutMsec = 10 * 1000; + +QList> searchRecipientsParameters(const QString &query, + qint64 offset, + qint64 limit, + const QList &recipientTypeClasses, + const std::optional &shareId) +{ + auto parameters = QList>{ + {"query"_L1, query}, + {"offset"_L1, QString::number(offset)}, + {"limit"_L1, QString::number(limit)}, + }; + for (const auto &recipientTypeClass : recipientTypeClasses) { + 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, + 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, shareId)}} +{ + 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()); + }); +} + +} diff --git a/src/gui/sharing/searchrecipientsjob.h b/src/gui/sharing/searchrecipientsjob.h new file mode 100644 index 0000000000000..464085e31f545 --- /dev/null +++ b/src/gui/sharing/searchrecipientsjob.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 +{ + +/** + * @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: + /** + * @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 + * @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 = {}, + const std::optional &shareId = std::nullopt); + +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 new file mode 100644 index 0000000000000..2e30aa2be6994 --- /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, + {.body = QJsonObject{{"class"_L1, permissionClass}, {"enabled"_L1, enabled}}}} +{ +} + +} diff --git a/src/gui/sharing/setpermissionjob.h b/src/gui/sharing/setpermissionjob.h new file mode 100644 index 0000000000000..aa6b407bf82e3 --- /dev/null +++ b/src/gui/sharing/setpermissionjob.h @@ -0,0 +1,32 @@ +/* + * 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 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 new file mode 100644 index 0000000000000..d531d5250d426 --- /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, + {.body = QJsonObject{{"permissionPresetClass"_L1, permissionPreset}}}} +{ +} + +} diff --git a/src/gui/sharing/setpermissionpresetjob.h b/src/gui/sharing/setpermissionpresetjob.h new file mode 100644 index 0000000000000..c1188a6ee305f --- /dev/null +++ b/src/gui/sharing/setpermissionpresetjob.h @@ -0,0 +1,31 @@ +/* + * 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 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..953de1967a77f --- /dev/null +++ b/src/gui/sharing/setsharestatejob.cpp @@ -0,0 +1,40 @@ +/* + * 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; + case Share::ShareState::Unknown: + break; + } + 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/share.cpp b/src/gui/sharing/share.cpp new file mode 100644 index 0000000000000..0aa3c81e7c323 --- /dev/null +++ b/src/gui/sharing/share.cpp @@ -0,0 +1,205 @@ + +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "share.h" + +#include "sharingconstants.h" + +#include + +#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(); + 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) + :_account{account} +{} + +QString Share::id() const +{ + return _id; +} + +Share::ShareState Share::state() const +{ + return _state; +} + +QString Share::permissionPreset() const +{ + return _permissionPreset; +} + +const QList> &Share::permissions() const +{ + return _permissions; +} + +const QList> &Share::properties() const +{ + return _properties; +} + +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) { + return; + } + + _id = id; + Q_EMIT idChanged(); +} + +void Share::setState(const QString &state) +{ + auto newState = ShareState::Unknown; + + if (state == "draft"_L1) { + newState = ShareState::Draft; + } else 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::setPermissionPreset(const QString &permissionPreset) +{ + if (_permissionPreset == permissionPreset) { + return; + } + + _permissionPreset = permissionPreset; + Q_EMIT permissionPresetChanged(); +} + +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(); +} + +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 new file mode 100644 index 0000000000000..a950eff0b68b7 --- /dev/null +++ b/src/gui/sharing/share.h @@ -0,0 +1,86 @@ +/* + * 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 "recipient.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(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) + 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); + + enum class ShareState { + Unknown, + Draft, + Active, + Deleted + }; + Q_ENUM(ShareState) + + void updateFromJson(const QJsonDocument &json); + + [[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; + /** @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(); + void stateChanged(); + void permissionPresetChanged(); + void permissionsChanged(); + void propertiesChanged(); + void recipientsChanged(); + +private: + AccountPtr _account; + QString _id; + ShareState _state = ShareState::Unknown; + 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); +}; + +} diff --git a/src/gui/sharing/sharingconstants.h b/src/gui/sharing/sharingconstants.h new file mode 100644 index 0000000000000..3d29f46fbdb40 --- /dev/null +++ b/src/gui/sharing/sharingconstants.h @@ -0,0 +1,26 @@ +/* + * 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"}; + +} + +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 new file mode 100644 index 0000000000000..0fe8f3495c48f --- /dev/null +++ b/src/gui/sharing/sharingcontroller.cpp @@ -0,0 +1,772 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "sharingcontroller.h" + +#include +#include +#include + +#include +#include +#include + +#include "addrecipientjob.h" +#include "addsourcejob.h" +#include "createsharejob.h" +#include "destroysharejob.h" +#include "generatesecretjob.h" +#include "getsharesjob.h" +#include "networkjobs.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" + +Q_LOGGING_CATEGORY(lcSharingController, "nextcloud.gui.sharing.sharingcontroller", QtInfoMsg) + +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} +{ +} + +SharingController::~SharingController() +{ + qDeleteAll(_shares); +} + +AccountPtr SharingController::account() const +{ + return _account; +} + +void SharingController::setAccount(AccountPtr account) +{ + if (_account == account) { + return; + } + + _account = account; + Q_EMIT accountChanged(); +} + +const QList &SharingController::shares() const +{ + return _shares; +} + +bool SharingController::creatingShare() const +{ + return _creatingShare; +} + +QString SharingController::shareCreationError() const +{ + return _shareCreationError; +} + +bool SharingController::destroyingShare() const +{ + return _destroyingShare; +} + +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) { + 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, [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(); +} + +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; + } + + if (beginShareCreation(fileId)) { + startShareCreation(fileId, recipientType, recipientValue, 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 false; + } + + if (fileId.isEmpty()) { + qCWarning(lcSharingController) << "attempted to create a new share without a file ID"; + return false; + } + + if (_creatingShare) { + qCDebug(lcSharingController) << "ignoring attempt to create a share while another creation is in progress"; + 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, 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); + return; + } + + share->setParent(this); + 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); + }); + connect(job, &CreateShareJob::networkError, this, [this](const QNetworkReply *reply) { + failShareCreation(reply ? reply->errorString() : tr("Could not create the share.")); + }); + job->start(); +} + +void SharingController::destroyShare(Share *share) +{ + if (!_account) { + qCWarning(lcSharingController) << "attempted to destroy a share without an account set"; + return; + } + + if (!containsShare(share)) { + qCWarning(lcSharingController) << "attempted to destroy a share not owned by this controller"; + 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, guardedShare](const QJsonDocument &, int) { + if (!guardedShare) { + setDestroyingShare(false); + return; + } + + const auto share = guardedShare.data(); + _shares.removeAll(share); + _pendingDraftUpdates.remove(share); + _activationRequested.remove(share); + _activationBlocked.remove(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(); +} + +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"; + return; + } + + if (!containsShare(share)) { + qCWarning(lcSharingController) << "attempted to add a recipient to a share not owned by this controller"; + return; + } + + const auto guardedShare = QPointer{share}; + 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); + } + }); + 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(); +} + +void SharingController::removeRecipient(Share *share, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance) +{ + if (!_account) { + qCWarning(lcSharingController) << "attempted to remove a recipient from a share without an account set"; + return; + } + + if (!containsShare(share)) { + qCWarning(lcSharingController) << "attempted to remove a recipient from a share not owned by this controller"; + return; + } + + 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(); +} + +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) { + qCWarning(lcSharingController) << "attempted to set permission without an account set"; + return; + } + + if (!containsShare(share)) { + qCWarning(lcSharingController) << "attempted to set permission on a share not owned by this controller"; + return; + } + + 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) { + if (*permissionFailureReported) { + return; + } + *permissionFailureReported = true; + markDraftUpdateFailed(guardedShare); + Q_EMIT permissionUpdateFailed(guardedShare, reply ? reply->errorString() : tr("Could not update the permissions.")); + }); + job->start(); +} + +void SharingController::setPermissionPreset(Share *share, const QString &permissionPreset) +{ + if (!_account) { + qCWarning(lcSharingController) << "attempted to set permission preset without an account set"; + return; + } + + if (!containsShare(share)) { + qCWarning(lcSharingController) << "attempted to set a permission preset on a share not owned by this controller"; + return; + } + + if (permissionPreset.isEmpty()) { + qCDebug(lcSharingController) << "ignoring attempt to set a null/empty permission preset"; + return; + } + + 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) { + if (*permissionFailureReported) { + return; + } + *permissionFailureReported = true; + markDraftUpdateFailed(guardedShare); + Q_EMIT permissionUpdateFailed(guardedShare, reply ? reply->errorString() : tr("Could not update the permissions.")); + }); + 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}; + 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(); +} + +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; + } + + 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); + 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); +} + +void SharingController::addSourceAfterCreation(QPointer share, + const QString &fileId, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance, + bool activateAfterCreation) +{ + 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, recipientType, recipientValue, recipientInstance, activateAfterCreation](QPointer updatedShare) { + if (!updatedShare) { + failShareCreation(tr("The newly created share is no longer available.")); + return; + } + + if (recipientType.isEmpty()) { + finishShareCreation(updatedShare, activateAfterCreation); + return; + } + + 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); + }); + 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::addRecipientAfterCreation(QPointer share, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance, + bool activateAfterCreation) +{ + 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, activateAfterCreation](QPointer updatedShare) { + if (!updatedShare) { + failShareCreation(tr("The newly created share is no longer available.")); + return; + } + 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); + }); + 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, 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) +{ + 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::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) { + return; + } + _creatingShare = creatingShare; + Q_EMIT creatingShareChanged(); +} + +void SharingController::setShareCreationError(const QString &error) +{ + if (_shareCreationError == error) { + return; + } + _shareCreationError = 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::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); + _shares = shares; + Q_EMIT sharesChanged(); +} diff --git a/src/gui/sharing/sharingcontroller.h b/src/gui/sharing/sharingcontroller.h new file mode 100644 index 0000000000000..64a84464a7f04 --- /dev/null +++ b/src/gui/sharing/sharingcontroller.h @@ -0,0 +1,244 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include +#include +#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(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) + Q_PROPERTY(bool resolvingInternalLink READ resolvingInternalLink NOTIFY resolvingInternalLinkChanged) + Q_PROPERTY(QString internalLinkError READ internalLinkError NOTIFY internalLinkErrorChanged) + +public: + SharingController(QObject *parent = nullptr); + ~SharingController() override; + + [[nodiscard]] AccountPtr account() const; + void setAccount(AccountPtr account); + + /** @brief Returns all shares associated with the initialized file. */ + [[nodiscard]] const QList &shares() const; + + /** @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. */ + [[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 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. + * + * @param fileId Server file ID used to filter the shares request + */ + Q_INVOKABLE void initialize(const QString &fileId); + + /** + * @brief Creates a draft share for one recipient and attaches the specified file. + * + * 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 + * @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 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); + + /** + * @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 = {}); + + /** + * @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); + 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(); + + /** @brief Emitted when creatingShare changes. */ + void creatingShareChanged(); + + /** @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(); + + /** @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); + + /** @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); + + /** @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); + + /** @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); + + /** @brief Emitted when activating a draft share failed. */ + void shareActivationFailed(Share *share, const QString &error); + +private: + AccountPtr _account; + QList _shares; + bool _creatingShare = false; + 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, + bool activateAfterCreation = false); + void addSourceAfterCreation(QPointer share, + const QString &fileId, + const QString &recipientType, + const QString &recipientValue, + const QString &recipientInstance, + bool activateAfterCreation); + void addRecipientAfterCreation(QPointer share, + const QString &recipientType, + const QString &recipientValue, + 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); + void startShareActivation(Share *share); + void setCreatingShare(bool creatingShare); + 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 new file mode 100644 index 0000000000000..d90e3fa305ff7 --- /dev/null +++ b/src/gui/sharing/unifiedsharelistmodel.cpp @@ -0,0 +1,228 @@ +/* + * 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 "sharingconstants.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; +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) + : 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 : _items.size(); +} + +QVariant UnifiedShareListModel::data(const QModelIndex &index, int role) const +{ + if (!checkIndex(index, CheckIndexOption::IndexIsValid | CheckIndexOption::ParentIsInvalid)) { + return {}; + } + + const auto &item = _items.at(index.row()); + const auto share = item.share.data(); + switch (role) { + case ShareRole: + return QVariant::fromValue(share); + case SectionRole: + 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 {}; + } +} + +QHash UnifiedShareListModel::roleNames() const +{ + return { + {ShareRole, "share"}, + {SectionRole, "section"}, + {RecipientNamesRole, "recipientNames"}, + {ItemTypeRole, "itemType"}, + {PublicLinkRole, "publicLink"}, + {PublicLinkUrlRole, "publicLinkUrl"}, + }; +} + +void UnifiedShareListModel::rebuild() +{ + beginResetModel(); + + for (const auto &connection : std::as_const(_shareConnections)) { + disconnect(connection); + } + _shareConnections.clear(); + + _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; + }); + + _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(); +} + +QString UnifiedShareListModel::sectionForShare(const Share *share) +{ + if (share && share->state() == Share::ShareState::Draft) { + return pendingSection; + } + + if (isExternalShare(share)) { + return externalSection; + } + return isInternalShare(share) ? internalSection : additionalSection; +} + +bool UnifiedShareListModel::isInternalShare(const Share *share) +{ + if (!share || share->recipients().isEmpty()) { + return false; + } + + 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) +{ + if (!share) { + return false; + } + + return std::ranges::any_of(share->recipients(), [](const QPointer &recipient) { + if (!recipient) { + return false; + } + + 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 new file mode 100644 index 0000000000000..efc9b949a8f5e --- /dev/null +++ b/src/gui/sharing/unifiedsharelistmodel.h @@ -0,0 +1,91 @@ +/* + * 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 sharing section headers, actions, and shares in display order. + */ +class UnifiedShareListModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + + 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) + + /** @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 headers, actions, and shares in the root list. */ + [[nodiscard]] int rowCount(const QModelIndex &parent = {}) const override; + + /** @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. */ + [[nodiscard]] QHash roleNames() const override; + +Q_SIGNALS: + /** @brief Emitted when the source controller changes. */ + void sharingControllerChanged(); + +private: + struct Item + { + ItemType type; + QString section; + QPointer share; + }; + + QPointer _sharingController; + QList _items; + QList _shareConnections; + + void rebuild(); + [[nodiscard]] static QString sectionForShare(const Share *share); + [[nodiscard]] static bool isInternalShare(const Share *share); + [[nodiscard]] static bool isExternalShare(const Share *share); +}; + +} diff --git a/src/gui/sharing/unifiedsharingrequest.cpp b/src/gui/sharing/unifiedsharingrequest.cpp new file mode 100644 index 0000000000000..46ec117debdf4 --- /dev/null +++ b/src/gui/sharing/unifiedsharingrequest.cpp @@ -0,0 +1,42 @@ +/* + * 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 UnifiedSharingRequestOptions &options) + : OcsJob{account} +{ + setPath(path); + setVerb(verb); + for (const auto &[name, value] : options.parameters) { + addParam(name, value); + } + if (options.passStatusCodes) { + setPassStatusCodes(*options.passStatusCodes); + } + if (options.body) { + setJsonBody(*options.body); + } +} + +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..e9d8e7e3fc52d --- /dev/null +++ b/src/gui/sharing/unifiedsharingrequest.h @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include +#include +#include + +#include + +#include "ocsjob.h" + +#include "accountfwd.h" + +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. + */ +class UnifiedSharingRequest : public OcsJob +{ + Q_OBJECT + +public: + explicit UnifiedSharingRequest(AccountPtr account, + const QString &path, + const QByteArray &verb, + const UnifiedSharingRequestOptions &options = {}); + + void start() override; + +private: + bool _started = false; +}; + +} diff --git a/src/gui/sharing/updatesharejob.cpp b/src/gui/sharing/updatesharejob.cpp new file mode 100644 index 0000000000000..6ac64e361b8aa --- /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, + 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) { + 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..12c2bbe2a29ad --- /dev/null +++ b/src/gui/sharing/updatesharejob.h @@ -0,0 +1,39 @@ +/* + * 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; + +/** + * @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 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); +}; + +} diff --git a/src/gui/socketapi/socketapi.cpp b/src/gui/socketapi/socketapi.cpp index 63887e03f4055..129862d126f52 100644 --- a/src/gui/socketapi/socketapi.cpp +++ b/src/gui/socketapi/socketapi.cpp @@ -599,42 +599,51 @@ 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); + + 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 fccdb9813d55a..4245b4b7f6b51 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" @@ -842,14 +843,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"; @@ -862,6 +863,21 @@ void Systray::createFileDetailsDialog(const QString &localPath) return; } + const auto relativePath = localPath.mid(folder->cleanPath().length() + 1); + auto resolvedFileId = fileId; + if (resolvedFileId.isEmpty()) { + auto fileRecord = SyncJournalFileRecord{}; + if (folder->journalDb()->getFileRecord(relativePath, &fileRecord)) { + resolvedFileId = QString::fromUtf8(fileRecord.numericFileId()); + } + } + const auto remotePath = QDir(folder->remotePath()).filePath(relativePath); + + if (folder->accountState()->account()->capabilities().unifiedSharingAvailable()) { + createUnifiedSharingDialog(folder->accountState()->account(), localPath, resolvedFileId, remotePath); + return; + } + const QVariantMap initialProperties{ {"accountState", QVariant::fromValue(folder->accountState())}, {"localPath", localPath}, @@ -889,9 +905,49 @@ void Systray::createFileDetailsDialog(const QString &localPath) } } -void Systray::createShareDialog(const QString &localPath) +void Systray::createUnifiedSharingDialog(const AccountPtr &account, const QString &localPath, const QString &fileId, const QString &remotePath) { - createFileDetailsDialog(localPath); + 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); Q_EMIT showFileDetailsPage(localPath, FileDetailsPage::Sharing); } @@ -913,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 29ae435439979..32a8936c676fd 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); @@ -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); @@ -189,7 +198,12 @@ private slots: Systray(); void setupContextMenu(); - void createFileDetailsDialog(const QString &localPath); + 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 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 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 } 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 + } + } +} diff --git a/src/libsync/account.h b/src/libsync/account.h index a7aa5b46b9004..7d890ba3f5f63 100644 --- a/src/libsync/account.h +++ b/src/libsync/account.h @@ -602,6 +602,7 @@ private slots: bool _serverHasValidSubscription = false; UpdateChannel _enterpriseUpdateChannel = UpdateChannel::Invalid; QByteArray _encryptionCertificateFingerprint; + #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..ab94b094bcbb1 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) { @@ -104,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/test/CMakeLists.txt b/test/CMakeLists.txt index bbfd290ec6c14..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) @@ -101,6 +103,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/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) diff --git a/test/qml/activityfilemenu/testactivityfilemenu.qml b/test/qml/activityfilemenu/testactivityfilemenu.qml new file mode 100644 index 0000000000000..84dc2db2b2770 --- /dev/null +++ b/test/qml/activityfilemenu/testactivityfilemenu.qml @@ -0,0 +1,103 @@ +/* + * 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" + +Item { + id: testRoot + + width: 200 + height: 100 + + TestCase { + id: testCase + + name: "ActivityFileMenu" + when: windowShown + + property ActivityFileMenuButton button + + SignalSpy { + id: fileDetailsRequestedSpy + signalName: "fileDetailsRequested" + } + + SignalSpy { + id: fileActionsRequestedSpy + signalName: "fileActionsRequested" + } + + Component { + id: buttonComponent + + ActivityFileMenuButton { + filePath: "/sync/folder/file.txt" + serverHasIntegration: true + itemFontPixelSize: 14 + buttonWidth: 44 + buttonHeight: 32 + buttonIconSize: 16 + } + } + + function init() + { + button = createTemporaryObject(buttonComponent, testRoot); + verify(button); + + fileDetailsRequestedSpy.target = button; + fileActionsRequestedSpy.target = button; + } + + function test_contentFitsWithoutScrolling() + { + 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() + { + mouseClick(button); + tryCompare(button.menu, "opened", true); + } + + function test_buttonOpensMenu() + { + verify(!button.menu.opened); + openMenu(); + verify(button.menu.opened); + } + + function test_fileDetailsItemEmitsCapturedPath() + { + openMenu(); + + const fileDetailsItem = button.menu.itemAt(0); + verify(fileDetailsItem); + mouseClick(fileDetailsItem); + + compare(fileDetailsRequestedSpy.count, 1); + compare(fileDetailsRequestedSpy.signalArguments[0][0], "/sync/folder/file.txt"); + } + + function test_fileActionsItemEmitsCapturedPath() + { + openMenu(); + + const fileActionsItem = button.menu.itemAt(1); + verify(fileActionsItem); + mouseClick(fileActionsItem); + + compare(fileActionsRequestedSpy.count, 1); + compare(fileActionsRequestedSpy.signalArguments[0][0], "/sync/folder/file.txt"); + } + } +} diff --git a/test/testunifiedsharing.cpp b/test/testunifiedsharing.cpp new file mode 100644 index 0000000000000..c4ad9692a99aa --- /dev/null +++ b/test/testunifiedsharing.cpp @@ -0,0 +1,2070 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * 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/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/recipientmodel.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/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" + +#include +#include +#include + +#include + +using namespace OCC; +using namespace OCC::Gui::Sharing; +using namespace Qt::StringLiterals; + +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->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; + 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 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}; + 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 *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) { + 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; + } + } + + 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": %1, + "message": "OK" + }, + "data": { + "id": "share-1" + } + } + })json"} + .arg(statusCode) + .toUtf8(); + return new FakePayloadReply{operation, request, response, this}; + }); + + 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, + const QByteArray &expectedVerb, + const QString &expectedPath, + 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}, "share-1"_L1}, + "GET", + "/ocs/v2.php/apps/sharing/api/v1/recipients", + {{"query"_L1, "ali"_L1}, + {"offset"_L1, "10"_L1}, + {"limit"_L1, "20"_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}, + "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, 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, 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", + {}, + {{"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}, {"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}}); + verifyRequest(new SetPermissionPresetJob{account, *share, "preset-class"_L1}, + "PUT", + "/ocs/v2.php/apps/sharing/api/v1/share/share-1/permission/preset", + {}, + {{"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"); + 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, 19); + 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}; + }); + + const auto job = new SearchRecipientsJob{fakeFolder.account(), "alice"_L1, 0, 10}; + QSignalSpy finishedSpy{job, &UnifiedSharingRequest::jobFinished}; + + job->start(); + job->start(); + + QVERIFY(finishedSpy.wait()); + 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; + + 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() + { + 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 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": "OC\\Core\\Sharing\\Recipient\\EmailShareRecipientType", + "display_name": "alice@example.com", + "value": "alice@example.com" + }] + }, { + "id": "internal-share", + "state": "active", + "recipients": [{ + "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": "OC\\Core\\Sharing\\Recipient\\UserShareRecipientType", + "value": "carol" + }] + }, { + "id": "empty-draft", + "state": "draft" + }, { + "id": "deleted-share", + "state": "deleted" + }, { + "id": "unknown-share", + "state": "paused" + }] + } + })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(), 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(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(1), UnifiedShareListModel::ShareRole).value(); + internalShare->updateFromJson(QJsonDocument::fromJson(R"json({ + "ocs": {"data": {"recipients": [{ + "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(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(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(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); + } + + 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 sharingControllerCreatesDraftForSelectedRecipient() + { + 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 addingRecipient = path.endsWith("/recipient"_L1); + const auto response = QString{R"json({ + "ocs": { + "meta": { + "status": "ok", + "statuscode": %1, + "message": "OK" + }, + "data": { + "id": "share-1", + "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}; + }); + + SharingController controller; + controller.setAccount(fakeFolder.account()); + QSignalSpy sharesChangedSpy{&controller, &SharingController::sharesChanged}; + QSignalSpy creatingShareChangedSpy{&controller, &SharingController::creatingShareChanged}; + QSignalSpy shareCreatedSpy{&controller, &SharingController::shareCreated}; + + controller.createShareForRecipient("42"_L1, "user"_L1, "alice"_L1, "cloud.example.com"_L1); + + QVERIFY(controller.creatingShare()); + 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(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)); + 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 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}; + 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() + { + 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 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}; + 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 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}; + 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.createShareForRecipient("42"_L1, "user"_L1, "alice"_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}; + 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 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)) + .toUtf8(); + return new FakePayloadReply{operation, request, payload, this}; + }); + + const auto account = fakeFolder.account(); + + QPointer createdShare; + const auto createJob = new CreateShareJob{account}; + 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 = new SetPermissionJob{account, *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 = new SearchRecipientsJob{account, "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 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) { + destroyed = true; + }); + destroyJob->start(); + QTRY_VERIFY(destroyed); + + delete createdShare; + delete fetchedShare; + qDeleteAll(fetchedShares); + } + + 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 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) { + 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}; + 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()); + QVERIFY(!model.fetchOngoing()); + 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); + 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); + QCOMPARE(model.data(model.index(0), RecipientSearchModel::DisplayNameRole).toString(), "new"_L1); + } +}; + +QTEST_GUILESS_MAIN(TestUnifiedSharing) + +#include "testunifiedsharing.moc" 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/Style/Style.qml b/theme/Style/Style.qml index 09941091252d5..438aaec853ad8 100644 --- a/theme/Style/Style.qml +++ b/theme/Style/Style.qml @@ -141,6 +141,20 @@ 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 sharingDialogShareListMaximumHeight: 3 * sharingDialogPaneHeaderHeight + 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 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 @@ +