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