diff --git a/Package.resolved b/Package.resolved index d1edc639..cdcc1dd8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Alamofire/Alamofire", "state" : { - "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", - "version" : "5.10.2" + "revision" : "7595cbcf59809f9977c5f6378500de2ad73b7ddb", + "version" : "5.12.0" } }, { diff --git a/Sources/NextcloudKit/Models/NKOCSWrapper.swift b/Sources/NextcloudKit/Models/NKOCSWrapper.swift new file mode 100644 index 00000000..8cfc1004 --- /dev/null +++ b/Sources/NextcloudKit/Models/NKOCSWrapper.swift @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Generic `Decodable` envelope for OCS v2 responses. +/// Server replies follow `{ "ocs": { "meta": ..., "data": ... } }`. +public struct NKOCSWrapper: Decodable { + public let ocs: Inner + + public struct Inner: Decodable { + public let meta: NKOCSMeta + public let data: T + } +} + +/// OCS response metadata. `message` is optional per the OCS contract. +public struct NKOCSMeta: Decodable, Sendable { + public let status: String + public let statuscode: Int + public let message: String? +} diff --git a/Sources/NextcloudKit/Models/NKTermsOfService.swift b/Sources/NextcloudKit/Models/NKTermsOfService.swift index 61c17d77..05c57d7a 100644 --- a/Sources/NextcloudKit/Models/NKTermsOfService.swift +++ b/Sources/NextcloudKit/Models/NKTermsOfService.swift @@ -5,7 +5,10 @@ import Foundation public class NKTermsOfService: NSObject { - public var meta: Meta? + /// Source-compat alias for callers that still reference `NKTermsOfService.Meta`. + public typealias Meta = NKOCSMeta + + public var meta: NKOCSMeta? public var data: OCSData? public override init() { @@ -14,9 +17,9 @@ public class NKTermsOfService: NSObject { public func loadFromJSON(_ jsonData: Data) -> Bool { do { - let decodedResponse = try JSONDecoder().decode(OCSResponse.self, from: jsonData) - self.meta = decodedResponse.ocs.meta - self.data = decodedResponse.ocs.data + let decoded = try JSONDecoder().decode(NKOCSWrapper.self, from: jsonData) + self.meta = decoded.ocs.meta + self.data = decoded.ocs.data return true } catch { debugPrint("[DEBUG] decode error:", error) @@ -36,26 +39,10 @@ public class NKTermsOfService: NSObject { return data?.hasSigned ?? false } - public func getMeta() -> Meta? { + public func getMeta() -> NKOCSMeta? { return meta } - // MARK: - Codable - private class OCSResponse: Codable { - let ocs: OCS - } - - private class OCS: Codable { - let meta: Meta - let data: OCSData - } - - public class Meta: Codable { - public let status: String - public let statuscode: Int - public let message: String - } - public class OCSData: Codable { public let terms: [Term] public let languages: [String: String] diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare+Mock.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare+Mock.swift new file mode 100644 index 00000000..f362f1a4 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare+Mock.swift @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +#if DEBUG +import Foundation + +public extension NKUnifiedShare { + /// Sample share for SwiftUI previews and tests, covering all property variants. + static var mock: NKUnifiedShare { + NKUnifiedShare( + id: "preview-1", + owner: NKUnifiedShareOwner( + userId: "alice", + instance: nil, + displayName: "Alice", + icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil) + ), + lastUpdated: 1_730_000_000_000, + state: .draft, + sources: [ + NKUnifiedShareSource( + class: "file", + value: "/Test.txt", + displayName: "Test.txt", + icon: NKUnifiedShareIcon(svg: nil, light: "https://example.com/light.png", dark: "https://example.com/dark.png") + ) + ], + recipients: .mocks, + properties: [ + NKUnifiedSharePropertyDate( + class: "expiration", + displayName: "Expiration", + priority: 10, + required: false, + minDate: "2026-01-01" + ), + NKUnifiedSharePropertyEnum( + class: "role", + displayName: "Role", + priority: 20, + required: true, + value: "editor", + validValues: ["viewer", "editor"] + ), + NKUnifiedSharePropertyBoolean( + class: "download", + displayName: "Allow download", + priority: 30, + required: false, + value: "true" + ), + NKUnifiedSharePropertyPassword( + class: "password", + displayName: "Password", + hint: "Min 8 chars", + priority: 40, + required: false + ), + NKUnifiedSharePropertyString( + class: "note", + displayName: "Note", + priority: 50, + required: false, + value: "hi", + minLength: 0, + maxLength: 1000 + ) + ], + permissions: [ + NKUnifiedSharePermission(class: "read", sourceClass: nil, displayName: "View files", hint: nil, priority: 1, presets: ["viewer", "editor"], enabled: true), + NKUnifiedSharePermission(class: "update", sourceClass: nil, displayName: "Edit files", hint: nil, priority: 2, presets: ["editor"], enabled: false), + NKUnifiedSharePermission(class: "share", sourceClass: nil, displayName: "Share with others", hint: nil, priority: 3, presets: ["editor"], enabled: false), + NKUnifiedSharePermission(class: "download", sourceClass: nil, displayName: "Download files", hint: nil, priority: 4, presets: ["viewer", "editor"], enabled: true) + ], + permissionPreset: nil + ) + } +} +#endif diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare.swift new file mode 100644 index 00000000..d30a5fd5 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShare.swift @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// A unified share returned by `ocs/v2.php/apps/sharing/api/v1/...`. +public final class NKUnifiedShare: Codable, Identifiable { + public let id: String + public let owner: NKUnifiedShareOwner + /// Unix time in milliseconds. + public let lastUpdated: Int64 + public let state: NKUnifiedShareState + public let sources: [NKUnifiedShareSource] + public let recipients: [NKUnifiedShareRecipient] + public let properties: [NKUnifiedShareProperty] + public let permissions: [NKUnifiedSharePermission] + /// Currently-applied permission preset class, if any. + public let permissionPreset: String? + + enum CodingKeys: String, CodingKey { + case id + case owner + case lastUpdated = "last_updated" + case state + case sources + case recipients + case properties + case permissions + case permissionPreset = "permission_preset" + } + + public init(id: String, + owner: NKUnifiedShareOwner, + lastUpdated: Int64, + state: NKUnifiedShareState, + sources: [NKUnifiedShareSource], + recipients: [NKUnifiedShareRecipient], + properties: [NKUnifiedShareProperty], + permissions: [NKUnifiedSharePermission], + permissionPreset: String? = nil) { + self.id = id + self.owner = owner + self.lastUpdated = lastUpdated + self.state = state + self.sources = sources + self.recipients = recipients + self.properties = properties + self.permissions = permissions + self.permissionPreset = permissionPreset + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.id = try c.decode(String.self, forKey: .id) + self.owner = try c.decode(NKUnifiedShareOwner.self, forKey: .owner) + self.lastUpdated = try c.decode(Int64.self, forKey: .lastUpdated) + self.state = try c.decode(NKUnifiedShareState.self, forKey: .state) + self.sources = try c.decode([NKUnifiedShareSource].self, forKey: .sources) + self.recipients = try c.decode([NKUnifiedShareRecipient].self, forKey: .recipients) + self.permissions = try c.decode([NKUnifiedSharePermission].self, forKey: .permissions) + self.permissionPreset = try c.decodeIfPresent(String.self, forKey: .permissionPreset) + self.properties = try Self.decodeProperties(from: c) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(owner, forKey: .owner) + try c.encode(lastUpdated, forKey: .lastUpdated) + try c.encode(state, forKey: .state) + try c.encode(sources, forKey: .sources) + try c.encode(recipients, forKey: .recipients) + try c.encode(permissions, forKey: .permissions) + try c.encode(properties, forKey: .properties) + try c.encodeIfPresent(permissionPreset, forKey: .permissionPreset) + } + + /// Dispatch each `properties` element to the correct subclass based on its `type` discriminator. + /// Uses two independent unkeyed containers — one to peek, one to decode — so we can read the + /// `type` of every element without consuming the cursor we actually need for the concrete decode. + private static func decodeProperties(from c: KeyedDecodingContainer) throws -> [NKUnifiedShareProperty] { + var peek = try c.nestedUnkeyedContainer(forKey: .properties) + var real = try c.nestedUnkeyedContainer(forKey: .properties) + var result: [NKUnifiedShareProperty] = [] + + while !real.isAtEnd { + let holder = try peek.decode(TypeHolder.self) + switch holder.type { + case .date: + result.append(try real.decode(NKUnifiedSharePropertyDate.self)) + case .enumeration: + result.append(try real.decode(NKUnifiedSharePropertyEnum.self)) + case .boolean: + result.append(try real.decode(NKUnifiedSharePropertyBoolean.self)) + case .password: + result.append(try real.decode(NKUnifiedSharePropertyPassword.self)) + case .string: + result.append(try real.decode(NKUnifiedSharePropertyString.self)) + } + } + return result + } + + private struct TypeHolder: Decodable { + let type: NKUnifiedSharePropertyType + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareIcon.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareIcon.swift new file mode 100644 index 00000000..3a97304b --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareIcon.swift @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Icon attached to an owner, source, recipient, or permission category. +/// +/// The OpenAPI declares this as `anyOf `. The two variants have disjoint key +/// sets (`svg` vs `light`+`dark`) so a single flat struct with all keys optional decodes either +/// shape cleanly with synthesized Codable. +public struct NKUnifiedShareIcon: Codable, Sendable { + /// Inline SVG body (IconSVG variant). + public let svg: String? + + /// Absolute URL to a light-theme image (IconURL variant). + public let light: String? + + /// Absolute URL to a dark-theme image (IconURL variant). + public let dark: String? +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareOwner.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareOwner.swift new file mode 100644 index 00000000..b81f7846 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareOwner.swift @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Owner of a unified share. +public struct NKUnifiedShareOwner: Codable, Sendable { + public let userId: String + public let instance: String? + public let displayName: String + public let icon: NKUnifiedShareIcon + + enum CodingKeys: String, CodingKey { + case userId = "user_id" + case instance + case displayName = "display_name" + case icon + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermission.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermission.swift new file mode 100644 index 00000000..c3b3b20e --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermission.swift @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// A toggleable permission on a unified share (can view / can edit / can comment / …). +public struct NKUnifiedSharePermission: Codable, Sendable { + public let `class`: String + public let sourceClass: String? + public let displayName: String + public let hint: String? + public let priority: Int + /// Class identifiers of the permission presets this permission belongs to. + public let presets: [String] + public let enabled: Bool + + enum CodingKeys: String, CodingKey { + case `class` + case sourceClass = "source_class" + case displayName = "display_name" + case hint + case priority + case presets + case enabled + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermissionPreset.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermissionPreset.swift new file mode 100644 index 00000000..efbf903b --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharePermissionPreset.swift @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// A selectable permission preset advertised by the sharing capability. +public struct NKUnifiedSharePermissionPreset: Codable, Sendable { + public let `class`: String + public let displayName: String + public let hint: String? + + public init(class: String, displayName: String, hint: String? = nil) { + self.class = `class` + self.displayName = displayName + self.hint = hint + } + + enum CodingKeys: String, CodingKey { + case `class` + case displayName = "display_name" + case hint + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareProperty.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareProperty.swift new file mode 100644 index 00000000..60b2ab90 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareProperty.swift @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Property type discriminator. +public enum NKUnifiedSharePropertyType: String, Codable, Sendable { + case date + case enumeration = "enum" + case boolean + case password + case string +} + +/// Base class for a unified share property. +/// +/// Variant fields live on the concrete subclasses (`NKUnifiedSharePropertyDate`, …). When decoding +/// `NKUnifiedShare.properties`, the dispatch on `type` picks the right subclass. +public class NKUnifiedShareProperty: Codable { + public let `class`: String + public let displayName: String + public let hint: String? + public let priority: Int + public let required: Bool + public let advanced: Bool + public let value: String? + public let type: NKUnifiedSharePropertyType + + enum CodingKeys: String, CodingKey { + case `class` + case displayName = "display_name" + case hint + case priority + case required + case advanced + case value + case type + } + + public init(class: String, displayName: String, hint: String?, priority: Int, required: Bool, advanced: Bool, value: String?, type: NKUnifiedSharePropertyType) { + self.class = `class` + self.displayName = displayName + self.hint = hint + self.priority = priority + self.required = required + self.advanced = advanced + self.value = value + self.type = type + } + + public required init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.class = try c.decode(String.self, forKey: .class) + self.displayName = try c.decode(String.self, forKey: .displayName) + self.hint = try c.decodeIfPresent(String.self, forKey: .hint) + self.priority = try c.decode(Int.self, forKey: .priority) + self.required = try c.decode(Bool.self, forKey: .required) + self.advanced = try c.decode(Bool.self, forKey: .advanced) + self.value = try c.decodeIfPresent(String.self, forKey: .value) + self.type = try c.decode(NKUnifiedSharePropertyType.self, forKey: .type) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(self.class, forKey: .class) + try c.encode(displayName, forKey: .displayName) + try c.encodeIfPresent(hint, forKey: .hint) + try c.encode(priority, forKey: .priority) + try c.encode(required, forKey: .required) + try c.encode(advanced, forKey: .advanced) + try c.encodeIfPresent(value, forKey: .value) + try c.encode(type, forKey: .type) + } +} + +/// Date-typed property; adds an optional valid range. +public final class NKUnifiedSharePropertyDate: NKUnifiedShareProperty { + public let minDate: String? + public let maxDate: String? + + enum DateKeys: String, CodingKey { + case minDate = "min_date" + case maxDate = "max_date" + } + + public init(class: String, displayName: String, hint: String? = nil, priority: Int, required: Bool, advanced: Bool = false, value: String? = nil, minDate: String? = nil, maxDate: String? = nil) { + self.minDate = minDate + self.maxDate = maxDate + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, advanced: advanced, value: value, type: .date) + } + + public required init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: DateKeys.self) + self.minDate = try c.decodeIfPresent(String.self, forKey: .minDate) + self.maxDate = try c.decodeIfPresent(String.self, forKey: .maxDate) + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + var c = encoder.container(keyedBy: DateKeys.self) + try c.encodeIfPresent(minDate, forKey: .minDate) + try c.encodeIfPresent(maxDate, forKey: .maxDate) + } +} + +/// Enum-typed property; carries the allowed value set. +public final class NKUnifiedSharePropertyEnum: NKUnifiedShareProperty { + public let validValues: [String] + + enum EnumKeys: String, CodingKey { + case validValues = "valid_values" + } + + public init(class: String, displayName: String, hint: String? = nil, priority: Int, required: Bool, advanced: Bool = false, value: String? = nil, validValues: [String]) { + self.validValues = validValues + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, advanced: advanced, value: value, type: .enumeration) + } + + public required init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: EnumKeys.self) + self.validValues = try c.decode([String].self, forKey: .validValues) + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + var c = encoder.container(keyedBy: EnumKeys.self) + try c.encode(validValues, forKey: .validValues) + } +} + +/// Boolean-typed property; no additional fields. +public final class NKUnifiedSharePropertyBoolean: NKUnifiedShareProperty { + public init(class: String, displayName: String, hint: String? = nil, priority: Int, required: Bool, advanced: Bool = false, value: String? = nil) { + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, advanced: advanced, value: value, type: .boolean) + } + + public required init(from decoder: Decoder) throws { + try super.init(from: decoder) + } +} + +/// Password-typed property; no additional fields. +public final class NKUnifiedSharePropertyPassword: NKUnifiedShareProperty { + public init(class: String, displayName: String, hint: String? = nil, priority: Int, required: Bool, advanced: Bool = false, value: String? = nil) { + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, advanced: advanced, value: value, type: .password) + } + + public required init(from decoder: Decoder) throws { + try super.init(from: decoder) + } +} + +/// String-typed property; adds optional length bounds. +public final class NKUnifiedSharePropertyString: NKUnifiedShareProperty { + public let minLength: Int? + public let maxLength: Int? + + enum StringKeys: String, CodingKey { + case minLength = "min_length" + case maxLength = "max_length" + } + + public init(class: String, displayName: String, hint: String? = nil, priority: Int, required: Bool, advanced: Bool = false, value: String? = nil, minLength: Int? = nil, maxLength: Int? = nil) { + self.minLength = minLength + self.maxLength = maxLength + super.init(class: `class`, displayName: displayName, hint: hint, priority: priority, required: required, advanced: advanced, value: value, type: .string) + } + + public required init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: StringKeys.self) + self.minLength = try c.decodeIfPresent(Int.self, forKey: .minLength) + self.maxLength = try c.decodeIfPresent(Int.self, forKey: .maxLength) + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) + var c = encoder.container(keyedBy: StringKeys.self) + try c.encodeIfPresent(minLength, forKey: .minLength) + try c.encodeIfPresent(maxLength, forKey: .maxLength) + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient+Mock.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient+Mock.swift new file mode 100644 index 00000000..dece2b8f --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient+Mock.swift @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +#if DEBUG +import Foundation + +public extension NKUnifiedShareRecipient { + /// Sample recipient for SwiftUI previews and tests. + static var mock: NKUnifiedShareRecipient { + NKUnifiedShareRecipient( + class: "user", + value: "bob", + instance: nil, + displayName: "Bob", + icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil), + secret: Secret(updatable: false), + initiator: nil + ) + } +} + +public extension Array where Element == NKUnifiedShareRecipient { + /// A few sample recipients, e.g. for autocomplete results. + static var mocks: [NKUnifiedShareRecipient] { + [ + NKUnifiedShareRecipient(class: "", value: "bob", instance: nil, displayName: "Bob", icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil), secret: .init(updatable: false), initiator: nil), + NKUnifiedShareRecipient(class: "", value: "team", instance: nil, displayName: "Team", icon: NKUnifiedShareIcon(svg: "", light: nil, dark: nil), secret: .init(updatable: false), initiator: nil), + NKUnifiedShareRecipient(class: "", value: "carol@example.com", instance: "example.com", displayName: "Carol (example.com)", icon: nil, secret: .init(updatable: true, url: "https://example.com/s/abc"), initiator: nil) + ] + } +} +#endif diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient.swift new file mode 100644 index 00000000..1aa640c6 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareRecipient.swift @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// A recipient (user, group, federated user, public link, …) on a unified share. +public struct NKUnifiedShareRecipient: Codable, Sendable { + public let `class`: String + public let value: String + public let instance: String? + public let displayName: String + public let icon: NKUnifiedShareIcon? + public let secret: Secret + public let initiator: NKUnifiedShareOwner? + + enum CodingKeys: String, CodingKey { + case `class` + case value + case instance + case displayName = "display_name" + case icon + case secret + case initiator + } + + /// A recipient's secret; `url` carries the public link when present. + public struct Secret: Codable, Sendable { + public let updatable: Bool + public let value: String? + public let url: String? + + public init(updatable: Bool, value: String? = nil, url: String? = nil) { + self.updatable = updatable + self.value = value + self.url = url + } + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareSource.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareSource.swift new file mode 100644 index 00000000..702f4ec2 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareSource.swift @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// An item being shared (file, folder, calendar, contact, …). +public struct NKUnifiedShareSource: Codable, Sendable { + public let `class`: String + public let value: String + public let displayName: String + public let icon: NKUnifiedShareIcon? + + enum CodingKeys: String, CodingKey { + case `class` + case value + case displayName = "display_name" + case icon + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareSourceType.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareSourceType.swift new file mode 100644 index 00000000..79c3a6ef --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareSourceType.swift @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// A source type advertised by the sharing capability. +public struct NKUnifiedShareSourceType: Codable, Sendable { + public let `class`: String + + public init(class: String) { + self.class = `class` + } + + enum CodingKeys: String, CodingKey { + case `class` + } +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareState.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareState.swift new file mode 100644 index 00000000..7cfd5a35 --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedShareState.swift @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// Lifecycle state of a unified share. +public enum NKUnifiedShareState: String, Codable, Sendable { + case active + case draft + case deleted +} diff --git a/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharingCapabilities.swift b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharingCapabilities.swift new file mode 100644 index 00000000..91185a2f --- /dev/null +++ b/Sources/NextcloudKit/Models/UnifiedSharing/NKUnifiedSharingCapabilities.swift @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +/// The `sharing` block of the server capabilities, describing unified-sharing support. +public struct NKUnifiedSharingCapabilities: Codable, Sendable { + public let apiVersions: [String] + public let sourceTypes: [NKUnifiedShareSourceType] + public let permissionPresets: [NKUnifiedSharePermissionPreset] + + public init(apiVersions: [String], sourceTypes: [NKUnifiedShareSourceType], permissionPresets: [NKUnifiedSharePermissionPreset]) { + self.apiVersions = apiVersions + self.sourceTypes = sourceTypes + self.permissionPresets = permissionPresets + } + + enum CodingKeys: String, CodingKey { + case apiVersions = "api_versions" + case sourceTypes = "source_types" + case permissionPresets = "permission_presets" + } +} diff --git a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift index de4e898d..6df82e3a 100644 --- a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift +++ b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift @@ -119,6 +119,7 @@ public extension NextcloudKit { struct Capabilities: Codable { let downloadLimit: DownloadLimit? let filessharing: FilesSharing? + let sharing: Sharing? let theming: Theming? let endtoendencryption: EndToEndEncryption? let richdocuments: RichDocuments? @@ -138,6 +139,7 @@ public extension NextcloudKit { enum CodingKeys: String, CodingKey { case downloadLimit = "downloadlimit" case filessharing = "files_sharing" + case sharing case theming case endtoendencryption = "end-to-end-encryption" case richdocuments, activity, notifications, files @@ -153,6 +155,8 @@ public extension NextcloudKit { struct Governance: Codable {} + struct Sharing: Codable {} + struct DownloadLimit: Codable { let enabled: Bool? let defaultLimit: Int? @@ -395,6 +399,8 @@ public extension NextcloudKit { capabilities.fileSharingDownloadLimit = json.downloadLimit?.enabled ?? false capabilities.fileSharingDownloadLimitDefaultLimit = json.downloadLimit?.defaultLimit ?? 1 + capabilities.unifiedSharingEnabled = json.sharing != nil + capabilities.themingColor = json.theming?.color ?? "" capabilities.themingColorElement = json.theming?.colorelement ?? "" capabilities.themingColorText = json.theming?.colortext ?? "" @@ -500,6 +506,7 @@ final public class NKCapabilities: Sendable { public var fileSharingDefaultPermission: Int = 0 public var fileSharingDownloadLimit: Bool = false public var fileSharingDownloadLimitDefaultLimit: Int = 1 + public var unifiedSharingEnabled: Bool = false public var themingColor: String = "" public var themingColorElement: String = "" public var themingColorText: String = "" diff --git a/Sources/NextcloudKit/NextcloudKit+TermsOfService.swift b/Sources/NextcloudKit/NextcloudKit+TermsOfService.swift index a3b88929..dd2f9cf8 100644 --- a/Sources/NextcloudKit/NextcloudKit+TermsOfService.swift +++ b/Sources/NextcloudKit/NextcloudKit+TermsOfService.swift @@ -34,7 +34,7 @@ public extension NextcloudKit { if meta.statuscode == 200 { options.queue.async { completion(account, tos, response, .success) } } else { - options.queue.async { completion(account, tos, response, NKError(errorCode: meta.statuscode, errorDescription: meta.message, responseData: jsonData)) } + options.queue.async { completion(account, tos, response, NKError(errorCode: meta.statuscode, errorDescription: meta.message ?? "", responseData: jsonData)) } } } else { options.queue.async { completion(account, nil, response, .invalidData) } diff --git a/Sources/NextcloudKit/NextcloudKit+UnifiedSharing.swift b/Sources/NextcloudKit/NextcloudKit+UnifiedSharing.swift new file mode 100644 index 00000000..3f49deed --- /dev/null +++ b/Sources/NextcloudKit/NextcloudKit+UnifiedSharing.swift @@ -0,0 +1,586 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Alamofire + +/// Endpoints for the unified-sharing OCS API (`ocs/v2.php/apps/sharing/api/v1/...`). +public extension NextcloudKit { + // MARK: - List & search + + /// `GET /shares` — paginated list of shares the current user can see. + func listUnifiedShares(filterSourceTypeClass: String? = nil, + filterSourceTypeValue: String? = nil, + lastShareID: String? = nil, + limit: Int? = nil, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, shares: [NKUnifiedShare]?, responseData: AFDataResponse?, error: NKError) { + let endpoint = "ocs/v2.php/apps/sharing/api/v1/shares" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, nil, .urlError) + } + + var parameters: [String: String] = [:] + if let filterSourceTypeClass { parameters["filterSourceTypeClass"] = filterSourceTypeClass } + if let filterSourceTypeValue { parameters["filterSourceTypeValue"] = filterSourceTypeValue } + if let lastShareID { parameters["lastShareID"] = lastShareID } + if let limit { parameters["limit"] = String(limit) } + + let response = await nkSession.sessionData + .request(url, method: .get, parameters: parameters, encoding: URLEncoding.default, + headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + return decodeUnifiedShareList(response: response, account: account) + } + + /// `GET /recipients` — search recipients (users, groups, federated …) by free-text query. + func searchUnifiedShareRecipients(query: String, + recipientTypeClasses: [String]? = nil, + limit: Int? = nil, + offset: Int? = nil, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, recipients: [NKUnifiedShareRecipient]?, responseData: AFDataResponse?, error: NKError) { + let endpoint = "ocs/v2.php/apps/sharing/api/v1/recipients" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, nil, .urlError) + } + + // Build the query manually so `recipientTypeClasses[]` can repeat once per element + // (a plain [String: String] can't hold an array, and [String: Any] isn't Sendable). + guard let baseURL = try? url.asURL(), + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + return (account, nil, nil, .urlError) + } + + var queryItems: [URLQueryItem] = [URLQueryItem(name: "query", value: query)] + recipientTypeClasses?.forEach { queryItems.append(URLQueryItem(name: "recipientTypeClasses[]", value: $0)) } + if let limit { queryItems.append(URLQueryItem(name: "limit", value: String(limit))) } + if let offset { queryItems.append(URLQueryItem(name: "offset", value: String(offset))) } + components.queryItems = queryItems + + guard let requestURL = components.url else { + return (account, nil, nil, .urlError) + } + + let response = await nkSession.sessionData + .request(requestURL, method: .get, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + switch response.result { + case .failure(let error): + return (account, nil, response, NKError(error: error, afResponse: response, responseData: response.data)) + case .success(let data): + do { + let wrap = try JSONDecoder().decode(NKOCSWrapper<[NKUnifiedShareRecipient]>.self, from: data) + guard 200..<300 ~= wrap.ocs.meta.statuscode else { + return (account, nil, response, NKError(statusCode: wrap.ocs.meta.statuscode, fallbackDescription: wrap.ocs.meta.message ?? "", responseData: data)) + } + return (account, wrap.ocs.data, response, .success) + } catch { + return (account, nil, response, NKError(error: error, responseData: data)) + } + } + } + + /// `GET /secret` — generate a new server-side secret (returns the secret string). + func generateUnifiedShareSecret(account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, secret: String?, responseData: AFDataResponse?, error: NKError) { + let endpoint = "ocs/v2.php/apps/sharing/api/v1/secret" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, nil, .urlError) + } + + let response = await nkSession.sessionData + .request(url, method: .get, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + switch response.result { + case .failure(let error): + return (account, nil, response, NKError(error: error, afResponse: response, responseData: response.data)) + case .success(let data): + do { + let wrap = try JSONDecoder().decode(NKOCSWrapper.self, from: data) + guard 200..<300 ~= wrap.ocs.meta.statuscode else { + return (account, nil, response, NKError(statusCode: wrap.ocs.meta.statuscode, fallbackDescription: wrap.ocs.meta.message ?? "", responseData: data)) + } + return (account, wrap.ocs.data, response, .success) + } catch { + return (account, nil, response, NKError(error: error, responseData: data)) + } + } + } + + // MARK: - Capabilities + + /// `GET /cloud/capabilities` — the unified-sharing capability block, or `nil` if the server + /// doesn't advertise it. + func getUnifiedSharingCapabilities(account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, capabilities: NKUnifiedSharingCapabilities?, responseData: AFDataResponse?, error: NKError) { + let endpoint = "ocs/v2.php/cloud/capabilities" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, nil, .urlError) + } + + let response = await nkSession.sessionData + .request(url, method: .get, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + switch response.result { + case .failure(let error): + return (account, nil, response, NKError(error: error, afResponse: response, responseData: response.data)) + case .success(let data): + do { + let wrap = try JSONDecoder().decode(NKOCSWrapper.self, from: data) + guard 200..<300 ~= wrap.ocs.meta.statuscode else { + return (account, nil, response, NKError(statusCode: wrap.ocs.meta.statuscode, fallbackDescription: wrap.ocs.meta.message ?? "", responseData: data)) + } + return (account, wrap.ocs.data.capabilities.sharing, response, .success) + } catch { + return (account, nil, response, NKError(error: error, responseData: data)) + } + } + } + + // MARK: - Single share lifecycle + + /// `POST /share` — create a new (draft) share. Returns the created share. + func createUnifiedShare(account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + let endpoint = "ocs/v2.php/apps/sharing/api/v1/share" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, nil, .urlError) + } + + let response = await nkSession.sessionData + .request(url, method: .post, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + return decodeUnifiedShare(response: response, account: account) + } + + /// `POST /share/{id}` — fetch a specific share. Modelled as POST because the body carries + /// `secret` and free-form `arguments` per the OpenAPI. + func getUnifiedShare(id: String, + secret: String? = nil, + arguments: [String: Any]? = nil, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + guard let encodedId = id.urlEncoded else { + return (account, nil, nil, .urlError) + } + let endpoint = "ocs/v2.php/apps/sharing/api/v1/share/\(encodedId)" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, nil, .urlError) + } + + var body: [String: Any] = [:] + if let secret { body["secret"] = secret } + if let arguments { body["arguments"] = arguments } + + var urlRequest: URLRequest + + do { + urlRequest = try URLRequest(url: url, method: .post, headers: headers) + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if !body.isEmpty { + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + } + } catch { + return (account, nil, nil, NKError(error: error)) + } + + let response = await nkSession.sessionData + .request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + return decodeUnifiedShare(response: response, account: account) + } + + /// `DELETE /share/{id}` — 204 success, no response body. + func deleteUnifiedShare(id: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, responseData: AFDataResponse?, error: NKError) { + guard let encodedId = id.urlEncoded else { + return (account, nil, .urlError) + } + let endpoint = "ocs/v2.php/apps/sharing/api/v1/share/\(encodedId)" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, .urlError) + } + + let response = await nkSession.sessionData + .request(url, method: .delete, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + switch response.result { + case .failure(let error): + return (account, response, NKError(error: error, afResponse: response, responseData: response.data)) + case .success: + return (account, response, .success) + } + } + + // MARK: - Share mutations (return the updated share) + + /// `PUT /share/{id}/permission` — toggle a permission. + func setUnifiedSharePermission(id: String, + permissionClass: String, + enabled: Bool, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + await mutateUnifiedShare(method: .put, + subpath: "permission", + id: id, + body: ["class": permissionClass, "enabled": enabled], + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `PUT /share/{id}/permission/preset` — apply a permission preset. + func setUnifiedSharePermissionPreset(id: String, + permissionPresetClass: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + await mutateUnifiedShare(method: .put, + subpath: "permission/preset", + id: id, + body: ["permissionPresetClass": permissionPresetClass], + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `PUT /share/{id}/property` — set a property's value. + func setUnifiedShareProperty(id: String, + propertyClass: String, + value: String?, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + // Send an explicit null value to clear a property (e.g. removing an expiration date). + let body: [String: Any] = ["class": propertyClass, "value": value ?? NSNull()] + return await mutateUnifiedShare(method: .put, + subpath: "property", + id: id, + body: body, + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `PUT /share/{id}/state` — transition state (active/draft/deleted). + func setUnifiedShareState(id: String, + state: NKUnifiedShareState, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + await mutateUnifiedShare(method: .put, + subpath: "state", + id: id, + body: ["state": state.rawValue], + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `POST /share/{id}/recipient` — add a recipient. + func addUnifiedShareRecipient(id: String, + recipientClass: String, + value: String, + instance: String? = nil, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + var body: [String: Any] = ["class": recipientClass, "value": value] + if let instance { body["instance"] = instance } + return await mutateUnifiedShare(method: .post, + subpath: "recipient", + id: id, + body: body, + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `DELETE /share/{id}/recipient` — remove a recipient. Parameters travel as query string. + func removeUnifiedShareRecipient(id: String, + recipientClass: String, + value: String, + instance: String? = nil, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + var query: [String: String] = ["class": recipientClass, "value": value] + if let instance { query["instance"] = instance } + return await mutateUnifiedShareWithQuery(method: .delete, + subpath: "recipient", + id: id, + query: query, + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `PUT /share/{id}/recipient/secret` — set/rotate a recipient's secret. + func setUnifiedShareRecipientSecret(id: String, + recipientClass: String, + value: String, + secret: String, + instance: String? = nil, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + var body: [String: Any] = ["class": recipientClass, "value": value, "secret": secret] + if let instance { body["instance"] = instance } + return await mutateUnifiedShare(method: .put, + subpath: "recipient/secret", + id: id, + body: body, + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `POST /share/{id}/source` — add a source. + func addUnifiedShareSource(id: String, + sourceClass: String, + value: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + await mutateUnifiedShare(method: .post, + subpath: "source", + id: id, + body: ["class": sourceClass, "value": value], + account: account, + options: options, + taskHandler: taskHandler) + } + + /// `DELETE /share/{id}/source` — remove a source. Parameters travel as query string. + func removeUnifiedShareSource(id: String, + sourceClass: String, + value: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + await mutateUnifiedShareWithQuery(method: .delete, + subpath: "source", + id: id, + query: ["class": sourceClass, "value": value], + account: account, + options: options, + taskHandler: taskHandler) + } + + // MARK: - Private helpers + + /// Shared body-carrying mutation that returns the updated share. + private func mutateUnifiedShare(method: HTTPMethod, + subpath: String, + id: String, + body: [String: Any], + account: String, + options: NKRequestOptions, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + guard let encodedId = id.urlEncoded else { + return (account, nil, nil, .urlError) + } + let endpoint = "ocs/v2.php/apps/sharing/api/v1/share/\(encodedId)/\(subpath)" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, nil, .urlError) + } + + var urlRequest: URLRequest + + do { + urlRequest = try URLRequest(url: url, method: method, headers: headers) + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + } catch { + return (account, nil, nil, NKError(error: error)) + } + + let response = await nkSession.sessionData + .request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + return decodeUnifiedShare(response: response, account: account) + } + + /// Shared query-carrying mutation (DELETE subresources) that returns the updated share. + private func mutateUnifiedShareWithQuery(method: HTTPMethod, + subpath: String, + id: String, + query: [String: String], + account: String, + options: NKRequestOptions, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + guard let encodedId = id.urlEncoded else { + return (account, nil, nil, .urlError) + } + let endpoint = "ocs/v2.php/apps/sharing/api/v1/share/\(encodedId)/\(subpath)" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, nil, .urlError) + } + + let response = await nkSession.sessionData + .request(url, method: method, parameters: query, encoding: URLEncoding.queryString, + headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .serializingData() + .response + + return decodeUnifiedShare(response: response, account: account) + } + + /// Decode an OCS response containing a single `Share`. + private func decodeUnifiedShare(response: AFDataResponse, + account: String + ) -> (account: String, share: NKUnifiedShare?, responseData: AFDataResponse?, error: NKError) { + switch response.result { + case .failure(let error): + return (account, nil, response, NKError(error: error, afResponse: response, responseData: response.data)) + case .success(let data): + do { + let wrap = try JSONDecoder().decode(NKOCSWrapper.self, from: data) + guard 200..<300 ~= wrap.ocs.meta.statuscode else { + return (account, nil, response, NKError(statusCode: wrap.ocs.meta.statuscode, fallbackDescription: wrap.ocs.meta.message ?? "", responseData: data)) + } + return (account, wrap.ocs.data, response, .success) + } catch { + return (account, nil, response, NKError(error: error, responseData: data)) + } + } + } + + /// Decode an OCS response containing an array of `Share`. + private func decodeUnifiedShareList(response: AFDataResponse, + account: String + ) -> (account: String, shares: [NKUnifiedShare]?, responseData: AFDataResponse?, error: NKError) { + switch response.result { + case .failure(let error): + return (account, nil, response, NKError(error: error, afResponse: response, responseData: response.data)) + case .success(let data): + do { + let wrap = try JSONDecoder().decode(NKOCSWrapper<[NKUnifiedShare]>.self, from: data) + guard 200..<300 ~= wrap.ocs.meta.statuscode else { + return (account, nil, response, NKError(statusCode: wrap.ocs.meta.statuscode, fallbackDescription: wrap.ocs.meta.message ?? "", responseData: data)) + } + return (account, wrap.ocs.data, response, .success) + } catch { + return (account, nil, response, NKError(error: error, responseData: data)) + } + } + } + + /// `ocs.data` shape of `/cloud/capabilities`, narrowed to the unified-sharing block. + private struct CapabilitiesEnvelope: Decodable { + let capabilities: Capabilities + + struct Capabilities: Decodable { + let sharing: NKUnifiedSharingCapabilities? + } + } +} diff --git a/Sources/NextcloudKitUI/Localizable.xcstrings b/Sources/NextcloudKitUI/Localizable.xcstrings index 98f718a0..a842329b 100644 --- a/Sources/NextcloudKitUI/Localizable.xcstrings +++ b/Sources/NextcloudKitUI/Localizable.xcstrings @@ -1306,6 +1306,179 @@ } } }, + "Add people" : { + "comment" : "Prompt to add recipients in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add people" + } + } + } + }, + "Anyone" : { + "comment" : "Audience option for anyone with link.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anyone" + } + } + } + }, + "Cancel" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, + "Can…" : { + "comment" : "Menu option that reveals per-permission toggles for custom permissions.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Can…" + } + } + } + }, + "Copy private link" : { + "comment" : "Copies the in-server link of the file for invited people.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy private link" + } + } + } + }, + "Copy public link" : { + "comment" : "Copies the public link of an Anyone share.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy public link" + } + } + } + }, + "Couldn't load shares" : { + "comment" : "Full-screen error title when the share list fails to load.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't load shares" + } + } + } + }, + "Create a new share" : { + "comment" : "Navigation title when creating a share.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create a new share" + } + } + } + }, + "Custom link" : { + "comment" : "Heading of the editable public-link token row.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom link" + } + } + } + }, + "Delete" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete" + } + } + } + }, + "Delete share?" : { + "comment" : "Title of the delete confirmation dialog.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete share?" + } + } + } + }, + "Edit" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit" + } + } + } + }, + "Edit share" : { + "comment" : "Navigation title when editing an existing share.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit share" + } + } + } + }, + "Failed to update share." : { + "comment" : "Shown when a share modification fails.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to update share." + } + } + } + }, + "Invited People" : { + "comment" : "Audience option for specific recipients.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invited People" + } + } + } + }, + "Link token" : { + "comment" : "Placeholder of the editable public-link token field.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Link token" + } + } + } + }, "Login Failed" : { "comment" : "Alert title", "localizations" : { @@ -1959,6 +2132,17 @@ } } }, + "No shares yet" : { + "comment" : "Empty state title of the share list.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No shares yet" + } + } + } + }, "OK" : { "comment" : "Button label for error alert dismissal.", "localizations" : { @@ -2612,6 +2796,38 @@ } } }, + "Participants" : { + "comment" : "Label above the permission selector in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Participants" + } + } + } + }, + "Refresh link" : { + "comment" : "Accessibility label for regenerating the public-link token.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refresh link" + } + } + } + }, + "Retry" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retry" + } + } + } + }, "Scan QR Code" : { "comment" : "Button label\nNavigation bar title", "localizations" : { @@ -3265,6 +3481,28 @@ } } }, + "Send" : { + "comment" : "Primary action button title in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send" + } + } + } + }, + "Send email" : { + "comment" : "Menu option that opens the share editor to notify recipients.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send email" + } + } + } + }, "Server Address" : { "comment" : "Label for text field.", "localizations" : { @@ -3918,6 +4156,50 @@ } } }, + "Settings" : { + "comment" : "Settings section title in the unified share view.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, + "Share link" : { + "comment" : "Row title for a public link share.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share link" + } + } + } + }, + "Share link (%lld)" : { + "comment" : "Row title for the nth public link share of a file.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share link (%lld)" + } + } + } + }, + "Share public link" : { + "comment" : "Primary action activating an Anyone share.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share public link" + } + } + } + }, "The address of your Nextcloud web interface when you open it in your browser." : { "comment" : "Label below the server address field in the login view.", "localizations" : { @@ -5223,6 +5505,38 @@ } } } + }, + "The link can be changed to be easy to remember, but do not set it to something that is easy to guess." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The link can be changed to be easy to remember, but do not set it to something that is easy to guess." + } + } + } + }, + "This share will be permanently removed." : { + "comment" : "Message of the delete confirmation dialog.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This share will be permanently removed." + } + } + } + }, + "Use the + button to share %@." : { + "comment" : "Empty state description; placeholder is the file name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use the + button to share %@." + } + } + } } }, "version" : "1.0" diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift new file mode 100644 index 00000000..32a43fbc --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditModel.swift @@ -0,0 +1,335 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import NextcloudKit + +enum UnifiedShareViewState { + case loading + case shareUpdated(share: NKUnifiedShare) + case error(Error) +} + +@MainActor +@Observable +public class UnifiedShareEditModel { + /// Server class for a file/folder share source. + static let nodeSourceClass = "OCA\\Files\\Sharing\\Source\\NodeShareSourceType" + /// Server class for a public-link (token) recipient. + static let tokenRecipientClass = "OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType" + + private static let searchDebounce: Duration = .milliseconds(300) + private static let searchLimit = 10 + + var state: UnifiedShareViewState = .loading + /// Recipient autocomplete results — coexist with a loaded share, so kept out of `state`. + var recipientResults: [NKUnifiedShareRecipient] = [] + /// Selectable permission presets advertised by the server's sharing capability. + var permissionPresets: [NKUnifiedSharePermissionPreset] = [] + /// Last property-update error text, keyed by property class (shown under the field). + var propertyErrors: [String: String] = [:] + /// Transient mutation failure, shown as an alert while the form stays usable. + var mutationError: NKError? + /// A copy-link activation is in flight. + var isPreparingLink = false + /// Set once the draft has been activated (sent), so the sheet can dismiss. + var didActivate = false + /// Property classes with an update in flight; Send stays disabled until empty. + var pendingProperties: Set = [] + @ObservationIgnored private var searchTask: Task? + @ObservationIgnored private var propertyTasks: [String: Task] = [:] + let account: String + /// Globally-unique id of the file/folder being shared (attached as the share source). + let sourceId: String? + + init(account: String, sourceId: String? = nil) { + self.account = account + self.sourceId = sourceId + } + + /// Edit an already-existing share (from the list) rather than creating a new draft. + init(account: String, existingShare: NKUnifiedShare) { + self.account = account + self.sourceId = nil + self.state = .shareUpdated(share: existingShare) + } + +#if DEBUG + /// Preview-only initializer that starts in a given state. + init(account: String, + state: UnifiedShareViewState, + recipientResults: [NKUnifiedShareRecipient] = [], + permissionPresets: [NKUnifiedSharePermissionPreset] = []) { + self.account = account + self.sourceId = nil + self.state = state + self.recipientResults = recipientResults + self.permissionPresets = permissionPresets + } +#endif + + func loadCapabilities() { + Task { + let result = await NextcloudKit.shared.getUnifiedSharingCapabilities(account: account) + permissionPresets = result.capabilities?.permissionPresets ?? [] + } + } + + func setPermissionPreset(share: NKUnifiedShare, presetClass: String) { + Task { + let result = await NextcloudKit.shared.setUnifiedSharePermissionPreset(id: share.id, permissionPresetClass: presetClass, account: account) + guard let share = result.share else { + mutationError = result.error + return + } + + state = .shareUpdated(share: share) + } + } + + func setPermission(share: NKUnifiedShare, permissionClass: String, enabled: Bool) { + Task { + let result = await NextcloudKit.shared.setUnifiedSharePermission(id: share.id, permissionClass: permissionClass, enabled: enabled, account: account) + guard let share = result.share else { + mutationError = result.error + return + } + + state = .shareUpdated(share: share) + } + } + + func createShare() { + Task { + let result = await NextcloudKit.shared.createUnifiedShare(account: account) + guard var share = result.share else { + state = .error(result.error) + return + } + + // Point the draft at the actual file/folder being shared. + if let sourceId, !sourceId.isEmpty { + let sourceResult = await NextcloudKit.shared.addUnifiedShareSource(id: share.id, sourceClass: Self.nodeSourceClass, value: sourceId, account: account) + if let updated = sourceResult.share { + share = updated + } else { + mutationError = sourceResult.error + } + } + + state = .shareUpdated(share: share) + } + } + + /// Switch between an invited-people share and a public-link (token) share. + func setShareeType(share: NKUnifiedShare, anyone: Bool) { + Task { + var current = share + + if anyone { + var removalFailed = false + + for recipient in current.recipients where recipient.class != Self.tokenRecipientClass { + guard let updated = await removingRecipient(from: current, recipient: recipient) else { + removalFailed = true + break + } + + current = updated + } + + if !removalFailed, !current.recipients.contains(where: { $0.class == Self.tokenRecipientClass }) { + let result = await NextcloudKit.shared.addUnifiedShareRecipient(id: current.id, recipientClass: Self.tokenRecipientClass, value: UUID().uuidString, account: account) + if let updated = result.share { + current = updated + } else { + mutationError = result.error + } + } + } else { + for recipient in current.recipients where recipient.class == Self.tokenRecipientClass { + guard let updated = await removingRecipient(from: current, recipient: recipient) else { + break + } + + current = updated + } + } + + state = .shareUpdated(share: current) + } + } + + private func removingRecipient(from share: NKUnifiedShare, recipient: NKUnifiedShareRecipient) async -> NKUnifiedShare? { + let result = await NextcloudKit.shared.removeUnifiedShareRecipient(id: share.id, recipientClass: recipient.class, value: recipient.value, instance: recipient.instance, account: account) + + if result.share == nil { + mutationError = result.error + } + + return result.share + } + + /// Return the public link, activating the share first to mint it if needed. + func prepareLinkForCopy(share: NKUnifiedShare) async -> String? { + if let url = share.recipients.compactMap({ $0.secret.url }).first { + return url + } + + guard !isPreparingLink else { + return nil + } + + isPreparingLink = true + defer { isPreparingLink = false } + + let result = await NextcloudKit.shared.setUnifiedShareState(id: share.id, state: .active, account: account) + guard let updated = result.share else { + mutationError = result.error + return nil + } + + state = .shareUpdated(share: updated) + NotificationCenter.default.post(name: .unifiedShareDidChange, object: nil) + return updated.recipients.compactMap { $0.secret.url }.first + } + + /// Debounced, latest-wins: a new keystroke cancels the pending search so a slow + /// stale response can't overwrite fresher results. + func searchRecipients(query: String) { + searchTask?.cancel() + + guard !query.isEmpty else { + recipientResults = [] + return + } + + searchTask = Task { + try? await Task.sleep(for: Self.searchDebounce) + + guard !Task.isCancelled else { return } + + let result = await NextcloudKit.shared.searchUnifiedShareRecipients(query: query, limit: Self.searchLimit, account: account) + + guard !Task.isCancelled else { return } + + recipientResults = result.recipients ?? [] + } + } + + func deleteShare(share: NKUnifiedShare) { + Task { + await NextcloudKit.shared.deleteUnifiedShare(id: share.id, account: account) + } + } + + func addRecipient(share: NKUnifiedShare, recipient: NKUnifiedShareRecipient) { + Task { + let result = await NextcloudKit.shared.addUnifiedShareRecipient(id: share.id, recipientClass: recipient.class, value: recipient.value, account: account) + guard let share = result.share else { + mutationError = result.error + return + } + + state = .shareUpdated(share: share) + } + } + + func removeRecipient(share: NKUnifiedShare, recipient: NKUnifiedShareRecipient) { + Task { + let result = await NextcloudKit.shared.removeUnifiedShareRecipient(id: share.id, recipientClass: recipient.class, value: recipient.value, instance: recipient.instance, account: account) + guard let share = result.share else { + mutationError = result.error + return + } + + state = .shareUpdated(share: share) + } + } + + /// Latest-wins per property: a new commit cancels the pending one for the same class. + func setProperty(share: NKUnifiedShare, propertyClass: String, value: String?) { + propertyTasks[propertyClass]?.cancel() + + let isClearing = value?.isEmpty ?? true + + if isClearing { + propertyErrors[propertyClass] = nil + } + + pendingProperties.insert(propertyClass) + + propertyTasks[propertyClass] = Task { + let result = await NextcloudKit.shared.setUnifiedShareProperty(id: share.id, propertyClass: propertyClass, value: value, account: account) + + // A newer commit for this class superseded us; it owns the pending entry. + guard !Task.isCancelled else { return } + + pendingProperties.remove(propertyClass) + + guard let share = result.share else { + if !isClearing { + let description = result.error.errorDescription + propertyErrors[propertyClass] = description.isEmpty ? String(localized: "Failed to update share.") : description + } + + return + } + + propertyErrors[propertyClass] = nil + state = .shareUpdated(share: share) + } + } + + /// Activate the draft (draft → active). This is what persists the share and, for invited + /// recipients, triggers the server-side notification/email. + func activate(share: NKUnifiedShare) { + Task { + let result = await NextcloudKit.shared.setUnifiedShareState(id: share.id, state: .active, account: account) + guard let share = result.share else { + mutationError = result.error + return + } + + didActivate = true + state = .shareUpdated(share: share) + NotificationCenter.default.post(name: .unifiedShareDidChange, object: nil) + } + } + + /// Discard on dismiss only while still a draft (an activated/link share is kept). + func discardDraftIfNeeded(share: NKUnifiedShare) { + guard share.state == .draft else { + return + } + + deleteShare(share: share) + } + + func updateRecipientSecret(share: NKUnifiedShare, recipient: NKUnifiedShareRecipient, secret: String) { + Task { + let result = await NextcloudKit.shared.setUnifiedShareRecipientSecret(id: share.id, recipientClass: recipient.class, value: recipient.value, secret: secret, instance: recipient.instance, account: account) + guard let share = result.share else { + mutationError = result.error + return + } + + state = .shareUpdated(share: share) + } + } + + /// Mint a fresh server secret and apply it to the recipient (the "regenerate link" action). + func regenerateRecipientSecret(share: NKUnifiedShare, recipient: NKUnifiedShareRecipient) { + Task { + let generated = await NextcloudKit.shared.generateUnifiedShareSecret(account: account) + guard let secret = generated.secret else { + mutationError = generated.error + return + } + + updateRecipientSecret(share: share, recipient: recipient, secret: secret) + } + } +} + diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift new file mode 100644 index 00000000..1074b867 --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareEditView.swift @@ -0,0 +1,875 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import NextcloudKit +#if canImport(UIKit) +import UIKit +#endif + +/// View used for Unified Sharing. +public struct UnifiedShareEditView: View { + /// Editing an existing share (vs composing a new draft): the audience is fixed. + let isEditingExisting: Bool + /// The file's in-server link, copied for invited-people shares. + let internalLink: String? + @State private var model: UnifiedShareEditModel + + @State private var shareeType: ShareeType = .invited + @State private var permissionSelection: PermissionSelection = .unset + @State private var isSettingsExpanded = false + @State private var recipients = "" + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + + public init(account: String, sourceId: String? = nil, internalLink: String? = nil) { + self.isEditingExisting = false + self.internalLink = internalLink + model = UnifiedShareEditModel(account: account, sourceId: sourceId) + } + + /// Open the editor on an existing share (from the list). + public init(account: String, share: NKUnifiedShare, internalLink: String? = nil, expandSettings: Bool = false, forceCustomPermissions: Bool = false) { + self.isEditingExisting = true + self.internalLink = internalLink + model = UnifiedShareEditModel(account: account, existingShare: share) + _shareeType = State(initialValue: share.recipients.contains { $0.class == UnifiedShareEditModel.tokenRecipientClass } ? .anyone : .invited) + _isSettingsExpanded = State(initialValue: expandSettings) + _permissionSelection = State(initialValue: forceCustomPermissions ? .custom : .unset) + } + + init(model: UnifiedShareEditModel) { + self.isEditingExisting = false + self.internalLink = nil + self.model = model + } + + public var body: some View { + ZStack { + switch model.state { + case .loading: + ProgressView() + case .shareUpdated(let share): + + Form { + // The audience is only selectable for a new draft; an existing share's is fixed. + if !isEditingExisting { + shareeTypePicker(share: share) + } + + if shareeType == .invited { + if !peopleRecipients(share).isEmpty { + recipientPills(share: share) + .listRowSeparator(.hidden) + } + + TextField( + String(localized: "Add people"), + text: $recipients + ) + .onChange(of: recipients) { + model.searchRecipients(query: recipients) + } + // Publish the field's frame so the dropdown can be drawn outside the Form. + .anchorPreference(key: AddPeopleFieldAnchorKey.self, value: .bounds) { $0 } + } + + permissionField(share: share) + + ForEach(basicProperties(share), id: \.class) { property in + PropertyRow(property: property, error: model.propertyErrors[property.class]) { value in + model.setProperty(share: share, propertyClass: property.class, value: value) + } + } + + settingsRow(share: share) + + if structuralCanSend(share) { + actionButtons(share: share) + } + } + .onDisappear { + model.discardDraftIfNeeded(share: share) + } + .onChange(of: model.didActivate) { + if model.didActivate { + dismiss() + } + } + // Draw the dropdown above the Form, anchored just beneath the field, so the + // Form's row clipping can't cut it off. + .overlayPreferenceValue(AddPeopleFieldAnchorKey.self) { anchor in + GeometryReader { proxy in + if let anchor, !model.recipientResults.isEmpty { + let frame = proxy[anchor] + + recipientDropdown(share: share) + .frame(width: frame.width) + .offset(x: frame.minX, y: frame.maxY + 4) + } + } + } + + case .error(let error): + Text(error.localizedDescription) + } + } + .navigationTitle(isEditingExisting ? String(localized: "Edit share") : String(localized: "Create a new share")) + .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled() + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + } + } + .alert( + String(localized: "Failed to update share."), + isPresented: Binding( + get: { model.mutationError != nil }, + set: { if !$0 { model.mutationError = nil } } + ) + ) { + Button(String(localized: "OK"), role: .cancel) {} + } message: { + if let error = model.mutationError { + Text(error.errorDescription) + } + } + .task { + guard !isPreview else { + return + } + + if case .loading = model.state { + model.createShare() + } + + if model.permissionPresets.isEmpty { + model.loadCapabilities() + } + } + } + + private var isPreview: Bool { + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + } + + private func shareeTypePicker(share: NKUnifiedShare) -> some View { + Picker("", selection: $shareeType) { + Text(String(localized: "Invited People")) + .tag(ShareeType.invited) + + Text(String(localized: "Anyone")) + .tag(ShareeType.anyone) + } + .pickerStyle(.segmented) + .listRowSeparator(.hidden) + .onChange(of: shareeType) { + model.setShareeType(share: share, anyone: shareeType == .anyone) + } + } + + @ViewBuilder + private func permissionField(share: NKUnifiedShare) -> some View { + Picker(String(localized: "Participants"), selection: Binding( + get: { isCustomSelected(share) ? Self.customTag : (selectedPresetClass(share) ?? Self.customTag) }, + set: { tag in + if tag == Self.customTag { + permissionSelection = .custom + } else { + permissionSelection = .preset(tag) + model.setPermissionPreset(share: share, presetClass: tag) + } + } + )) { + ForEach(applicablePresets(share), id: \.class) { preset in + Text(preset.displayName) + .tag(preset.class) + } + + // Custom: reveals the per-permission toggles below. Client-side only, no request. + Text(String(localized: "Can…")) + .tag(Self.customTag) + } + .pickerStyle(.menu) + + if isCustomSelected(share) { + ForEach(share.permissions, id: \.class) { permission in + PermissionToggleRow(permission: permission) { enabled in + model.setPermission(share: share, permissionClass: permission.class, enabled: enabled) + } + // Re-seed the toggle whenever the server's enabled value changes (e.g. after a + // preset like "Can edit" recomputes the permissions), not just on first render. + .id(permission.enabled) + } + } + } + + private static let customTag = "__nk_custom_permissions__" + + /// Presets from the capability, narrowed to those the share's permissions reference. + private func applicablePresets(_ share: NKUnifiedShare) -> [NKUnifiedSharePermissionPreset] { + let applicable = Set(share.permissions.flatMap { $0.presets }) + return model.permissionPresets.filter { applicable.contains($0.class) } + } + + /// The effective preset class: the user's pick, else the share's server-side preset. + private func selectedPresetClass(_ share: NKUnifiedShare) -> String? { + switch permissionSelection { + case .unset: return share.permissionPreset + case .custom: return nil + case .preset(let presetClass): return presetClass + } + } + + /// Custom mode (toggles shown) when there's no preset, or the preset isn't a known one. + private func isCustomSelected(_ share: NKUnifiedShare) -> Bool { + guard let presetClass = selectedPresetClass(share) else { + return true + } + + return !applicablePresets(share).contains { $0.class == presetClass } + } + + /// Advanced properties + editable link tokens live behind the disclosure; basic properties inline. + @ViewBuilder + private func settingsRow(share: NKUnifiedShare) -> some View { + if !advancedProperties(share).isEmpty || !customLinkRecipients(share).isEmpty { + DisclosureGroup(isExpanded: $isSettingsExpanded) { + ForEach(advancedProperties(share), id: \.class) { property in + PropertyRow(property: property, error: model.propertyErrors[property.class]) { value in + model.setProperty(share: share, propertyClass: property.class, value: value) + } + } + + customLinkRows(share: share) + } label: { + Text(String(localized: "Settings")) + } + } + } + + @ViewBuilder + private func customLinkRows(share: NKUnifiedShare) -> some View { + if !customLinkRecipients(share).isEmpty { + ForEach(customLinkRecipients(share), id: \.value) { recipient in + CustomLinkRow( + recipient: recipient, + onCommit: { token in model.updateRecipientSecret(share: share, recipient: recipient, secret: token) }, + onRegenerate: { model.regenerateRecipientSecret(share: share, recipient: recipient) } + ) + } + + Text(String(localized: "The link can be changed to be easy to remember, but do not set it to something that is easy to guess.")) + .font(.footnote) + .foregroundStyle(.secondary) + .listRowSeparator(.hidden) + } + } + + private func basicProperties(_ share: NKUnifiedShare) -> [NKUnifiedShareProperty] { + share.properties.filter { !$0.advanced }.sorted { $0.priority < $1.priority } + } + + private func advancedProperties(_ share: NKUnifiedShare) -> [NKUnifiedShareProperty] { + share.properties.filter { $0.advanced }.sorted { $0.priority < $1.priority } + } + + /// Recipients whose secret can be edited — i.e. custom/private links. + private func customLinkRecipients(_ share: NKUnifiedShare) -> [NKUnifiedShareRecipient] { + share.recipients.filter { $0.secret.updatable } + } + + private func recipientDropdown(share: NKUnifiedShare) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(model.recipientResults, id: \.value) { recipient in + Button { + model.addRecipient(share: share, recipient: recipient) + recipients = "" + } label: { + HStack(spacing: 10) { + if let icon = recipient.icon { + recipientIcon(icon) + } + + Text(recipient.displayName) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if recipient.value != model.recipientResults.last?.value { + Divider() + } + } + } + } + .frame(height: dropdownHeight) + .background(.background) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(.quaternary) + } + .shadow(radius: 4, y: 2) + } + + /// Height of the suggestions dropdown: one row each, capped so it stays a dropdown. + private var dropdownHeight: CGFloat { + min(CGFloat(model.recipientResults.count) * 44, 220) + } + + @ViewBuilder + private func recipientIcon(_ icon: NKUnifiedShareIcon) -> some View { + if let urlString = (colorScheme == .dark ? icon.dark : icon.light) ?? icon.light ?? icon.dark, + let url = URL(string: urlString) { + AsyncImage(url: url) { image in + image + .resizable() + .scaledToFit() + } placeholder: { + ProgressView() + } + .frame(width: 24, height: 24) + .clipShape(Circle()) + } + } + + private func recipientPills(share: NKUnifiedShare) -> some View { + FlowLayout(spacing: 8) { + ForEach(peopleRecipients(share), id: \.value) { recipient in + recipientPill(recipient, share: share) + } + } + } + + private func peopleRecipients(_ share: NKUnifiedShare) -> [NKUnifiedShareRecipient] { + share.recipients.filter { !$0.secret.updatable } + } + + private func recipientPill(_ recipient: NKUnifiedShareRecipient, share: NKUnifiedShare) -> some View { + HStack(spacing: 6) { + recipientAvatar(recipient) + + Text(recipient.displayName) + .lineLimit(1) + + Button { + model.removeRecipient(share: share, recipient: recipient) + } label: { + Image(systemName: "xmark") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Capsule().fill(.quaternary)) + .overlay(Capsule().stroke(.tertiary, lineWidth: 0.5)) + .contentShape(Capsule()) + .onTapGesture { + model.removeRecipient(share: share, recipient: recipient) + } + } + + /// The recipient's URL avatar when available, otherwise a circle with its initial. + @ViewBuilder + private func recipientAvatar(_ recipient: NKUnifiedShareRecipient) -> some View { + if let icon = recipient.icon, icon.light != nil || icon.dark != nil { + recipientIcon(icon) + } else { + Circle() + .fill(.quaternary) + .frame(width: 24, height: 24) + .overlay { + Text(recipient.displayName.prefix(1).uppercased()) + .font(.caption) + } + } + } + + private func actionButtons(share: NKUnifiedShare) -> some View { + HStack(spacing: 16) { + copyButton(share: share) + + Button(sendLabel) { + model.activate(share: share) + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .disabled(!canSend(share)) + } + .padding(.top, 18) + } + + /// Anyone: copy the public link, activating the draft to mint it if needed. + /// Invited: copy the internal link — never activates (that would send the share). + @ViewBuilder + private func copyButton(share: NKUnifiedShare) -> some View { + if shareeType == .anyone { + Button { + Task { + if let link = await model.prepareLinkForCopy(share: share) { + copyToPasteboard(link) + } + } + } label: { + if model.isPreparingLink { + ProgressView() + } else { + Text(String(localized: "Copy public link")) + } + } + .buttonStyle(.bordered) + .frame(maxWidth: .infinity) + .disabled(model.isPreparingLink || !canSend(share)) + } else { + Button(String(localized: "Copy private link")) { + if let internalLink { + copyToPasteboard(internalLink) + } + } + .buttonStyle(.bordered) + .frame(maxWidth: .infinity) + .disabled(internalLink == nil) + } + } + + private var sendLabel: String { + shareeType == .anyone ? String(localized: "Share public link") : String(localized: "Send") + } + + /// Structural sendability — gates whether the action buttons render at all. + private func structuralCanSend(_ share: NKUnifiedShare) -> Bool { + !share.sources.isEmpty + && !share.recipients.isEmpty + && share.permissions.contains { $0.enabled } + && !share.properties.contains { $0.required && ($0.value ?? "").isEmpty } + } + + private func canSend(_ share: NKUnifiedShare) -> Bool { + structuralCanSend(share) + && model.propertyErrors.isEmpty + && model.pendingProperties.isEmpty + } + + private func copyToPasteboard(_ string: String) { + #if canImport(UIKit) + UIPasteboard.general.string = string + #endif + } +} + +/// A simple left-to-right layout that wraps its subviews onto new rows when they overflow. +private struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var rowWidth: CGFloat = 0 + var rowHeight: CGFloat = 0 + var totalWidth: CGFloat = 0 + var totalHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + + if rowWidth > 0, rowWidth + spacing + size.width > maxWidth { + totalWidth = max(totalWidth, rowWidth) + totalHeight += rowHeight + spacing + rowWidth = size.width + rowHeight = size.height + } else { + rowWidth += (rowWidth > 0 ? spacing : 0) + size.width + rowHeight = max(rowHeight, size.height) + } + } + + totalWidth = max(totalWidth, rowWidth) + totalHeight += rowHeight + return CGSize(width: min(totalWidth, maxWidth), height: totalHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) { + var x = bounds.minX + var y = bounds.minY + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + + if x > bounds.minX, x + size.width > bounds.maxX { + x = bounds.minX + y += rowHeight + spacing + rowHeight = 0 + } + + subview.place(at: CGPoint(x: x, y: y), anchor: .topLeading, proposal: ProposedViewSize(size)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} + +/// Carries the "Add people" field's frame up to the ZStack so the dropdown can sit beneath it. +private struct AddPeopleFieldAnchorKey: PreferenceKey { + static let defaultValue: Anchor? = nil + + static func reduce(value: inout Anchor?, nextValue: () -> Anchor?) { + value = value ?? nextValue() + } +} + +private extension UnifiedShareEditView { + enum ShareeType { + case invited + case anyone + } + + enum PermissionSelection: Equatable { + case unset + case custom + case preset(String) + } +} + +private struct PermissionToggleRow: View { + let permission: NKUnifiedSharePermission + let onChange: (Bool) -> Void + + @State private var isOn: Bool + + init(permission: NKUnifiedSharePermission, onChange: @escaping (Bool) -> Void) { + self.permission = permission + self.onChange = onChange + _isOn = State(initialValue: permission.enabled) + } + + var body: some View { + Toggle(permission.displayName, isOn: $isOn) + .onChange(of: isOn) { + onChange(isOn) + } + } +} + +/// Renders the right editor for a property's concrete type, with a hint/error caption. +private struct PropertyRow: View { + let property: NKUnifiedShareProperty + let error: String? + let onCommit: (String?) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + editor + + if let caption { + Text(caption) + .font(.caption) + .foregroundStyle(error != nil ? Color.red : Color.secondary) + } + } + } + + private var caption: String? { + if let error, !error.isEmpty { + return error + } + + if let hint = property.hint, !hint.isEmpty { + return hint + } + + return nil + } + + @ViewBuilder + private var editor: some View { + switch property { + case let property as NKUnifiedSharePropertyBoolean: + BooleanPropertyEditor(property: property, onCommit: onCommit) + case let property as NKUnifiedSharePropertyEnum: + EnumPropertyEditor(property: property, onCommit: onCommit) + case let property as NKUnifiedSharePropertyDate: + DatePropertyEditor(property: property, onCommit: onCommit) + case let property as NKUnifiedSharePropertyPassword: + TextPropertyEditor(property: property, secure: true, onCommit: onCommit) + case let property as NKUnifiedSharePropertyString: + TextPropertyEditor(property: property, secure: false, onCommit: onCommit) + default: + LabeledContent(property.displayName) { + Text(property.value ?? "").foregroundStyle(.secondary) + } + } + } +} + +private struct BooleanPropertyEditor: View { + let property: NKUnifiedSharePropertyBoolean + let onCommit: (String?) -> Void + @State private var isOn: Bool + + init(property: NKUnifiedSharePropertyBoolean, onCommit: @escaping (String?) -> Void) { + self.property = property + self.onCommit = onCommit + _isOn = State(initialValue: property.value == "true") + } + + var body: some View { + Toggle(property.displayName, isOn: $isOn) + .onChange(of: isOn) { + onCommit(isOn ? "true" : "false") + } + } +} + +private struct EnumPropertyEditor: View { + let property: NKUnifiedSharePropertyEnum + let onCommit: (String?) -> Void + /// nil while the property is unset — the picker shows no value until the user picks one. + @State private var selection: String? + + init(property: NKUnifiedSharePropertyEnum, onCommit: @escaping (String?) -> Void) { + self.property = property + self.onCommit = onCommit + + let value = property.value + _selection = State(initialValue: (value?.isEmpty ?? true) ? nil : value) + } + + var body: some View { + Picker(property.displayName, selection: $selection) { + ForEach(property.validValues, id: \.self) { value in + Text(value).tag(String?.some(value)) + } + } + .pickerStyle(.menu) + .onChange(of: selection) { + if let selection { + onCommit(selection) + } + } + } +} + +private struct DatePropertyEditor: View { + let property: NKUnifiedSharePropertyDate + let onCommit: (String?) -> Void + @State private var date: Date + @State private var hasDate: Bool + + init(property: NKUnifiedSharePropertyDate, onCommit: @escaping (String?) -> Void) { + self.property = property + self.onCommit = onCommit + let parsed = Self.parse(property.value) + _date = State(initialValue: parsed ?? Date()) + _hasDate = State(initialValue: parsed != nil) + } + + var body: some View { + if hasDate { + HStack(spacing: 12) { + DatePicker(property.displayName, selection: $date, in: dateRange, displayedComponents: .date) + .onChange(of: date) { + onCommit(Self.format(date)) + } + + Button { + hasDate = false + + // Nothing to clear server-side while the picker was only revealed. + if let value = property.value, !value.isEmpty { + onCommit("") + } + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .padding(.leading, 4) + } + } else { + // Revealing the picker commits nothing; the first user-chosen date does. + Button { + hasDate = true + } label: { + HStack { + Text(property.displayName) + .foregroundStyle(.primary) + Spacer() + Image(systemName: "calendar.badge.plus") + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + + private var dateRange: ClosedRange { + let today = Calendar.current.startOfDay(for: Date()) + let lower = max(Self.parse(property.minDate) ?? today, today) + let upper = Self.parse(property.maxDate) ?? .distantFuture + + return lower...max(lower, upper) + } + + private static let formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withColonSeparatorInTimeZone] + formatter.timeZone = .current + return formatter + }() + + static func parse(_ string: String?) -> Date? { + guard let string, !string.isEmpty else { + return nil + } + + return formatter.date(from: string) ?? ISO8601DateFormatter().date(from: string) + } + + static func format(_ date: Date) -> String { + formatter.string(from: Calendar.current.startOfDay(for: date)) + } +} + +private struct TextPropertyEditor: View { + let property: NKUnifiedShareProperty + let secure: Bool + let onCommit: (String?) -> Void + @State private var text: String + @State private var committed: String + @FocusState private var focused: Bool + + init(property: NKUnifiedShareProperty, secure: Bool, onCommit: @escaping (String?) -> Void) { + self.property = property + self.secure = secure + self.onCommit = onCommit + let initial = property.value ?? "" + _text = State(initialValue: initial) + _committed = State(initialValue: initial) + } + + var body: some View { + field + .focused($focused) + .onChange(of: focused) { + if !focused { + commit() + } + } + .onSubmit { + commit() + } + } + + @ViewBuilder + private var field: some View { + if secure { + SecureField(property.displayName, text: $text) + } else { + TextField(property.displayName, text: $text) + } + } + + private func commit() { + guard text != committed else { + return + } + + committed = text + onCommit(text) + } +} + +private struct CustomLinkRow: View { + let recipient: NKUnifiedShareRecipient + let onCommit: (String) -> Void + let onRegenerate: () -> Void + private let prefix: String + @State private var token: String + @State private var committed: String + @FocusState private var focused: Bool + + private static let maxTokenLength = 32 + + init(recipient: NKUnifiedShareRecipient, onCommit: @escaping (String) -> Void, onRegenerate: @escaping () -> Void) { + self.recipient = recipient + self.onCommit = onCommit + self.onRegenerate = onRegenerate + let initial = recipient.secret.value ?? "" + _token = State(initialValue: initial) + _committed = State(initialValue: initial) + + // The prefix is the link URL with the token suffix stripped (e.g. ".../index.php/s/"). + let url = recipient.secret.url ?? "" + if !initial.isEmpty, url.hasSuffix(initial) { + self.prefix = String(url.dropLast(initial.count)) + } else { + self.prefix = url + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "Custom link")) + .font(.headline) + + if !prefix.isEmpty { + Text(prefix) + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack { + TextField(String(localized: "Link token"), text: $token) + .focused($focused) + .onChange(of: token) { + if token.count > Self.maxTokenLength { + token = String(token.prefix(Self.maxTokenLength)) + } + } + .onChange(of: focused) { + if !focused, token != committed, !token.isEmpty { + committed = token + onCommit(token) + } + } + + Button { + onRegenerate() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "Refresh link")) + } + } + .padding(.vertical, 4) + } +} + +#Preview { + UnifiedShareEditView( + model: UnifiedShareEditModel( + account: "", + state: .shareUpdated(share: .mock), + recipientResults: .mocks, + permissionPresets: [ + NKUnifiedSharePermissionPreset(class: "viewer", displayName: "Can view"), + NKUnifiedSharePermissionPreset(class: "editor", displayName: "Can edit") + ] + ) + ) +} diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareListModel.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareListModel.swift new file mode 100644 index 00000000..428a7d3f --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareListModel.swift @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import NextcloudKit + +extension Notification.Name { + /// Posted when a share is created/activated elsewhere, so any visible list can refresh. + static let unifiedShareDidChange = Notification.Name("unifiedShareDidChange") +} + +enum UnifiedShareListState { + case loading + case loaded([NKUnifiedShare]) + case error(Error) +} + +@MainActor +@Observable +public class UnifiedShareListModel { + private static let pageSize = 100 + private static let maxPages = 20 + + var state: UnifiedShareListState = .loading + var permissionPresets: [NKUnifiedSharePermissionPreset] = [] + /// Handler for failures that shouldn't replace visible content (the app shows a banner). + let onError: ((NKError) -> Void)? + let account: String + let sourceId: String? + + init(account: String, sourceId: String?, onError: ((NKError) -> Void)? = nil) { + self.account = account + self.sourceId = sourceId + self.onError = onError + } + + func load() { + Task { + if permissionPresets.isEmpty { + let capabilities = await NextcloudKit.shared.getUnifiedSharingCapabilities(account: account) + permissionPresets = capabilities.capabilities?.permissionPresets ?? [] + } + + await refresh() + } + } + + func refresh() async { + var all: [NKUnifiedShare] = [] + var lastShareID: String? + + // The server pages drafts and active shares together, so fetch every page + // before filtering — otherwise actives past page one would be missed. + for _ in 0.. [NKUnifiedSharePermissionPreset] { + let applicable = Set(share.permissions.flatMap { $0.presets }) + return permissionPresets.filter { applicable.contains($0.class) } + } + + func setPermissionPreset(share: NKUnifiedShare, presetClass: String) { + Task { + let result = await NextcloudKit.shared.setUnifiedSharePermissionPreset(id: share.id, permissionPresetClass: presetClass, account: account) + guard let updated = result.share else { + onError?(result.error) + return + } + + replace(updated) + } + } + + private func replace(_ updated: NKUnifiedShare) { + if case .loaded(let shares) = state { + state = .loaded(shares.map { $0.id == updated.id ? updated : $0 }) + } + } + + func delete(share: NKUnifiedShare) { + Task { + let result = await NextcloudKit.shared.deleteUnifiedShare(id: share.id, account: account) + + guard result.error == .success else { + onError?(result.error) + return + } + + if case .loaded(let shares) = state { + state = .loaded(shares.filter { $0.id != share.id }) + } + } + } +} diff --git a/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareListView.swift b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareListView.swift new file mode 100644 index 00000000..f82eb3a4 --- /dev/null +++ b/Sources/NextcloudKitUI/Views/Unified Share/UnifiedShareListView.swift @@ -0,0 +1,288 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import NextcloudKit + +/// Lists the existing unified shares for a file, with tap-to-edit and swipe-to-delete. +public struct UnifiedShareListView: View { + let fileName: String + let account: String + /// Brand/accent color (the app passes NCBrandColor); used for the chip and ⋯ button. + let tint: Color + /// The file's in-server link, forwarded to the editor for invited-people shares. + let internalLink: String? + @State private var model: UnifiedShareListModel + @State private var editing: ShareEditor? + @State private var shareToDelete: NKUnifiedShare? + @Environment(\.colorScheme) private var colorScheme + + public init(fileName: String, account: String, sourceId: String? = nil, internalLink: String? = nil, tint: Color = .accentColor, onError: ((NKError) -> Void)? = nil) { + self.fileName = fileName + self.account = account + self.tint = tint + self.internalLink = internalLink + model = UnifiedShareListModel(account: account, sourceId: sourceId, onError: onError) + } + + public var body: some View { + content + .task { + if case .loading = model.state { + model.load() + } + } + .sheet(item: $editing, onDismiss: { + Task { await model.refresh() } + }) { editor in + NavigationStack { + UnifiedShareEditView( + account: account, + share: editor.share, + internalLink: internalLink, + expandSettings: editor.expandSettings, + forceCustomPermissions: editor.forceCustomPermissions + ) + } + } + // A share created/activated from the "+" modal (outside this view) refreshes the list. + .onReceive(NotificationCenter.default.publisher(for: .unifiedShareDidChange)) { _ in + Task { await model.refresh() } + } + } + + @ViewBuilder + private var content: some View { + switch model.state { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .error(let error): + ContentUnavailableView { + Label(String(localized: "Couldn't load shares"), systemImage: "exclamationmark.triangle") + } description: { + Text(error.localizedDescription) + } actions: { + Button(String(localized: "Retry")) { + model.load() + } + } + + case .loaded(let shares): + List { + ForEach(shares) { share in + shareRow(share, allShares: shares) + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + shareToDelete = share + } label: { + Label(String(localized: "Delete"), systemImage: "trash") + } + .tint(.red) + } + } + } + .listStyle(.insetGrouped) + .refreshable { + await model.refresh() + } + .overlay { + if shares.isEmpty { + ContentUnavailableView( + String(localized: "No shares yet"), + systemImage: "person.2.slash", + description: Text(String(localized: "Use the + button to share \(fileName).")) + ) + } + } + } + } + + private func shareRow(_ share: NKUnifiedShare, allShares: [NKUnifiedShare]) -> some View { + HStack(spacing: 12) { + shareIcon(share) + .frame(width: 32, height: 32) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(headline(share, in: allShares)) + .foregroundStyle(.primary) + + presetChip(share) + } + + Spacer() + + overflowMenu(share) + .confirmationDialog( + String(localized: "Delete share?"), + isPresented: Binding( + get: { shareToDelete?.id == share.id }, + set: { if !$0 { shareToDelete = nil } } + ), + titleVisibility: .visible + ) { + Button(String(localized: "Delete"), role: .destructive) { + model.delete(share: share) + shareToDelete = nil + } + + Button(String(localized: "Cancel"), role: .cancel) { + shareToDelete = nil + } + } message: { + Text(String(localized: "This share will be permanently removed.")) + } + } + .contentShape(Rectangle()) + .onTapGesture { + editing = ShareEditor(share: share) + } + } + + // MARK: - Row content + + @ViewBuilder + private func shareIcon(_ share: NKUnifiedShare) -> some View { + if let icon = share.recipients.first?.icon, + let urlString = iconURL(icon), + let url = URL(string: urlString) { + AsyncImage(url: url) { image in + image.resizable().scaledToFill() + } placeholder: { + Circle().fill(.quaternary) + } + } else { + linkIcon + } + } + + private var linkIcon: some View { + Circle() + .fill(.quaternary) + .overlay { + Image(systemName: "link") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + + private func headline(_ share: NKUnifiedShare, in shares: [NKUnifiedShare]) -> String { + guard isLink(share) else { + return peopleRecipients(share).first?.displayName ?? "" + } + + if let label = label(share) { + return label + } + + let links = shares.filter { isLink($0) } + if links.count <= 1 { + return String(localized: "Share link") + } + + let position = (links.sorted { $0.lastUpdated < $1.lastUpdated }.firstIndex { $0.id == share.id } ?? 0) + 1 + return String(localized: "Share link (\(position))") + } + + /// The preset chip — a menu of the applicable presets plus "Custom permissions" (opens the editor). + private func presetChip(_ share: NKUnifiedShare) -> some View { + Menu { + ForEach(model.applicablePresets(share), id: \.class) { preset in + Button(preset.displayName) { + model.setPermissionPreset(share: share, presetClass: preset.class) + } + } + + Divider() + + Button(String(localized: "Can…")) { + editing = ShareEditor(share: share, forceCustomPermissions: true) + } + } label: { + HStack(spacing: 2) { + Text(presetLabel(share)) + Image(systemName: "chevron.down") + .font(.caption2) + } + .font(.subheadline) + .foregroundStyle(tint) + .padding(.horizontal, 10) + .padding(.vertical, 2) + .background(tint.opacity(0.12), in: Capsule()) + } + .menuStyle(.borderlessButton) + .fixedSize() + } + + private func overflowMenu(_ share: NKUnifiedShare) -> some View { + Menu { + Button { + editing = ShareEditor(share: share) + } label: { + Label(String(localized: "Edit"), systemImage: "pencil") + } + + Button { + editing = ShareEditor(share: share, expandSettings: true) + } label: { + Label(String(localized: "Send email"), systemImage: "envelope") + } + + Button(role: .destructive) { + shareToDelete = share + } label: { + Label(String(localized: "Delete"), systemImage: "trash") + } + .tint(.red) + } label: { + Image(systemName: "ellipsis") + .foregroundStyle(.primary) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .tint(.primary) + } + + private func presetLabel(_ share: NKUnifiedShare) -> String { + if let presetClass = share.permissionPreset, + let preset = model.permissionPresets.first(where: { $0.class == presetClass }) { + return preset.displayName + } + + return String(localized: "Can…") + } + + // MARK: - Helpers + + private func isLink(_ share: NKUnifiedShare) -> Bool { + share.recipients.contains { $0.secret.updatable } + } + + private func peopleRecipients(_ share: NKUnifiedShare) -> [NKUnifiedShareRecipient] { + share.recipients.filter { !$0.secret.updatable } + } + + /// A property whose class contains "label" carries the link's custom label. + private func label(_ share: NKUnifiedShare) -> String? { + let value = share.properties.first { $0.class.range(of: "label", options: .caseInsensitive) != nil }?.value + guard let value, !value.isEmpty else { + return nil + } + + return value + } + + private func iconURL(_ icon: NKUnifiedShareIcon) -> String? { + (colorScheme == .dark ? icon.dark : icon.light) ?? icon.light ?? icon.dark + } +} + +private struct ShareEditor: Identifiable { + let share: NKUnifiedShare + var expandSettings = false + var forceCustomPermissions = false + var id: String { share.id } +} diff --git a/Tests/NextcloudKitUnitTests/UnifiedShareDecodingTests.swift b/Tests/NextcloudKitUnitTests/UnifiedShareDecodingTests.swift new file mode 100644 index 00000000..ac4c3f9a --- /dev/null +++ b/Tests/NextcloudKitUnitTests/UnifiedShareDecodingTests.swift @@ -0,0 +1,264 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Testing +@testable import NextcloudKit + +/// Verifies that `NKUnifiedShare` decodes through `NKOCSWrapper` and that the polymorphic +/// `properties` array yields the right concrete `NKUnifiedShareProperty` subclass per element. +@Suite("Unified share Codable") +struct UnifiedShareDecodingTests { + private func decodeShare(json: String) throws -> NKUnifiedShare { + let data = Data(json.utf8) + let wrap = try JSONDecoder().decode(NKOCSWrapper.self, from: data) + return wrap.ocs.data + } + + @Test("Decodes all five property variants into the matching subclass") + func decodesAllPropertyVariants() throws { + let json = """ + { + "ocs": { + "meta": { "status": "ok", "statuscode": 200 }, + "data": { + "id": "s1", + "owner": { + "user_id": "alice", + "instance": null, + "display_name": "Alice", + "icon": { "svg": "" } + }, + "last_updated": 1730000000000, + "state": "active", + "sources": [], + "recipients": [], + "permissions": [], + "properties": [ + { + "class": "expiration", + "display_name": "Expiration", + "hint": null, + "priority": 10, + "required": false, + "advanced": false, + "value": null, + "type": "date", + "min_date": "2026-01-01", + "max_date": null + }, + { + "class": "role", + "display_name": "Role", + "hint": null, + "priority": 20, + "required": true, + "advanced": true, + "value": "editor", + "type": "enum", + "valid_values": ["viewer", "editor"] + }, + { + "class": "download", + "display_name": "Allow download", + "hint": null, + "priority": 30, + "required": false, + "advanced": false, + "value": "true", + "type": "boolean" + }, + { + "class": "password", + "display_name": "Password", + "hint": "Min 8 chars", + "priority": 40, + "required": false, + "advanced": true, + "value": null, + "type": "password" + }, + { + "class": "note", + "display_name": "Note", + "hint": null, + "priority": 50, + "required": false, + "advanced": false, + "value": "hi", + "type": "string", + "min_length": 0, + "max_length": 1000 + } + ] + } + } + } + """ + + let share = try decodeShare(json: json) + + #expect(share.id == "s1") + #expect(share.state == .active) + #expect(share.lastUpdated == 1_730_000_000_000) + #expect(share.properties.count == 5) + + let p0 = try #require(share.properties[0] as? NKUnifiedSharePropertyDate) + #expect(p0.type == .date) + #expect(p0.advanced == false) + #expect(p0.minDate == "2026-01-01") + #expect(p0.maxDate == nil) + + let p1 = try #require(share.properties[1] as? NKUnifiedSharePropertyEnum) + #expect(p1.type == .enumeration) + #expect(p1.advanced == true) + #expect(p1.validValues == ["viewer", "editor"]) + + let p2 = try #require(share.properties[2] as? NKUnifiedSharePropertyBoolean) + #expect(p2.type == .boolean) + #expect(p2.value == "true") + + let p3 = try #require(share.properties[3] as? NKUnifiedSharePropertyPassword) + #expect(p3.type == .password) + #expect(p3.hint == "Min 8 chars") + + let p4 = try #require(share.properties[4] as? NKUnifiedSharePropertyString) + #expect(p4.type == .string) + #expect(p4.minLength == 0) + #expect(p4.maxLength == 1000) + } + + @Test("Decodes both Icon shapes (svg / light+dark)") + func decodesIconVariants() throws { + let json = """ + { + "ocs": { + "meta": { "status": "ok", "statuscode": 200 }, + "data": { + "id": "s2", + "owner": { + "user_id": "bob", + "instance": null, + "display_name": "Bob", + "icon": { "svg": "" } + }, + "last_updated": 0, + "state": "draft", + "sources": [ + { + "class": "file", + "value": "/foo.txt", + "display_name": "foo.txt", + "icon": { "light": "https://x/light.png", "dark": "https://x/dark.png" } + } + ], + "recipients": [], + "permissions": [], + "properties": [] + } + } + } + """ + + let share = try decodeShare(json: json) + + #expect(share.owner.icon.svg == "") + #expect(share.owner.icon.light == nil) + let source = try #require(share.sources.first) + #expect(source.icon?.svg == nil) + #expect(source.icon?.light == "https://x/light.png") + #expect(source.icon?.dark == "https://x/dark.png") + } + + @Test("Decodes a list response via NKOCSWrapper<[NKUnifiedShare]>") + func decodesShareListEnvelope() throws { + let json = """ + { + "ocs": { + "meta": { "status": "ok", "statuscode": 200, "message": "OK" }, + "data": [] + } + } + """ + let wrap = try JSONDecoder().decode(NKOCSWrapper<[NKUnifiedShare]>.self, from: Data(json.utf8)) + #expect(wrap.ocs.meta.statuscode == 200) + #expect(wrap.ocs.meta.message == "OK") + #expect(wrap.ocs.data.isEmpty) + } + + @Test("Decodes permission, recipient secret/initiator and share permission_preset") + func decodesPermissionRecipientAndPreset() throws { + let json = """ + { + "ocs": { + "meta": { "status": "ok", "statuscode": 200 }, + "data": { + "id": "s3", + "owner": { "user_id": "alice", "instance": null, "display_name": "Alice", "icon": { "svg": "" } }, + "last_updated": 0, + "state": "active", + "sources": [], + "recipients": [ + { + "class": "link", + "value": "token", + "instance": null, + "display_name": "Public link", + "icon": null, + "secret": { "updatable": true, "url": "https://x/s/abc" }, + "initiator": { "user_id": "alice", "instance": null, "display_name": "Alice", "icon": { "svg": "" } } + } + ], + "permissions": [ + { + "class": "download", + "source_class": null, + "display_name": "Allow download", + "hint": null, + "priority": 30, + "presets": ["viewer", "editor"], + "enabled": true + } + ], + "properties": [], + "permission_preset": "editor" + } + } + } + """ + + let share = try decodeShare(json: json) + + #expect(share.permissionPreset == "editor") + + let permission = try #require(share.permissions.first) + #expect(permission.sourceClass == nil) + #expect(permission.priority == 30) + #expect(permission.presets == ["viewer", "editor"]) + #expect(permission.enabled == true) + + let recipient = try #require(share.recipients.first) + #expect(recipient.secret.updatable == true) + #expect(recipient.secret.url == "https://x/s/abc") + #expect(recipient.secret.value == nil) + #expect(recipient.initiator?.userId == "alice") + } + + @Test("Decodes the sharing capabilities block") + func decodesSharingCapabilities() throws { + let json = """ + { + "api_versions": ["v1"], + "source_types": [ { "class": "file" } ], + "permission_presets": [ { "class": "viewer", "display_name": "Viewer", "hint": null } ] + } + """ + let caps = try JSONDecoder().decode(NKUnifiedSharingCapabilities.self, from: Data(json.utf8)) + + #expect(caps.apiVersions == ["v1"]) + #expect(caps.sourceTypes.first?.class == "file") + #expect(caps.permissionPresets.first?.class == "viewer") + #expect(caps.permissionPresets.first?.displayName == "Viewer") + } +}